oscura 0.0.1__py3-none-any.whl → 0.1.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 (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.0.dist-info/METADATA +300 -0
  460. oscura-0.1.0.dist-info/RECORD +463 -0
  461. oscura-0.1.0.dist-info/entry_points.txt +2 -0
  462. {oscura-0.0.1.dist-info → oscura-0.1.0.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.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,1689 @@
1
+ """Spectral analysis functions for waveform data.
2
+
3
+ This module provides FFT, PSD, and spectral quality metrics
4
+ per IEEE 1241-2010 for ADC characterization.
5
+
6
+
7
+ Example:
8
+ >>> from oscura.analyzers.waveform.spectral import fft, thd, snr
9
+ >>> freq, magnitude = fft(trace)
10
+ >>> thd_db = thd(trace)
11
+ >>> snr_db = snr(trace)
12
+
13
+ References:
14
+ IEEE 1241-2010: Standard for Terminology and Test Methods for
15
+ Analog-to-Digital Converters
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from functools import lru_cache
21
+ from typing import TYPE_CHECKING, Literal
22
+
23
+ import numpy as np
24
+ from scipy import signal as sp_signal
25
+
26
+ from oscura.core.exceptions import AnalysisError, InsufficientDataError
27
+ from oscura.utils.windowing import get_window
28
+
29
+ if TYPE_CHECKING:
30
+ from numpy.typing import NDArray
31
+
32
+ from oscura.core.types import WaveformTrace
33
+
34
+ # Global FFT cache statistics
35
+ _fft_cache_stats = {"hits": 0, "misses": 0, "size": 128}
36
+
37
+
38
+ def get_fft_cache_stats() -> dict[str, int]:
39
+ """Get FFT cache statistics.
40
+
41
+ Returns:
42
+ Dictionary with cache hits, misses, and configured size.
43
+
44
+ Example:
45
+ >>> stats = get_fft_cache_stats()
46
+ >>> print(f"Cache hit rate: {stats['hits'] / (stats['hits'] + stats['misses']):.1%}")
47
+ """
48
+ return _fft_cache_stats.copy()
49
+
50
+
51
+ def clear_fft_cache() -> None:
52
+ """Clear the FFT result cache.
53
+
54
+ Useful for freeing memory or forcing recomputation.
55
+
56
+ Example:
57
+ >>> clear_fft_cache() # Clear cached FFT results
58
+ """
59
+ _compute_fft_cached.cache_clear()
60
+ _fft_cache_stats["hits"] = 0
61
+ _fft_cache_stats["misses"] = 0
62
+
63
+
64
+ def configure_fft_cache(size: int) -> None:
65
+ """Configure FFT cache size.
66
+
67
+ Args:
68
+ size: Maximum number of FFT results to cache (default 128).
69
+
70
+ Example:
71
+ >>> configure_fft_cache(256) # Increase cache size for better hit rate
72
+ """
73
+ global _compute_fft_cached
74
+ _fft_cache_stats["size"] = size
75
+ # Recreate cache with new size
76
+ _compute_fft_cached = lru_cache(maxsize=size)(_compute_fft_impl)
77
+ _fft_cache_stats["hits"] = 0
78
+ _fft_cache_stats["misses"] = 0
79
+
80
+
81
+ def _compute_fft_impl(
82
+ data_bytes: bytes,
83
+ n: int,
84
+ window: str,
85
+ nfft: int,
86
+ detrend_method: str,
87
+ sample_rate: float,
88
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
89
+ """Internal cached FFT implementation.
90
+
91
+ Args:
92
+ data_bytes: Hash-friendly bytes representation of data.
93
+ n: Number of samples.
94
+ window: Window function name.
95
+ nfft: FFT length.
96
+ detrend_method: Detrend method.
97
+ sample_rate: Sample rate in Hz.
98
+
99
+ Returns:
100
+ (freq, magnitude_db, phase) tuple.
101
+ """
102
+ # Reconstruct data from bytes
103
+ data = np.frombuffer(data_bytes, dtype=np.float64)
104
+
105
+ # Apply window
106
+ w = get_window(window, n)
107
+ data_windowed = data * w
108
+
109
+ # Compute FFT
110
+ spectrum = np.fft.rfft(data_windowed, n=nfft)
111
+
112
+ # Frequency axis
113
+ freq = np.fft.rfftfreq(nfft, d=1.0 / sample_rate)
114
+
115
+ # Magnitude in dB (normalized by window coherent gain)
116
+ window_gain = np.sum(w) / n
117
+ magnitude = np.abs(spectrum) / (n * window_gain)
118
+ # Avoid log(0)
119
+ magnitude = np.maximum(magnitude, 1e-20)
120
+ magnitude_db = 20 * np.log10(magnitude)
121
+
122
+ # Phase
123
+ phase = np.angle(spectrum)
124
+
125
+ return freq, magnitude_db, phase
126
+
127
+
128
+ # Create cached version with default size
129
+ _compute_fft_cached = lru_cache(maxsize=128)(_compute_fft_impl)
130
+
131
+
132
+ def fft(
133
+ trace: WaveformTrace,
134
+ *,
135
+ window: str = "hann",
136
+ nfft: int | None = None,
137
+ detrend: Literal["none", "mean", "linear"] = "mean",
138
+ return_phase: bool = False,
139
+ use_cache: bool = True,
140
+ ) -> (
141
+ tuple[NDArray[np.float64], NDArray[np.float64]]
142
+ | tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]
143
+ ):
144
+ """Compute windowed FFT with optional zero-padding and caching.
145
+
146
+ Computes the single-sided magnitude spectrum in dB with optional
147
+ phase output. Uses configurable windowing and zero-padding.
148
+ Results are cached for repeated analysis of the same data.
149
+
150
+ Args:
151
+ trace: Input waveform trace.
152
+ window: Window function name (default "hann").
153
+ nfft: FFT length. If None, uses next power of 2.
154
+ detrend: Detrend method before FFT:
155
+ - "none": No detrending
156
+ - "mean": Remove DC offset (default)
157
+ - "linear": Remove linear trend
158
+ return_phase: If True, also return phase in radians.
159
+ use_cache: If True, cache FFT results for reuse (default True).
160
+
161
+ Returns:
162
+ If return_phase=False:
163
+ (frequencies, magnitude_db) - Frequency axis and magnitude in dB
164
+ If return_phase=True:
165
+ (frequencies, magnitude_db, phase_rad) - Plus phase in radians
166
+
167
+ Raises:
168
+ InsufficientDataError: If trace has fewer than 2 samples.
169
+
170
+ Example:
171
+ >>> freq, mag = fft(trace)
172
+ >>> plt.semilogx(freq, mag)
173
+ >>> plt.xlabel("Frequency (Hz)")
174
+ >>> plt.ylabel("Magnitude (dB)")
175
+ >>> # Check cache performance
176
+ >>> stats = get_fft_cache_stats()
177
+ >>> print(f"Cache hits: {stats['hits']}, misses: {stats['misses']}")
178
+
179
+ References:
180
+ IEEE 1241-2010 Section 4.1.1
181
+ """
182
+ data = trace.data
183
+ n = len(data)
184
+
185
+ if n < 2:
186
+ raise InsufficientDataError(
187
+ "FFT requires at least 2 samples",
188
+ required=2,
189
+ available=n,
190
+ analysis_type="fft",
191
+ )
192
+
193
+ # Detrend
194
+ if detrend == "mean":
195
+ data_processed = data - np.mean(data)
196
+ elif detrend == "linear":
197
+ data_processed = sp_signal.detrend(data, type="linear")
198
+ else:
199
+ data_processed = data
200
+
201
+ # Determine FFT length
202
+ nfft_computed = int(2 ** np.ceil(np.log2(n))) if nfft is None else max(nfft, n)
203
+
204
+ sample_rate = trace.metadata.sample_rate
205
+
206
+ # Use cache if enabled
207
+ if use_cache:
208
+ # Convert to bytes for cache key (hashable)
209
+ data_bytes = data_processed.tobytes()
210
+
211
+ # Call cached implementation
212
+ freq, magnitude_db, phase = _compute_fft_cached(
213
+ data_bytes,
214
+ n,
215
+ window,
216
+ nfft_computed,
217
+ detrend,
218
+ sample_rate,
219
+ )
220
+ _fft_cache_stats["hits"] += 1
221
+
222
+ if return_phase:
223
+ return freq, magnitude_db, phase
224
+ else:
225
+ return freq, magnitude_db
226
+
227
+ # Non-cached path
228
+ _fft_cache_stats["misses"] += 1
229
+
230
+ # Apply window
231
+ w = get_window(window, n)
232
+ data_windowed = data_processed * w
233
+
234
+ # Compute FFT
235
+ spectrum = np.fft.rfft(data_windowed, n=nfft_computed)
236
+
237
+ # Frequency axis
238
+ freq = np.fft.rfftfreq(nfft_computed, d=1.0 / sample_rate)
239
+
240
+ # Magnitude in dB (normalized by window coherent gain)
241
+ window_gain = np.sum(w) / n
242
+ magnitude = np.abs(spectrum) / (n * window_gain)
243
+ # Avoid log(0)
244
+ magnitude = np.maximum(magnitude, 1e-20)
245
+ magnitude_db = 20 * np.log10(magnitude)
246
+
247
+ if return_phase:
248
+ phase = np.angle(spectrum)
249
+ return freq, magnitude_db, phase
250
+ else:
251
+ return freq, magnitude_db
252
+
253
+
254
+ def psd(
255
+ trace: WaveformTrace,
256
+ *,
257
+ window: str = "hann",
258
+ nperseg: int | None = None,
259
+ noverlap: int | None = None,
260
+ nfft: int | None = None,
261
+ scaling: Literal["density", "spectrum"] = "density",
262
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
263
+ """Compute Power Spectral Density using Welch's method.
264
+
265
+ Uses overlapped segment averaging for reduced variance PSD estimation.
266
+
267
+ Args:
268
+ trace: Input waveform trace.
269
+ window: Window function name (default "hann").
270
+ nperseg: Segment length. If None, uses n // 8 or 256, whichever larger.
271
+ noverlap: Overlap between segments. If None, uses nperseg // 2.
272
+ nfft: FFT length per segment. If None, uses nperseg.
273
+ scaling: Output scaling:
274
+ - "density": Power spectral density (V^2/Hz)
275
+ - "spectrum": Power spectrum (V^2)
276
+
277
+ Returns:
278
+ (frequencies, psd) - Frequency axis and PSD in dB/Hz.
279
+
280
+ Raises:
281
+ InsufficientDataError: If trace has insufficient data.
282
+
283
+ Example:
284
+ >>> freq, psd_db = psd(trace)
285
+ >>> plt.semilogx(freq, psd_db)
286
+ >>> plt.ylabel("PSD (dB/Hz)")
287
+
288
+ References:
289
+ Welch, P. D. (1967). "The use of fast Fourier transform for the
290
+ estimation of power spectra"
291
+ """
292
+ data = trace.data
293
+ n = len(data)
294
+
295
+ if n < 16:
296
+ raise InsufficientDataError(
297
+ "PSD requires at least 16 samples",
298
+ required=16,
299
+ available=n,
300
+ analysis_type="psd",
301
+ )
302
+
303
+ sample_rate = trace.metadata.sample_rate
304
+
305
+ # Default segment length
306
+ if nperseg is None:
307
+ nperseg = max(256, n // 8)
308
+ nperseg = min(nperseg, n)
309
+
310
+ # Default overlap (50% for Hann window)
311
+ if noverlap is None:
312
+ noverlap = nperseg // 2
313
+
314
+ freq, psd_linear = sp_signal.welch(
315
+ data,
316
+ fs=sample_rate,
317
+ window=window,
318
+ nperseg=nperseg,
319
+ noverlap=noverlap,
320
+ nfft=nfft,
321
+ scaling=scaling,
322
+ detrend="constant",
323
+ )
324
+
325
+ # Convert to dB
326
+ psd_linear = np.maximum(psd_linear, 1e-20)
327
+ psd_db = 10 * np.log10(psd_linear)
328
+
329
+ return freq, psd_db
330
+
331
+
332
+ def periodogram(
333
+ trace: WaveformTrace,
334
+ *,
335
+ window: str = "hann",
336
+ nfft: int | None = None,
337
+ scaling: Literal["density", "spectrum"] = "density",
338
+ detrend: Literal["constant", "linear", False] = "constant",
339
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
340
+ """Compute classical periodogram PSD estimate.
341
+
342
+ Single-segment PSD estimation using scaled FFT magnitude squared.
343
+
344
+ Args:
345
+ trace: Input waveform trace.
346
+ window: Window function name (default "hann").
347
+ nfft: FFT length. If None, uses data length.
348
+ scaling: Output scaling ("density" or "spectrum").
349
+ detrend: Detrending method.
350
+
351
+ Returns:
352
+ (frequencies, psd) - Frequency axis and PSD.
353
+
354
+ Example:
355
+ >>> freq, psd = periodogram(trace)
356
+
357
+ References:
358
+ IEEE 1241-2010 Section 4.1.2
359
+ """
360
+ sample_rate = trace.metadata.sample_rate
361
+
362
+ freq, psd_linear = sp_signal.periodogram(
363
+ trace.data,
364
+ fs=sample_rate,
365
+ window=window,
366
+ nfft=nfft,
367
+ scaling=scaling,
368
+ detrend=detrend,
369
+ )
370
+
371
+ # Convert to dB
372
+ psd_linear = np.maximum(psd_linear, 1e-20)
373
+ psd_db = 10 * np.log10(psd_linear)
374
+
375
+ return freq, psd_db
376
+
377
+
378
+ def bartlett_psd(
379
+ trace: WaveformTrace,
380
+ *,
381
+ n_segments: int = 8,
382
+ window: str = "rectangular",
383
+ nfft: int | None = None,
384
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
385
+ """Compute Bartlett's method PSD estimate.
386
+
387
+ Averages periodograms of non-overlapping segments for
388
+ reduced variance at cost of frequency resolution.
389
+
390
+ Args:
391
+ trace: Input waveform trace.
392
+ n_segments: Number of non-overlapping segments.
393
+ window: Window function per segment.
394
+ nfft: FFT length per segment.
395
+
396
+ Returns:
397
+ (frequencies, psd_db) - Frequency axis and PSD in dB.
398
+
399
+ Raises:
400
+ AnalysisError: If no segments were processed (empty trace).
401
+ InsufficientDataError: If trace has fewer than 16*n_segments samples.
402
+
403
+ Example:
404
+ >>> freq, psd = bartlett_psd(trace, n_segments=8)
405
+ """
406
+ data = trace.data
407
+ n = len(data)
408
+ sample_rate = trace.metadata.sample_rate
409
+
410
+ segment_length = n // n_segments
411
+
412
+ if segment_length < 16:
413
+ raise InsufficientDataError(
414
+ f"Bartlett requires at least {16 * n_segments} samples for {n_segments} segments",
415
+ required=16 * n_segments,
416
+ available=n,
417
+ analysis_type="bartlett_psd",
418
+ )
419
+
420
+ if nfft is None:
421
+ nfft = segment_length
422
+
423
+ # Accumulate periodograms
424
+ psd_sum = None
425
+ w = get_window(window, segment_length)
426
+ window_power = np.sum(w**2)
427
+
428
+ for i in range(n_segments):
429
+ segment = data[i * segment_length : (i + 1) * segment_length]
430
+ segment_windowed = segment * w
431
+
432
+ spectrum = np.fft.rfft(segment_windowed, n=nfft)
433
+ # Power spectrum (V^2)
434
+ psd_segment = (np.abs(spectrum) ** 2) / (sample_rate * window_power)
435
+
436
+ if psd_sum is None:
437
+ psd_sum = psd_segment
438
+ else:
439
+ psd_sum += psd_segment
440
+
441
+ if psd_sum is None:
442
+ raise AnalysisError("No segments were processed - input trace may be empty")
443
+ psd_avg = psd_sum / n_segments
444
+
445
+ # Frequency axis
446
+ freq = np.fft.rfftfreq(nfft, d=1.0 / sample_rate)
447
+
448
+ # Convert to dB
449
+ psd_avg = np.maximum(psd_avg, 1e-20)
450
+ psd_db = 10 * np.log10(psd_avg)
451
+
452
+ return freq, psd_db
453
+
454
+
455
+ def spectrogram(
456
+ trace: WaveformTrace,
457
+ *,
458
+ window: str = "hann",
459
+ nperseg: int | None = None,
460
+ noverlap: int | None = None,
461
+ nfft: int | None = None,
462
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
463
+ """Compute Short-Time Fourier Transform spectrogram.
464
+
465
+ Time-frequency representation for analyzing non-stationary signals.
466
+
467
+ Args:
468
+ trace: Input waveform trace.
469
+ window: Window function name.
470
+ nperseg: Segment length. If None, auto-selected.
471
+ noverlap: Overlap between segments. If None, uses nperseg - 1.
472
+ nfft: FFT length per segment.
473
+
474
+ Returns:
475
+ (times, frequencies, magnitude_db) - Time axis, frequency axis,
476
+ and magnitude in dB as 2D array.
477
+
478
+ Example:
479
+ >>> t, f, Sxx = spectrogram(trace)
480
+ >>> plt.pcolormesh(t, f, Sxx, shading='auto')
481
+ >>> plt.ylabel('Frequency (Hz)')
482
+ >>> plt.xlabel('Time (s)')
483
+ """
484
+ data = trace.data
485
+ n = len(data)
486
+ sample_rate = trace.metadata.sample_rate
487
+
488
+ if nperseg is None:
489
+ nperseg = min(256, n // 4)
490
+ nperseg = max(nperseg, 16)
491
+
492
+ if noverlap is None:
493
+ noverlap = nperseg - nperseg // 8
494
+
495
+ freq, times, Sxx = sp_signal.spectrogram(
496
+ data,
497
+ fs=sample_rate,
498
+ window=window,
499
+ nperseg=nperseg,
500
+ noverlap=noverlap,
501
+ nfft=nfft,
502
+ scaling="spectrum",
503
+ )
504
+
505
+ # Convert to dB
506
+ Sxx = np.maximum(Sxx, 1e-20)
507
+ Sxx_db = 10 * np.log10(Sxx)
508
+
509
+ return times, freq, Sxx_db
510
+
511
+
512
+ def _find_fundamental(
513
+ freq: NDArray[np.float64],
514
+ magnitude: NDArray[np.float64],
515
+ *,
516
+ min_freq: float = 0.0,
517
+ ) -> tuple[int, float, float]:
518
+ """Find fundamental frequency in spectrum.
519
+
520
+ Args:
521
+ freq: Frequency axis.
522
+ magnitude: Magnitude spectrum (linear, not dB).
523
+ min_freq: Minimum frequency to consider.
524
+
525
+ Returns:
526
+ (index, frequency, magnitude) of fundamental.
527
+ """
528
+ # Skip DC and frequencies below min_freq
529
+ valid_mask = freq > max(min_freq, freq[1])
530
+ valid_indices = np.where(valid_mask)[0]
531
+
532
+ if len(valid_indices) == 0:
533
+ return 0, 0.0, 0.0
534
+
535
+ # Find peak in valid region
536
+ local_peak_idx = np.argmax(magnitude[valid_indices])
537
+ peak_idx = valid_indices[local_peak_idx]
538
+
539
+ return int(peak_idx), float(freq[peak_idx]), float(magnitude[peak_idx])
540
+
541
+
542
+ def _find_harmonic_indices(
543
+ freq: NDArray[np.float64],
544
+ fundamental_freq: float,
545
+ n_harmonics: int,
546
+ ) -> list[int]:
547
+ """Find indices of harmonic frequencies.
548
+
549
+ Args:
550
+ freq: Frequency axis.
551
+ fundamental_freq: Fundamental frequency.
552
+ n_harmonics: Number of harmonics to find.
553
+
554
+ Returns:
555
+ List of indices for harmonics 2, 3, ..., n_harmonics+1.
556
+ """
557
+ indices = []
558
+
559
+ for h in range(2, n_harmonics + 2):
560
+ target_freq = h * fundamental_freq
561
+ if target_freq > freq[-1]:
562
+ break
563
+
564
+ # Find closest bin
565
+ idx = np.argmin(np.abs(freq - target_freq))
566
+ indices.append(int(idx))
567
+
568
+ return indices
569
+
570
+
571
+ def thd(
572
+ trace: WaveformTrace,
573
+ *,
574
+ n_harmonics: int = 10,
575
+ window: str = "hann",
576
+ nfft: int | None = None,
577
+ return_db: bool = True,
578
+ ) -> float:
579
+ """Compute Total Harmonic Distortion.
580
+
581
+ THD is the ratio of harmonic power to fundamental power.
582
+
583
+ Args:
584
+ trace: Input waveform trace.
585
+ n_harmonics: Number of harmonics to include (default 10).
586
+ window: Window function for FFT.
587
+ nfft: FFT length. If None, uses data length (no zero-padding) to
588
+ preserve coherent sampling per IEEE 1241-2010.
589
+ return_db: If True, return in dB. If False, return percentage.
590
+
591
+ Returns:
592
+ THD in dB or percentage.
593
+
594
+ Example:
595
+ >>> thd_db = thd(trace)
596
+ >>> thd_pct = thd(trace, return_db=False)
597
+ >>> print(f"THD: {thd_db:.1f} dB ({thd_pct:.2f}%)")
598
+
599
+ References:
600
+ IEEE 1241-2010 Section 4.1.4.2
601
+ """
602
+ # Use data length as NFFT to avoid zero-padding that breaks coherence
603
+ if nfft is None:
604
+ nfft = len(trace.data)
605
+
606
+ result = fft(trace, window=window, nfft=nfft, detrend="mean")
607
+ freq, mag_db = result[0], result[1]
608
+
609
+ # Convert to linear
610
+ magnitude = 10 ** (mag_db / 20)
611
+
612
+ # Find fundamental
613
+ _fund_idx, fund_freq, fund_mag = _find_fundamental(freq, magnitude)
614
+
615
+ if fund_mag == 0 or fund_freq == 0:
616
+ return np.nan # type: ignore[no-any-return]
617
+
618
+ # Find harmonics
619
+ harmonic_indices = _find_harmonic_indices(freq, fund_freq, n_harmonics)
620
+
621
+ if len(harmonic_indices) == 0:
622
+ return 0.0 if not return_db else -np.inf
623
+
624
+ # Sum harmonic power
625
+ harmonic_power = sum(magnitude[i] ** 2 for i in harmonic_indices)
626
+
627
+ # THD ratio
628
+ thd_ratio = np.sqrt(harmonic_power) / fund_mag
629
+
630
+ if return_db:
631
+ if thd_ratio <= 0:
632
+ return -np.inf # type: ignore[no-any-return]
633
+ return float(20 * np.log10(thd_ratio))
634
+ else:
635
+ return float(thd_ratio * 100)
636
+
637
+
638
+ def snr(
639
+ trace: WaveformTrace,
640
+ *,
641
+ n_harmonics: int = 10,
642
+ window: str = "hann",
643
+ nfft: int | None = None,
644
+ ) -> float:
645
+ """Compute Signal-to-Noise Ratio.
646
+
647
+ SNR is the ratio of signal power to noise power, excluding harmonics.
648
+
649
+ Args:
650
+ trace: Input waveform trace.
651
+ n_harmonics: Number of harmonics to exclude from noise.
652
+ window: Window function for FFT.
653
+ nfft: FFT length. If None, uses data length (no zero-padding) to
654
+ preserve coherent sampling per IEEE 1241-2010.
655
+
656
+ Returns:
657
+ SNR in dB.
658
+
659
+ Example:
660
+ >>> snr_db = snr(trace)
661
+ >>> print(f"SNR: {snr_db:.1f} dB")
662
+
663
+ References:
664
+ IEEE 1241-2010 Section 4.1.4.1
665
+ """
666
+ # Use data length as NFFT to avoid zero-padding that breaks coherence
667
+ if nfft is None:
668
+ nfft = len(trace.data)
669
+
670
+ result = fft(trace, window=window, nfft=nfft, detrend="mean")
671
+ freq, mag_db = result[0], result[1]
672
+ magnitude = 10 ** (mag_db / 20)
673
+
674
+ # Find fundamental
675
+ fund_idx, fund_freq, fund_mag = _find_fundamental(freq, magnitude)
676
+
677
+ if fund_mag == 0 or fund_freq == 0:
678
+ return np.nan # type: ignore[no-any-return]
679
+
680
+ # Find harmonics to exclude
681
+ harmonic_indices = _find_harmonic_indices(freq, fund_freq, n_harmonics)
682
+
683
+ # Build exclusion set: DC, fundamental, and harmonics
684
+ # Also exclude bins adjacent to fundamental and harmonics (spectral leakage)
685
+ exclude_indices = {0} # DC
686
+
687
+ # Exclude fundamental and adjacent bins
688
+ for offset in range(-3, 4): # +/- 3 bins around fundamental
689
+ idx = fund_idx + offset
690
+ if 0 <= idx < len(magnitude):
691
+ exclude_indices.add(idx)
692
+
693
+ # Exclude harmonics and adjacent bins
694
+ for h_idx in harmonic_indices:
695
+ for offset in range(-3, 4): # +/- 3 bins around each harmonic
696
+ idx = h_idx + offset
697
+ if 0 <= idx < len(magnitude):
698
+ exclude_indices.add(idx)
699
+
700
+ # Signal power (fundamental only, using single bin or small window)
701
+ # Use 3-bin sum around fundamental for better estimate
702
+ signal_power = 0.0
703
+ for offset in range(-1, 2):
704
+ idx = fund_idx + offset
705
+ if 0 <= idx < len(magnitude):
706
+ signal_power += magnitude[idx] ** 2
707
+
708
+ # Noise power (all bins except excluded ones)
709
+ noise_power = 0.0
710
+ for i in range(len(magnitude)):
711
+ if i not in exclude_indices:
712
+ noise_power += magnitude[i] ** 2
713
+
714
+ if noise_power <= 0:
715
+ return np.inf # type: ignore[no-any-return]
716
+
717
+ snr_ratio = signal_power / noise_power
718
+ return float(10 * np.log10(snr_ratio))
719
+
720
+
721
+ def sinad(
722
+ trace: WaveformTrace,
723
+ *,
724
+ window: str = "hann",
725
+ nfft: int | None = None,
726
+ ) -> float:
727
+ """Compute Signal-to-Noise and Distortion ratio.
728
+
729
+ SINAD is the ratio of signal power to noise plus distortion power.
730
+
731
+ Args:
732
+ trace: Input waveform trace.
733
+ window: Window function for FFT.
734
+ nfft: FFT length. If None, uses data length (no zero-padding) to
735
+ preserve coherent sampling per IEEE 1241-2010.
736
+
737
+ Returns:
738
+ SINAD in dB.
739
+
740
+ Example:
741
+ >>> sinad_db = sinad(trace)
742
+ >>> print(f"SINAD: {sinad_db:.1f} dB")
743
+
744
+ References:
745
+ IEEE 1241-2010 Section 4.1.4.3
746
+ """
747
+ # Use data length as NFFT to avoid zero-padding that breaks coherence
748
+ if nfft is None:
749
+ nfft = len(trace.data)
750
+
751
+ result = fft(trace, window=window, nfft=nfft, detrend="mean")
752
+ freq, mag_db = result[0], result[1]
753
+ magnitude = 10 ** (mag_db / 20)
754
+
755
+ # Find fundamental
756
+ fund_idx, _fund_freq, fund_mag = _find_fundamental(freq, magnitude)
757
+
758
+ if fund_mag == 0:
759
+ return np.nan # type: ignore[no-any-return]
760
+
761
+ # Signal power: use 3-bin window around fundamental to capture spectral leakage
762
+ signal_power = 0.0
763
+ for offset in range(-1, 2):
764
+ idx = fund_idx + offset
765
+ if 0 <= idx < len(magnitude):
766
+ signal_power += magnitude[idx] ** 2
767
+
768
+ # Total power (exclude DC)
769
+ total_power = np.sum(magnitude[1:] ** 2)
770
+
771
+ # Noise + distortion power = everything except signal
772
+ nad_power = total_power - signal_power
773
+
774
+ if nad_power <= 0:
775
+ return np.inf # type: ignore[no-any-return]
776
+
777
+ sinad_ratio = signal_power / nad_power
778
+ return float(10 * np.log10(sinad_ratio))
779
+
780
+
781
+ def enob(
782
+ trace: WaveformTrace,
783
+ *,
784
+ window: str = "hann",
785
+ nfft: int | None = None,
786
+ ) -> float:
787
+ """Compute Effective Number of Bits from SINAD.
788
+
789
+ ENOB = (SINAD - 1.76) / 6.02
790
+
791
+ Args:
792
+ trace: Input waveform trace.
793
+ window: Window function for FFT.
794
+ nfft: FFT length.
795
+
796
+ Returns:
797
+ ENOB in bits, or np.nan if SINAD is invalid.
798
+
799
+ Example:
800
+ >>> bits = enob(trace)
801
+ >>> print(f"ENOB: {bits:.2f} bits")
802
+
803
+ References:
804
+ IEEE 1241-2010 Section 4.1.4.4
805
+ """
806
+ sinad_db = sinad(trace, window=window, nfft=nfft)
807
+
808
+ if np.isnan(sinad_db) or sinad_db <= 0:
809
+ return np.nan # type: ignore[no-any-return]
810
+
811
+ return float((sinad_db - 1.76) / 6.02)
812
+
813
+
814
+ def sfdr(
815
+ trace: WaveformTrace,
816
+ *,
817
+ window: str = "hann",
818
+ nfft: int | None = None,
819
+ ) -> float:
820
+ """Compute Spurious-Free Dynamic Range.
821
+
822
+ SFDR is the ratio of fundamental to largest spurious component.
823
+
824
+ Args:
825
+ trace: Input waveform trace.
826
+ window: Window function for FFT.
827
+ nfft: FFT length. If None, uses data length (no zero-padding) to
828
+ preserve coherent sampling per IEEE 1241-2010.
829
+
830
+ Returns:
831
+ SFDR in dBc (dB relative to carrier/fundamental).
832
+
833
+ Example:
834
+ >>> sfdr_db = sfdr(trace)
835
+ >>> print(f"SFDR: {sfdr_db:.1f} dBc")
836
+
837
+ References:
838
+ IEEE 1241-2010 Section 4.1.4.5
839
+ """
840
+ # Use data length as NFFT to avoid zero-padding that breaks coherence
841
+ if nfft is None:
842
+ nfft = len(trace.data)
843
+
844
+ result = fft(trace, window=window, nfft=nfft, detrend="mean")
845
+ freq, mag_db = result[0], result[1]
846
+ magnitude = 10 ** (mag_db / 20)
847
+
848
+ # Find fundamental
849
+ fund_idx, _fund_freq, fund_mag = _find_fundamental(freq, magnitude)
850
+
851
+ if fund_mag == 0:
852
+ return np.nan # type: ignore[no-any-return]
853
+
854
+ # Create mask for spurs (exclude fundamental and DC)
855
+ spur_mask = np.ones(len(magnitude), dtype=bool)
856
+ spur_mask[0] = False # DC
857
+ spur_mask[fund_idx] = False
858
+
859
+ # Exclude more bins adjacent to fundamental to account for spectral leakage
860
+ # For Hann window, typical main lobe width is ~4 bins
861
+ for offset in range(-5, 6):
862
+ if offset == 0:
863
+ continue
864
+ idx = fund_idx + offset
865
+ if 0 <= idx < len(magnitude):
866
+ spur_mask[idx] = False
867
+
868
+ # Find largest spur
869
+ spur_magnitudes = magnitude[spur_mask]
870
+ if len(spur_magnitudes) == 0:
871
+ return np.inf # type: ignore[no-any-return]
872
+
873
+ max_spur = np.max(spur_magnitudes)
874
+
875
+ if max_spur <= 0:
876
+ return np.inf # type: ignore[no-any-return]
877
+
878
+ sfdr_ratio = fund_mag / max_spur
879
+ return float(20 * np.log10(sfdr_ratio))
880
+
881
+
882
+ def hilbert_transform(
883
+ trace: WaveformTrace,
884
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
885
+ """Compute Hilbert transform for envelope and instantaneous frequency.
886
+
887
+ Computes the analytic signal to extract envelope (instantaneous
888
+ amplitude), instantaneous phase, and instantaneous frequency.
889
+
890
+ Args:
891
+ trace: Input waveform trace.
892
+
893
+ Returns:
894
+ (envelope, phase, inst_freq) - Instantaneous amplitude,
895
+ phase (radians), and frequency (Hz).
896
+
897
+ Example:
898
+ >>> envelope, phase, inst_freq = hilbert_transform(trace)
899
+ >>> plt.plot(trace.time_vector, envelope)
900
+
901
+ References:
902
+ Oppenheim, A. V. & Schafer, R. W. (2009). Discrete-Time
903
+ Signal Processing, 3rd ed.
904
+ """
905
+ data = trace.data
906
+ sample_rate = trace.metadata.sample_rate
907
+
908
+ # Compute analytic signal
909
+ analytic = sp_signal.hilbert(data)
910
+
911
+ # Instantaneous amplitude (envelope)
912
+ envelope = np.abs(analytic)
913
+
914
+ # Instantaneous phase
915
+ phase = np.unwrap(np.angle(analytic))
916
+
917
+ # Instantaneous frequency (derivative of phase / 2pi)
918
+ inst_freq = np.zeros_like(phase)
919
+ inst_freq[1:] = np.diff(phase) * sample_rate / (2 * np.pi)
920
+ inst_freq[0] = inst_freq[1] # Extrapolate first sample
921
+
922
+ return envelope, phase, inst_freq
923
+
924
+
925
+ def cwt(
926
+ trace: WaveformTrace,
927
+ *,
928
+ wavelet: Literal["morlet", "mexh", "ricker"] = "morlet",
929
+ scales: NDArray[np.float64] | None = None,
930
+ n_scales: int = 64,
931
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
932
+ """Compute Continuous Wavelet Transform for time-frequency analysis.
933
+
934
+ Uses CWT to analyze non-stationary signals with multi-resolution
935
+ time-frequency representation.
936
+
937
+ Args:
938
+ trace: Input waveform trace.
939
+ wavelet: Wavelet type:
940
+ - "morlet": Morlet wavelet (complex, good for frequency localization)
941
+ - "mexh": Mexican hat wavelet (real, good for feature detection)
942
+ - "ricker": Ricker wavelet (real, synonym for Mexican hat)
943
+ scales: Array of scales to use. If None, auto-generated logarithmically.
944
+ n_scales: Number of scales if auto-generated (default 64).
945
+
946
+ Returns:
947
+ (scales, frequencies, coefficients) where coefficients is 2D array
948
+ of shape (n_scales, n_samples).
949
+
950
+ Raises:
951
+ InsufficientDataError: If trace has fewer than 8 samples.
952
+ ValueError: If wavelet type is not recognized.
953
+
954
+ Example:
955
+ >>> scales, freqs, coef = cwt(trace, wavelet="morlet")
956
+ >>> plt.pcolormesh(trace.time_vector, freqs, np.abs(coef))
957
+ >>> plt.ylabel("Frequency (Hz)")
958
+
959
+ References:
960
+ Mallat, S. (2009). A Wavelet Tour of Signal Processing, 3rd ed.
961
+ """
962
+ data = trace.data
963
+ sample_rate = trace.metadata.sample_rate
964
+
965
+ if len(data) < 8:
966
+ raise InsufficientDataError(
967
+ "CWT requires at least 8 samples",
968
+ required=8,
969
+ available=len(data),
970
+ analysis_type="cwt",
971
+ )
972
+
973
+ # Auto-generate scales if not provided
974
+ if scales is None:
975
+ # Logarithmically spaced scales from 1 to n/8
976
+ scales = np.logspace(0, np.log10(len(data) / 8), n_scales)
977
+
978
+ # Select wavelet function
979
+ if wavelet == "morlet":
980
+ # Morlet wavelet (complex): good for frequency analysis
981
+ widths = scales
982
+ coefficients = sp_signal.cwt(data, sp_signal.morlet2, widths)
983
+ elif wavelet in ("mexh", "ricker"):
984
+ # Mexican hat wavelet (Ricker): real, good for edge detection
985
+ widths = scales
986
+ coefficients = sp_signal.cwt(data, sp_signal.ricker, widths)
987
+ else:
988
+ raise ValueError(f"Unknown wavelet: {wavelet}. Choose from: morlet, mexh, ricker")
989
+
990
+ # Convert scales to frequencies
991
+ # For Morlet: f = fc / (scale * dt) where fc = center frequency
992
+ # For simplicity, use approximate relation: f = 1 / (scale * dt)
993
+ dt = 1.0 / sample_rate
994
+ frequencies = 1.0 / (scales * dt)
995
+
996
+ return scales, frequencies, coefficients
997
+
998
+
999
+ def dwt(
1000
+ trace: WaveformTrace,
1001
+ *,
1002
+ wavelet: str = "db4",
1003
+ level: int | None = None,
1004
+ mode: str = "symmetric",
1005
+ ) -> dict[str, NDArray[np.float64]]:
1006
+ """Compute Discrete Wavelet Transform for multi-level decomposition.
1007
+
1008
+ Decomposes signal into approximation (low-frequency) and detail
1009
+ (high-frequency) coefficients at multiple levels.
1010
+
1011
+ Args:
1012
+ trace: Input waveform trace.
1013
+ wavelet: Wavelet family:
1014
+ - "dbN": Daubechies wavelets (e.g., "db1", "db4", "db8")
1015
+ - "symN": Symlet wavelets (e.g., "sym2", "sym8")
1016
+ - "coifN": Coiflet wavelets (e.g., "coif1", "coif5")
1017
+ level: Decomposition level (auto-computed if None).
1018
+ mode: Signal extension mode ("symmetric", "periodic", "zero", etc.).
1019
+
1020
+ Returns:
1021
+ Dictionary with keys:
1022
+ - "cA": Final approximation coefficients
1023
+ - "cD1", "cD2", ...: Detail coefficients at each level
1024
+
1025
+ Raises:
1026
+ AnalysisError: If DWT decomposition fails.
1027
+ ImportError: If PyWavelets library is not installed.
1028
+ InsufficientDataError: If trace has fewer than 4 samples.
1029
+
1030
+ Example:
1031
+ >>> coeffs = dwt(trace, wavelet="db4", level=3)
1032
+ >>> print(f"Approximation: {len(coeffs['cA'])} coefficients")
1033
+ >>> print(f"Detail levels: {[k for k in coeffs if k.startswith('cD')]}")
1034
+
1035
+ Note:
1036
+ Requires pywt library. Install with: pip install PyWavelets
1037
+
1038
+ References:
1039
+ Daubechies, I. (1992). Ten Lectures on Wavelets
1040
+ """
1041
+ try:
1042
+ import pywt
1043
+ except ImportError:
1044
+ raise ImportError( # noqa: B904
1045
+ "DWT requires PyWavelets library. Install with: pip install PyWavelets"
1046
+ )
1047
+
1048
+ data = trace.data
1049
+
1050
+ if len(data) < 4:
1051
+ raise InsufficientDataError(
1052
+ "DWT requires at least 4 samples",
1053
+ required=4,
1054
+ available=len(data),
1055
+ analysis_type="dwt",
1056
+ )
1057
+
1058
+ # Auto-select decomposition level
1059
+ if level is None:
1060
+ level = pywt.dwt_max_level(len(data), wavelet)
1061
+ # Limit to reasonable levels
1062
+ level = min(level, 8)
1063
+
1064
+ try:
1065
+ # Perform multi-level DWT
1066
+ coeffs = pywt.wavedec(data, wavelet, mode=mode, level=level)
1067
+ except ValueError as e:
1068
+ raise AnalysisError(f"DWT decomposition failed: {e}", analysis_type="dwt") # noqa: B904
1069
+
1070
+ # Package into dictionary
1071
+ result = {"cA": coeffs[0]} # Approximation coefficients
1072
+
1073
+ for i, detail in enumerate(coeffs[1:], start=1):
1074
+ result[f"cD{i}"] = detail
1075
+
1076
+ return result
1077
+
1078
+
1079
+ def idwt(
1080
+ coeffs: dict[str, NDArray[np.float64]],
1081
+ *,
1082
+ wavelet: str = "db4",
1083
+ mode: str = "symmetric",
1084
+ ) -> NDArray[np.float64]:
1085
+ """Reconstruct signal from DWT coefficients.
1086
+
1087
+ Performs inverse DWT to reconstruct the original signal from
1088
+ approximation and detail coefficients.
1089
+
1090
+ Args:
1091
+ coeffs: Dictionary of DWT coefficients from dwt().
1092
+ wavelet: Wavelet family (must match original decomposition).
1093
+ mode: Signal extension mode (must match original decomposition).
1094
+
1095
+ Returns:
1096
+ Reconstructed signal array.
1097
+
1098
+ Raises:
1099
+ AnalysisError: If IDWT reconstruction fails.
1100
+ ImportError: If PyWavelets library is not installed.
1101
+
1102
+ Example:
1103
+ >>> coeffs = dwt(trace, wavelet="db4")
1104
+ >>> # Modify coefficients (e.g., denoise)
1105
+ >>> coeffs["cD1"] *= 0 # Remove finest detail
1106
+ >>> reconstructed = idwt(coeffs, wavelet="db4")
1107
+ """
1108
+ try:
1109
+ import pywt
1110
+ except ImportError:
1111
+ raise ImportError( # noqa: B904
1112
+ "IDWT requires PyWavelets library. Install with: pip install PyWavelets"
1113
+ )
1114
+
1115
+ # Reconstruct coefficient list
1116
+ cA = coeffs["cA"]
1117
+
1118
+ # Get detail levels in order
1119
+ detail_keys = sorted(
1120
+ [k for k in coeffs if k.startswith("cD")],
1121
+ key=lambda x: int(x[2:]),
1122
+ )
1123
+ details = [coeffs[k] for k in detail_keys]
1124
+
1125
+ # Combine into pywt format
1126
+ coeff_list = [cA, *details]
1127
+
1128
+ try:
1129
+ reconstructed = pywt.waverec(coeff_list, wavelet, mode=mode)
1130
+ except ValueError as e:
1131
+ raise AnalysisError(f"IDWT reconstruction failed: {e}", analysis_type="idwt") # noqa: B904
1132
+
1133
+ return np.asarray(reconstructed, dtype=np.float64)
1134
+
1135
+
1136
+ def mfcc(
1137
+ trace: WaveformTrace,
1138
+ *,
1139
+ n_mfcc: int = 13,
1140
+ n_fft: int = 512,
1141
+ hop_length: int | None = None,
1142
+ n_mels: int = 40,
1143
+ fmin: float = 0.0,
1144
+ fmax: float | None = None,
1145
+ ) -> NDArray[np.float64]:
1146
+ """Compute Mel-Frequency Cepstral Coefficients for audio analysis.
1147
+
1148
+ MFCCs are widely used in speech and audio processing for feature
1149
+ extraction and pattern recognition.
1150
+
1151
+ Args:
1152
+ trace: Input waveform trace.
1153
+ n_mfcc: Number of MFCC coefficients to return (default 13).
1154
+ n_fft: FFT window size (default 512).
1155
+ hop_length: Number of samples between frames. If None, uses n_fft // 4.
1156
+ n_mels: Number of Mel filterbank channels (default 40).
1157
+ fmin: Minimum frequency for Mel filterbank (Hz).
1158
+ fmax: Maximum frequency for Mel filterbank (default: sample_rate/2).
1159
+
1160
+ Returns:
1161
+ 2D array of shape (n_mfcc, n_frames) with MFCC time series.
1162
+
1163
+ Raises:
1164
+ InsufficientDataError: If trace has fewer than n_fft samples.
1165
+
1166
+ Example:
1167
+ >>> mfcc_features = mfcc(audio_trace, n_mfcc=13)
1168
+ >>> print(f"MFCCs: {mfcc_features.shape[0]} coefficients, {mfcc_features.shape[1]} frames")
1169
+
1170
+ Note:
1171
+ This is a custom implementation. For production use, consider librosa.
1172
+
1173
+ References:
1174
+ Davis, S. & Mermelstein, P. (1980). "Comparison of parametric
1175
+ representations for monosyllabic word recognition"
1176
+ """
1177
+ data = trace.data
1178
+ sample_rate = trace.metadata.sample_rate
1179
+
1180
+ if len(data) < n_fft:
1181
+ raise InsufficientDataError(
1182
+ f"MFCC requires at least {n_fft} samples",
1183
+ required=n_fft,
1184
+ available=len(data),
1185
+ analysis_type="mfcc",
1186
+ )
1187
+
1188
+ if hop_length is None:
1189
+ hop_length = n_fft // 4
1190
+
1191
+ if fmax is None:
1192
+ fmax = sample_rate / 2
1193
+
1194
+ # Compute STFT (Short-Time Fourier Transform)
1195
+ # Use scipy's spectrogram as a proxy
1196
+ _f, _t, Sxx = sp_signal.spectrogram(
1197
+ data,
1198
+ fs=sample_rate,
1199
+ window="hann",
1200
+ nperseg=n_fft,
1201
+ noverlap=n_fft - hop_length,
1202
+ scaling="spectrum",
1203
+ )
1204
+
1205
+ # Power spectrum magnitude
1206
+ power_spec = np.abs(Sxx)
1207
+
1208
+ # Create Mel filterbank
1209
+ mel_filters = _mel_filterbank(n_mels, n_fft, sample_rate, fmin, fmax)
1210
+
1211
+ # Apply Mel filterbank to power spectrum
1212
+ mel_spec = mel_filters @ power_spec
1213
+
1214
+ # Convert to log scale (dB)
1215
+ mel_spec = np.maximum(mel_spec, 1e-10)
1216
+ log_mel_spec = 10 * np.log10(mel_spec)
1217
+
1218
+ # Compute DCT (Discrete Cosine Transform) to get cepstral coefficients
1219
+ # Use scipy.fft.dct
1220
+ from scipy.fft import dct
1221
+
1222
+ mfcc_features = dct(log_mel_spec, axis=0, type=2, norm="ortho")[:n_mfcc, :]
1223
+
1224
+ return np.asarray(mfcc_features, dtype=np.float64)
1225
+
1226
+
1227
+ def _mel_filterbank(
1228
+ n_filters: int,
1229
+ n_fft: int,
1230
+ sample_rate: float,
1231
+ fmin: float,
1232
+ fmax: float,
1233
+ ) -> NDArray[np.float64]:
1234
+ """Create Mel-scale filterbank matrix.
1235
+
1236
+ Args:
1237
+ n_filters: Number of Mel filters.
1238
+ n_fft: FFT size.
1239
+ sample_rate: Sampling rate in Hz.
1240
+ fmin: Minimum frequency (Hz).
1241
+ fmax: Maximum frequency (Hz).
1242
+
1243
+ Returns:
1244
+ Filterbank matrix of shape (n_filters, n_fft // 2 + 1).
1245
+ """
1246
+
1247
+ def hz_to_mel(hz: float) -> float:
1248
+ """Convert Hz to Mel scale."""
1249
+ return 2595 * np.log10(1 + hz / 700) # type: ignore[no-any-return]
1250
+
1251
+ def mel_to_hz(mel: float) -> float:
1252
+ """Convert Mel scale to Hz."""
1253
+ return 700 * (10 ** (mel / 2595) - 1)
1254
+
1255
+ # Convert frequency range to Mel scale
1256
+ mel_min = hz_to_mel(fmin)
1257
+ mel_max = hz_to_mel(fmax)
1258
+
1259
+ # Create n_filters + 2 equally spaced points in Mel scale
1260
+ mel_points = np.linspace(mel_min, mel_max, n_filters + 2)
1261
+
1262
+ # Convert back to Hz
1263
+ hz_points = np.array([mel_to_hz(float(m)) for m in mel_points])
1264
+
1265
+ # Convert Hz to FFT bin indices
1266
+ n_freqs = n_fft // 2 + 1
1267
+ freq_bins = np.floor((n_fft + 1) * hz_points / sample_rate).astype(int)
1268
+
1269
+ # Create filterbank
1270
+ filterbank = np.zeros((n_filters, n_freqs))
1271
+
1272
+ for i in range(n_filters):
1273
+ left = freq_bins[i]
1274
+ center = freq_bins[i + 1]
1275
+ right = freq_bins[i + 2]
1276
+
1277
+ # Rising slope
1278
+ for j in range(left, center):
1279
+ if center > left:
1280
+ filterbank[i, j] = (j - left) / (center - left)
1281
+
1282
+ # Falling slope
1283
+ for j in range(center, right):
1284
+ if right > center:
1285
+ filterbank[i, j] = (right - j) / (right - center)
1286
+
1287
+ return filterbank
1288
+
1289
+
1290
+ # ==========================================================================
1291
+ # MEM-004, MEM-005, MEM-006: Chunked Processing for Large Signals
1292
+ # ==========================================================================
1293
+
1294
+
1295
+ def spectrogram_chunked(
1296
+ trace: WaveformTrace,
1297
+ *,
1298
+ chunk_size: int = 100_000_000,
1299
+ window: str = "hann",
1300
+ nperseg: int | None = None,
1301
+ noverlap: int | None = None,
1302
+ nfft: int | None = None,
1303
+ overlap_factor: float = 2.0,
1304
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
1305
+ """Compute spectrogram for very large signals using chunked processing.
1306
+
1307
+
1308
+ Processes signal in chunks with overlap to handle files larger than RAM.
1309
+ Stitches STFT results from overlapping chunks to create continuous spectrogram.
1310
+
1311
+ Args:
1312
+ trace: Input waveform trace.
1313
+ chunk_size: Maximum samples per chunk (default 100M).
1314
+ window: Window function name.
1315
+ nperseg: Segment length for STFT. If None, auto-selected.
1316
+ noverlap: Overlap between STFT segments. If None, uses nperseg - nperseg // 8.
1317
+ nfft: FFT length per segment.
1318
+ overlap_factor: Overlap factor between chunks (default 2.0 = 2*nperseg overlap).
1319
+
1320
+ Returns:
1321
+ (times, frequencies, magnitude_db) - Time axis, frequency axis,
1322
+ and magnitude in dB as 2D array.
1323
+
1324
+ Example:
1325
+ >>> # Process 10 GB file in 50M sample chunks
1326
+ >>> t, f, Sxx = spectrogram_chunked(trace, chunk_size=50_000_000, nperseg=4096)
1327
+ >>> print(f"Spectrogram shape: {Sxx.shape}")
1328
+
1329
+ References:
1330
+ scipy.signal.stft documentation
1331
+ """
1332
+ data = trace.data
1333
+ n = len(data)
1334
+ sample_rate = trace.metadata.sample_rate
1335
+
1336
+ # Set default parameters
1337
+ if nperseg is None:
1338
+ nperseg = min(256, n // 4)
1339
+ nperseg = max(nperseg, 16)
1340
+
1341
+ if noverlap is None:
1342
+ noverlap = nperseg - nperseg // 8
1343
+
1344
+ # Calculate chunk overlap (overlap_factor * nperseg on each boundary)
1345
+ chunk_overlap = int(overlap_factor * nperseg)
1346
+
1347
+ # If data fits in one chunk, use standard spectrogram
1348
+ if n <= chunk_size:
1349
+ return spectrogram(trace, window=window, nperseg=nperseg, noverlap=noverlap, nfft=nfft)
1350
+
1351
+ # Process chunks
1352
+ chunks_stft = []
1353
+ chunks_times = []
1354
+ chunk_start = 0
1355
+
1356
+ while chunk_start < n:
1357
+ # Determine chunk end with overlap
1358
+ chunk_end = min(chunk_start + chunk_size, n)
1359
+
1360
+ # Extract chunk with overlap on both sides
1361
+ chunk_data_start = chunk_start - chunk_overlap if chunk_start > 0 else 0
1362
+
1363
+ chunk_data_end = chunk_end + chunk_overlap if chunk_end < n else n
1364
+
1365
+ chunk_data = data[chunk_data_start:chunk_data_end]
1366
+
1367
+ # Compute STFT for chunk
1368
+ freq, times_chunk, Sxx_chunk = sp_signal.spectrogram(
1369
+ chunk_data,
1370
+ fs=sample_rate,
1371
+ window=window,
1372
+ nperseg=nperseg,
1373
+ noverlap=noverlap,
1374
+ nfft=nfft,
1375
+ scaling="spectrum",
1376
+ )
1377
+
1378
+ # Adjust time offset for chunk position
1379
+ time_offset = chunk_data_start / sample_rate
1380
+ times_chunk_adjusted = times_chunk + time_offset
1381
+
1382
+ # For overlapping chunks, trim overlap regions
1383
+ if chunk_start > 0 and chunk_end < n:
1384
+ # Middle chunk: trim both sides
1385
+ valid_time_start = chunk_start / sample_rate
1386
+ valid_time_end = chunk_end / sample_rate
1387
+ valid_mask = (times_chunk_adjusted >= valid_time_start) & (
1388
+ times_chunk_adjusted < valid_time_end
1389
+ )
1390
+ Sxx_chunk = Sxx_chunk[:, valid_mask]
1391
+ times_chunk_adjusted = times_chunk_adjusted[valid_mask]
1392
+ elif chunk_start > 0:
1393
+ # Last chunk: trim left overlap
1394
+ valid_time_start = chunk_start / sample_rate
1395
+ valid_mask = times_chunk_adjusted >= valid_time_start
1396
+ Sxx_chunk = Sxx_chunk[:, valid_mask]
1397
+ times_chunk_adjusted = times_chunk_adjusted[valid_mask]
1398
+ elif chunk_end < n:
1399
+ # First chunk: trim right overlap
1400
+ valid_time_end = chunk_end / sample_rate
1401
+ valid_mask = times_chunk_adjusted < valid_time_end
1402
+ Sxx_chunk = Sxx_chunk[:, valid_mask]
1403
+ times_chunk_adjusted = times_chunk_adjusted[valid_mask]
1404
+
1405
+ chunks_stft.append(Sxx_chunk)
1406
+ chunks_times.append(times_chunk_adjusted)
1407
+
1408
+ # Move to next chunk
1409
+ chunk_start += chunk_size
1410
+
1411
+ # Concatenate all chunks
1412
+ Sxx = np.concatenate(chunks_stft, axis=1)
1413
+ times = np.concatenate(chunks_times)
1414
+
1415
+ # Convert to dB
1416
+ Sxx = np.maximum(Sxx, 1e-20)
1417
+ Sxx_db = 10 * np.log10(Sxx)
1418
+
1419
+ return times, freq, Sxx_db
1420
+
1421
+
1422
+ def psd_chunked(
1423
+ trace: WaveformTrace,
1424
+ *,
1425
+ chunk_size: int = 100_000_000,
1426
+ window: str = "hann",
1427
+ nperseg: int | None = None,
1428
+ noverlap: int | None = None,
1429
+ nfft: int | None = None,
1430
+ scaling: Literal["density", "spectrum"] = "density",
1431
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
1432
+ """Compute Welch PSD for very large signals using chunked processing.
1433
+
1434
+
1435
+ Processes signal in chunks with proper overlap handling to compute
1436
+ Power Spectral Density for files larger than available RAM. The result
1437
+ is equivalent to computing Welch PSD on the entire signal but with
1438
+ bounded memory usage.
1439
+
1440
+ Args:
1441
+ trace: Input waveform trace.
1442
+ chunk_size: Maximum samples per chunk (default 100M).
1443
+ window: Window function name.
1444
+ nperseg: Segment length for Welch. If None, auto-selected.
1445
+ noverlap: Overlap between Welch segments. If None, uses nperseg // 2.
1446
+ nfft: FFT length per segment.
1447
+ scaling: Output scaling ("density" or "spectrum").
1448
+
1449
+ Returns:
1450
+ (frequencies, psd_db) - Frequency axis and PSD in dB.
1451
+
1452
+ Example:
1453
+ >>> # Process 10 GB file in 50M sample chunks
1454
+ >>> freq, psd = psd_chunked(trace, chunk_size=50_000_000, nperseg=4096)
1455
+ >>> print(f"Frequency resolution: {freq[1] - freq[0]:.3f} Hz")
1456
+
1457
+ Note:
1458
+ Memory usage is bounded by chunk_size, not file size.
1459
+ The result may differ slightly from standard psd() due to
1460
+ chunk boundary handling, but variance is typically reduced
1461
+ due to increased averaging.
1462
+
1463
+ References:
1464
+ Welch, P. D. (1967). "The use of fast Fourier transform for the
1465
+ estimation of power spectra"
1466
+ """
1467
+ data = trace.data
1468
+ n = len(data)
1469
+ sample_rate = trace.metadata.sample_rate
1470
+
1471
+ # Set default parameters
1472
+ if nperseg is None:
1473
+ nperseg = max(256, min(n // 8, chunk_size // 8))
1474
+ nperseg = min(nperseg, n)
1475
+
1476
+ if noverlap is None:
1477
+ noverlap = nperseg // 2
1478
+
1479
+ if nfft is None:
1480
+ nfft = nperseg
1481
+
1482
+ # If data fits in one chunk, use standard PSD
1483
+ if n <= chunk_size:
1484
+ return psd(
1485
+ trace,
1486
+ window=window,
1487
+ nperseg=nperseg,
1488
+ noverlap=noverlap,
1489
+ nfft=nfft,
1490
+ scaling=scaling,
1491
+ )
1492
+
1493
+ # Calculate chunk overlap to ensure proper segment handling at boundaries
1494
+ # Overlap should be at least nperseg to ensure no gaps in segment coverage
1495
+ chunk_overlap = nperseg
1496
+
1497
+ # Accumulate PSD estimates
1498
+ psd_sum: NDArray[np.float64] | None = None
1499
+ total_segments = 0
1500
+ freq: NDArray[np.float64] | None = None
1501
+
1502
+ chunk_start = 0
1503
+ while chunk_start < n:
1504
+ # Determine chunk boundaries with overlap
1505
+ chunk_data_start = max(0, chunk_start - chunk_overlap)
1506
+ chunk_end = min(chunk_start + chunk_size, n)
1507
+ chunk_data_end = min(chunk_end + chunk_overlap, n)
1508
+
1509
+ # Extract chunk
1510
+ chunk_data = data[chunk_data_start:chunk_data_end]
1511
+
1512
+ if len(chunk_data) < nperseg:
1513
+ # Last chunk too small, skip
1514
+ break
1515
+
1516
+ # Compute Welch PSD for chunk
1517
+ f, psd_linear = sp_signal.welch(
1518
+ chunk_data,
1519
+ fs=sample_rate,
1520
+ window=window,
1521
+ nperseg=nperseg,
1522
+ noverlap=noverlap,
1523
+ nfft=nfft,
1524
+ scaling=scaling,
1525
+ detrend="constant",
1526
+ )
1527
+
1528
+ # Count number of segments in this chunk
1529
+ hop = nperseg - noverlap
1530
+ num_segments = max(1, (len(chunk_data) - noverlap) // hop)
1531
+
1532
+ if psd_sum is None:
1533
+ psd_sum = psd_linear * num_segments
1534
+ freq = f
1535
+ else:
1536
+ psd_sum += psd_linear * num_segments
1537
+
1538
+ total_segments += num_segments
1539
+
1540
+ # Move to next chunk
1541
+ chunk_start += chunk_size
1542
+
1543
+ if psd_sum is None or total_segments == 0 or freq is None:
1544
+ # Fallback to standard PSD if something went wrong
1545
+ return psd(
1546
+ trace,
1547
+ window=window,
1548
+ nperseg=nperseg,
1549
+ noverlap=noverlap,
1550
+ nfft=nfft,
1551
+ scaling=scaling,
1552
+ )
1553
+
1554
+ # Average across all segments
1555
+ psd_avg = psd_sum / total_segments
1556
+
1557
+ # Convert to dB
1558
+ psd_avg = np.maximum(psd_avg, 1e-20)
1559
+ psd_db = 10 * np.log10(psd_avg)
1560
+
1561
+ return freq, psd_db
1562
+
1563
+
1564
+ def fft_chunked(
1565
+ trace: WaveformTrace,
1566
+ *,
1567
+ segment_size: int = 1_000_000,
1568
+ overlap_pct: float = 50.0,
1569
+ window: str = "hann",
1570
+ nfft: int | None = None,
1571
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
1572
+ """Compute FFT for very long signals using segmented processing.
1573
+
1574
+
1575
+ Divides signal into overlapping segments, computes FFT for each,
1576
+ and averages the magnitude spectra to reduce variance.
1577
+
1578
+ Args:
1579
+ trace: Input waveform trace.
1580
+ segment_size: Size of each segment in samples.
1581
+ overlap_pct: Percentage overlap between segments (0-100).
1582
+ window: Window function name.
1583
+ nfft: FFT length. If None, uses segment_size.
1584
+
1585
+ Returns:
1586
+ (frequencies, magnitude_db) - Frequency axis and averaged magnitude in dB.
1587
+
1588
+ Raises:
1589
+ AnalysisError: If no segments were processed (empty trace).
1590
+
1591
+ Example:
1592
+ >>> # Process 1 GB signal in 1M sample segments with 50% overlap
1593
+ >>> freq, mag = fft_chunked(trace, segment_size=1_000_000, overlap_pct=50)
1594
+ >>> print(f"Frequency resolution: {freq[1] - freq[0]:.3f} Hz")
1595
+
1596
+ References:
1597
+ Welch's method for spectral estimation
1598
+ """
1599
+ data = trace.data
1600
+ n = len(data)
1601
+ sample_rate = trace.metadata.sample_rate
1602
+
1603
+ if n < segment_size:
1604
+ # Use standard FFT if data fits in one segment
1605
+ result = fft(trace, window=window, nfft=nfft)
1606
+ return result[0], result[1] # type: ignore[return-value]
1607
+
1608
+ # Calculate overlap
1609
+ overlap_samples = int(segment_size * overlap_pct / 100.0)
1610
+ hop = segment_size - overlap_samples
1611
+
1612
+ # Determine number of segments
1613
+ num_segments = max(1, (n - overlap_samples) // hop)
1614
+
1615
+ if nfft is None:
1616
+ nfft = int(2 ** np.ceil(np.log2(segment_size)))
1617
+
1618
+ # Accumulate magnitude spectra
1619
+ freq: NDArray[np.float64] | None = None
1620
+ magnitude_sum: NDArray[np.float64] | None = None
1621
+ w = get_window(window, segment_size)
1622
+ window_gain = np.sum(w) / segment_size
1623
+
1624
+ for i in range(num_segments):
1625
+ start = i * hop
1626
+ end = min(start + segment_size, n)
1627
+
1628
+ if end - start < segment_size:
1629
+ # Last segment might be shorter, pad with zeros
1630
+ segment = np.zeros(segment_size)
1631
+ segment[: end - start] = data[start:end]
1632
+ else:
1633
+ segment = data[start:end]
1634
+
1635
+ # Detrend
1636
+ segment = segment - np.mean(segment)
1637
+
1638
+ # Window
1639
+ segment_windowed = segment * w
1640
+
1641
+ # FFT
1642
+ spectrum = np.fft.rfft(segment_windowed, n=nfft)
1643
+
1644
+ # Magnitude
1645
+ magnitude = np.abs(spectrum) / (segment_size * window_gain)
1646
+
1647
+ if magnitude_sum is None:
1648
+ magnitude_sum = magnitude
1649
+ freq = np.fft.rfftfreq(nfft, d=1.0 / sample_rate)
1650
+ else:
1651
+ magnitude_sum += magnitude
1652
+
1653
+ # Average
1654
+ if magnitude_sum is None:
1655
+ raise AnalysisError("No segments were processed - input trace may be empty")
1656
+ if freq is None:
1657
+ raise AnalysisError("Frequency array was not initialized - internal error")
1658
+ magnitude_avg = magnitude_sum / num_segments
1659
+
1660
+ # Convert to dB
1661
+ magnitude_avg = np.maximum(magnitude_avg, 1e-20)
1662
+ magnitude_db = 20 * np.log10(magnitude_avg)
1663
+
1664
+ return freq, magnitude_db
1665
+
1666
+
1667
+ __all__ = [
1668
+ "bartlett_psd",
1669
+ "clear_fft_cache",
1670
+ "configure_fft_cache",
1671
+ "cwt",
1672
+ "dwt",
1673
+ "enob",
1674
+ "fft",
1675
+ "fft_chunked",
1676
+ "get_fft_cache_stats",
1677
+ "hilbert_transform",
1678
+ "idwt",
1679
+ "mfcc",
1680
+ "periodogram",
1681
+ "psd",
1682
+ "psd_chunked",
1683
+ "sfdr",
1684
+ "sinad",
1685
+ "snr",
1686
+ "spectrogram",
1687
+ "spectrogram_chunked",
1688
+ "thd",
1689
+ ]