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.
- oscura/__init__.py +813 -8
- oscura/__main__.py +392 -0
- oscura/analyzers/__init__.py +37 -0
- oscura/analyzers/digital/__init__.py +177 -0
- oscura/analyzers/digital/bus.py +691 -0
- oscura/analyzers/digital/clock.py +805 -0
- oscura/analyzers/digital/correlation.py +720 -0
- oscura/analyzers/digital/edges.py +632 -0
- oscura/analyzers/digital/extraction.py +413 -0
- oscura/analyzers/digital/quality.py +878 -0
- oscura/analyzers/digital/signal_quality.py +877 -0
- oscura/analyzers/digital/thresholds.py +708 -0
- oscura/analyzers/digital/timing.py +1104 -0
- oscura/analyzers/eye/__init__.py +46 -0
- oscura/analyzers/eye/diagram.py +434 -0
- oscura/analyzers/eye/metrics.py +555 -0
- oscura/analyzers/jitter/__init__.py +83 -0
- oscura/analyzers/jitter/ber.py +333 -0
- oscura/analyzers/jitter/decomposition.py +759 -0
- oscura/analyzers/jitter/measurements.py +413 -0
- oscura/analyzers/jitter/spectrum.py +220 -0
- oscura/analyzers/measurements.py +40 -0
- oscura/analyzers/packet/__init__.py +171 -0
- oscura/analyzers/packet/daq.py +1077 -0
- oscura/analyzers/packet/metrics.py +437 -0
- oscura/analyzers/packet/parser.py +327 -0
- oscura/analyzers/packet/payload.py +2156 -0
- oscura/analyzers/packet/payload_analysis.py +1312 -0
- oscura/analyzers/packet/payload_extraction.py +236 -0
- oscura/analyzers/packet/payload_patterns.py +670 -0
- oscura/analyzers/packet/stream.py +359 -0
- oscura/analyzers/patterns/__init__.py +266 -0
- oscura/analyzers/patterns/clustering.py +1036 -0
- oscura/analyzers/patterns/discovery.py +539 -0
- oscura/analyzers/patterns/learning.py +797 -0
- oscura/analyzers/patterns/matching.py +1091 -0
- oscura/analyzers/patterns/periodic.py +650 -0
- oscura/analyzers/patterns/sequences.py +767 -0
- oscura/analyzers/power/__init__.py +116 -0
- oscura/analyzers/power/ac_power.py +391 -0
- oscura/analyzers/power/basic.py +383 -0
- oscura/analyzers/power/conduction.py +314 -0
- oscura/analyzers/power/efficiency.py +297 -0
- oscura/analyzers/power/ripple.py +356 -0
- oscura/analyzers/power/soa.py +372 -0
- oscura/analyzers/power/switching.py +479 -0
- oscura/analyzers/protocol/__init__.py +150 -0
- oscura/analyzers/protocols/__init__.py +150 -0
- oscura/analyzers/protocols/base.py +500 -0
- oscura/analyzers/protocols/can.py +620 -0
- oscura/analyzers/protocols/can_fd.py +448 -0
- oscura/analyzers/protocols/flexray.py +405 -0
- oscura/analyzers/protocols/hdlc.py +399 -0
- oscura/analyzers/protocols/i2c.py +368 -0
- oscura/analyzers/protocols/i2s.py +296 -0
- oscura/analyzers/protocols/jtag.py +393 -0
- oscura/analyzers/protocols/lin.py +445 -0
- oscura/analyzers/protocols/manchester.py +333 -0
- oscura/analyzers/protocols/onewire.py +501 -0
- oscura/analyzers/protocols/spi.py +334 -0
- oscura/analyzers/protocols/swd.py +325 -0
- oscura/analyzers/protocols/uart.py +393 -0
- oscura/analyzers/protocols/usb.py +495 -0
- oscura/analyzers/signal_integrity/__init__.py +63 -0
- oscura/analyzers/signal_integrity/embedding.py +294 -0
- oscura/analyzers/signal_integrity/equalization.py +370 -0
- oscura/analyzers/signal_integrity/sparams.py +484 -0
- oscura/analyzers/spectral/__init__.py +53 -0
- oscura/analyzers/spectral/chunked.py +273 -0
- oscura/analyzers/spectral/chunked_fft.py +571 -0
- oscura/analyzers/spectral/chunked_wavelet.py +391 -0
- oscura/analyzers/spectral/fft.py +92 -0
- oscura/analyzers/statistical/__init__.py +250 -0
- oscura/analyzers/statistical/checksum.py +923 -0
- oscura/analyzers/statistical/chunked_corr.py +228 -0
- oscura/analyzers/statistical/classification.py +778 -0
- oscura/analyzers/statistical/entropy.py +1113 -0
- oscura/analyzers/statistical/ngrams.py +614 -0
- oscura/analyzers/statistics/__init__.py +119 -0
- oscura/analyzers/statistics/advanced.py +885 -0
- oscura/analyzers/statistics/basic.py +263 -0
- oscura/analyzers/statistics/correlation.py +630 -0
- oscura/analyzers/statistics/distribution.py +298 -0
- oscura/analyzers/statistics/outliers.py +463 -0
- oscura/analyzers/statistics/streaming.py +93 -0
- oscura/analyzers/statistics/trend.py +520 -0
- oscura/analyzers/validation.py +598 -0
- oscura/analyzers/waveform/__init__.py +36 -0
- oscura/analyzers/waveform/measurements.py +943 -0
- oscura/analyzers/waveform/measurements_with_uncertainty.py +371 -0
- oscura/analyzers/waveform/spectral.py +1689 -0
- oscura/analyzers/waveform/wavelets.py +298 -0
- oscura/api/__init__.py +62 -0
- oscura/api/dsl.py +538 -0
- oscura/api/fluent.py +571 -0
- oscura/api/operators.py +498 -0
- oscura/api/optimization.py +392 -0
- oscura/api/profiling.py +396 -0
- oscura/automotive/__init__.py +73 -0
- oscura/automotive/can/__init__.py +52 -0
- oscura/automotive/can/analysis.py +356 -0
- oscura/automotive/can/checksum.py +250 -0
- oscura/automotive/can/correlation.py +212 -0
- oscura/automotive/can/discovery.py +355 -0
- oscura/automotive/can/message_wrapper.py +375 -0
- oscura/automotive/can/models.py +385 -0
- oscura/automotive/can/patterns.py +381 -0
- oscura/automotive/can/session.py +452 -0
- oscura/automotive/can/state_machine.py +300 -0
- oscura/automotive/can/stimulus_response.py +461 -0
- oscura/automotive/dbc/__init__.py +15 -0
- oscura/automotive/dbc/generator.py +156 -0
- oscura/automotive/dbc/parser.py +146 -0
- oscura/automotive/dtc/__init__.py +30 -0
- oscura/automotive/dtc/database.py +3036 -0
- oscura/automotive/j1939/__init__.py +14 -0
- oscura/automotive/j1939/decoder.py +745 -0
- oscura/automotive/loaders/__init__.py +35 -0
- oscura/automotive/loaders/asc.py +98 -0
- oscura/automotive/loaders/blf.py +77 -0
- oscura/automotive/loaders/csv_can.py +136 -0
- oscura/automotive/loaders/dispatcher.py +136 -0
- oscura/automotive/loaders/mdf.py +331 -0
- oscura/automotive/loaders/pcap.py +132 -0
- oscura/automotive/obd/__init__.py +14 -0
- oscura/automotive/obd/decoder.py +707 -0
- oscura/automotive/uds/__init__.py +48 -0
- oscura/automotive/uds/decoder.py +265 -0
- oscura/automotive/uds/models.py +64 -0
- oscura/automotive/visualization.py +369 -0
- oscura/batch/__init__.py +55 -0
- oscura/batch/advanced.py +627 -0
- oscura/batch/aggregate.py +300 -0
- oscura/batch/analyze.py +139 -0
- oscura/batch/logging.py +487 -0
- oscura/batch/metrics.py +556 -0
- oscura/builders/__init__.py +41 -0
- oscura/builders/signal_builder.py +1131 -0
- oscura/cli/__init__.py +14 -0
- oscura/cli/batch.py +339 -0
- oscura/cli/characterize.py +273 -0
- oscura/cli/compare.py +775 -0
- oscura/cli/decode.py +551 -0
- oscura/cli/main.py +247 -0
- oscura/cli/shell.py +350 -0
- oscura/comparison/__init__.py +66 -0
- oscura/comparison/compare.py +397 -0
- oscura/comparison/golden.py +487 -0
- oscura/comparison/limits.py +391 -0
- oscura/comparison/mask.py +434 -0
- oscura/comparison/trace_diff.py +30 -0
- oscura/comparison/visualization.py +481 -0
- oscura/compliance/__init__.py +70 -0
- oscura/compliance/advanced.py +756 -0
- oscura/compliance/masks.py +363 -0
- oscura/compliance/reporting.py +483 -0
- oscura/compliance/testing.py +298 -0
- oscura/component/__init__.py +38 -0
- oscura/component/impedance.py +365 -0
- oscura/component/reactive.py +598 -0
- oscura/component/transmission_line.py +312 -0
- oscura/config/__init__.py +191 -0
- oscura/config/defaults.py +254 -0
- oscura/config/loader.py +348 -0
- oscura/config/memory.py +271 -0
- oscura/config/migration.py +458 -0
- oscura/config/pipeline.py +1077 -0
- oscura/config/preferences.py +530 -0
- oscura/config/protocol.py +875 -0
- oscura/config/schema.py +713 -0
- oscura/config/settings.py +420 -0
- oscura/config/thresholds.py +599 -0
- oscura/convenience.py +457 -0
- oscura/core/__init__.py +299 -0
- oscura/core/audit.py +457 -0
- oscura/core/backend_selector.py +405 -0
- oscura/core/cache.py +590 -0
- oscura/core/cancellation.py +439 -0
- oscura/core/confidence.py +225 -0
- oscura/core/config.py +506 -0
- oscura/core/correlation.py +216 -0
- oscura/core/cross_domain.py +422 -0
- oscura/core/debug.py +301 -0
- oscura/core/edge_cases.py +541 -0
- oscura/core/exceptions.py +535 -0
- oscura/core/gpu_backend.py +523 -0
- oscura/core/lazy.py +832 -0
- oscura/core/log_query.py +540 -0
- oscura/core/logging.py +931 -0
- oscura/core/logging_advanced.py +952 -0
- oscura/core/memoize.py +171 -0
- oscura/core/memory_check.py +274 -0
- oscura/core/memory_guard.py +290 -0
- oscura/core/memory_limits.py +336 -0
- oscura/core/memory_monitor.py +453 -0
- oscura/core/memory_progress.py +465 -0
- oscura/core/memory_warnings.py +315 -0
- oscura/core/numba_backend.py +362 -0
- oscura/core/performance.py +352 -0
- oscura/core/progress.py +524 -0
- oscura/core/provenance.py +358 -0
- oscura/core/results.py +331 -0
- oscura/core/types.py +504 -0
- oscura/core/uncertainty.py +383 -0
- oscura/discovery/__init__.py +52 -0
- oscura/discovery/anomaly_detector.py +672 -0
- oscura/discovery/auto_decoder.py +415 -0
- oscura/discovery/comparison.py +497 -0
- oscura/discovery/quality_validator.py +528 -0
- oscura/discovery/signal_detector.py +769 -0
- oscura/dsl/__init__.py +73 -0
- oscura/dsl/commands.py +246 -0
- oscura/dsl/interpreter.py +455 -0
- oscura/dsl/parser.py +689 -0
- oscura/dsl/repl.py +172 -0
- oscura/exceptions.py +59 -0
- oscura/exploratory/__init__.py +111 -0
- oscura/exploratory/error_recovery.py +642 -0
- oscura/exploratory/fuzzy.py +513 -0
- oscura/exploratory/fuzzy_advanced.py +786 -0
- oscura/exploratory/legacy.py +831 -0
- oscura/exploratory/parse.py +358 -0
- oscura/exploratory/recovery.py +275 -0
- oscura/exploratory/sync.py +382 -0
- oscura/exploratory/unknown.py +707 -0
- oscura/export/__init__.py +25 -0
- oscura/export/wireshark/README.md +265 -0
- oscura/export/wireshark/__init__.py +47 -0
- oscura/export/wireshark/generator.py +312 -0
- oscura/export/wireshark/lua_builder.py +159 -0
- oscura/export/wireshark/templates/dissector.lua.j2 +92 -0
- oscura/export/wireshark/type_mapping.py +165 -0
- oscura/export/wireshark/validator.py +105 -0
- oscura/exporters/__init__.py +94 -0
- oscura/exporters/csv.py +303 -0
- oscura/exporters/exporters.py +44 -0
- oscura/exporters/hdf5.py +219 -0
- oscura/exporters/html_export.py +701 -0
- oscura/exporters/json_export.py +291 -0
- oscura/exporters/markdown_export.py +367 -0
- oscura/exporters/matlab_export.py +354 -0
- oscura/exporters/npz_export.py +219 -0
- oscura/exporters/spice_export.py +210 -0
- oscura/extensibility/__init__.py +131 -0
- oscura/extensibility/docs.py +752 -0
- oscura/extensibility/extensions.py +1125 -0
- oscura/extensibility/logging.py +259 -0
- oscura/extensibility/measurements.py +485 -0
- oscura/extensibility/plugins.py +414 -0
- oscura/extensibility/registry.py +346 -0
- oscura/extensibility/templates.py +913 -0
- oscura/extensibility/validation.py +651 -0
- oscura/filtering/__init__.py +89 -0
- oscura/filtering/base.py +563 -0
- oscura/filtering/convenience.py +564 -0
- oscura/filtering/design.py +725 -0
- oscura/filtering/filters.py +32 -0
- oscura/filtering/introspection.py +605 -0
- oscura/guidance/__init__.py +24 -0
- oscura/guidance/recommender.py +429 -0
- oscura/guidance/wizard.py +518 -0
- oscura/inference/__init__.py +251 -0
- oscura/inference/active_learning/README.md +153 -0
- oscura/inference/active_learning/__init__.py +38 -0
- oscura/inference/active_learning/lstar.py +257 -0
- oscura/inference/active_learning/observation_table.py +230 -0
- oscura/inference/active_learning/oracle.py +78 -0
- oscura/inference/active_learning/teachers/__init__.py +15 -0
- oscura/inference/active_learning/teachers/simulator.py +192 -0
- oscura/inference/adaptive_tuning.py +453 -0
- oscura/inference/alignment.py +653 -0
- oscura/inference/bayesian.py +943 -0
- oscura/inference/binary.py +1016 -0
- oscura/inference/crc_reverse.py +711 -0
- oscura/inference/logic.py +288 -0
- oscura/inference/message_format.py +1305 -0
- oscura/inference/protocol.py +417 -0
- oscura/inference/protocol_dsl.py +1084 -0
- oscura/inference/protocol_library.py +1230 -0
- oscura/inference/sequences.py +809 -0
- oscura/inference/signal_intelligence.py +1509 -0
- oscura/inference/spectral.py +215 -0
- oscura/inference/state_machine.py +634 -0
- oscura/inference/stream.py +918 -0
- oscura/integrations/__init__.py +59 -0
- oscura/integrations/llm.py +1827 -0
- oscura/jupyter/__init__.py +32 -0
- oscura/jupyter/display.py +268 -0
- oscura/jupyter/magic.py +334 -0
- oscura/loaders/__init__.py +526 -0
- oscura/loaders/binary.py +69 -0
- oscura/loaders/configurable.py +1255 -0
- oscura/loaders/csv.py +26 -0
- oscura/loaders/csv_loader.py +473 -0
- oscura/loaders/hdf5.py +9 -0
- oscura/loaders/hdf5_loader.py +510 -0
- oscura/loaders/lazy.py +370 -0
- oscura/loaders/mmap_loader.py +583 -0
- oscura/loaders/numpy_loader.py +436 -0
- oscura/loaders/pcap.py +432 -0
- oscura/loaders/preprocessing.py +368 -0
- oscura/loaders/rigol.py +287 -0
- oscura/loaders/sigrok.py +321 -0
- oscura/loaders/tdms.py +367 -0
- oscura/loaders/tektronix.py +711 -0
- oscura/loaders/validation.py +584 -0
- oscura/loaders/vcd.py +464 -0
- oscura/loaders/wav.py +233 -0
- oscura/math/__init__.py +45 -0
- oscura/math/arithmetic.py +824 -0
- oscura/math/interpolation.py +413 -0
- oscura/onboarding/__init__.py +39 -0
- oscura/onboarding/help.py +498 -0
- oscura/onboarding/tutorials.py +405 -0
- oscura/onboarding/wizard.py +466 -0
- oscura/optimization/__init__.py +19 -0
- oscura/optimization/parallel.py +440 -0
- oscura/optimization/search.py +532 -0
- oscura/pipeline/__init__.py +43 -0
- oscura/pipeline/base.py +338 -0
- oscura/pipeline/composition.py +242 -0
- oscura/pipeline/parallel.py +448 -0
- oscura/pipeline/pipeline.py +375 -0
- oscura/pipeline/reverse_engineering.py +1119 -0
- oscura/plugins/__init__.py +122 -0
- oscura/plugins/base.py +272 -0
- oscura/plugins/cli.py +497 -0
- oscura/plugins/discovery.py +411 -0
- oscura/plugins/isolation.py +418 -0
- oscura/plugins/lifecycle.py +959 -0
- oscura/plugins/manager.py +493 -0
- oscura/plugins/registry.py +421 -0
- oscura/plugins/versioning.py +372 -0
- oscura/py.typed +0 -0
- oscura/quality/__init__.py +65 -0
- oscura/quality/ensemble.py +740 -0
- oscura/quality/explainer.py +338 -0
- oscura/quality/scoring.py +616 -0
- oscura/quality/warnings.py +456 -0
- oscura/reporting/__init__.py +248 -0
- oscura/reporting/advanced.py +1234 -0
- oscura/reporting/analyze.py +448 -0
- oscura/reporting/argument_preparer.py +596 -0
- oscura/reporting/auto_report.py +507 -0
- oscura/reporting/batch.py +615 -0
- oscura/reporting/chart_selection.py +223 -0
- oscura/reporting/comparison.py +330 -0
- oscura/reporting/config.py +615 -0
- oscura/reporting/content/__init__.py +39 -0
- oscura/reporting/content/executive.py +127 -0
- oscura/reporting/content/filtering.py +191 -0
- oscura/reporting/content/minimal.py +257 -0
- oscura/reporting/content/verbosity.py +162 -0
- oscura/reporting/core.py +508 -0
- oscura/reporting/core_formats/__init__.py +17 -0
- oscura/reporting/core_formats/multi_format.py +210 -0
- oscura/reporting/engine.py +836 -0
- oscura/reporting/export.py +366 -0
- oscura/reporting/formatting/__init__.py +129 -0
- oscura/reporting/formatting/emphasis.py +81 -0
- oscura/reporting/formatting/numbers.py +403 -0
- oscura/reporting/formatting/standards.py +55 -0
- oscura/reporting/formatting.py +466 -0
- oscura/reporting/html.py +578 -0
- oscura/reporting/index.py +590 -0
- oscura/reporting/multichannel.py +296 -0
- oscura/reporting/output.py +379 -0
- oscura/reporting/pdf.py +373 -0
- oscura/reporting/plots.py +731 -0
- oscura/reporting/pptx_export.py +360 -0
- oscura/reporting/renderers/__init__.py +11 -0
- oscura/reporting/renderers/pdf.py +94 -0
- oscura/reporting/sections.py +471 -0
- oscura/reporting/standards.py +680 -0
- oscura/reporting/summary_generator.py +368 -0
- oscura/reporting/tables.py +397 -0
- oscura/reporting/template_system.py +724 -0
- oscura/reporting/templates/__init__.py +15 -0
- oscura/reporting/templates/definition.py +205 -0
- oscura/reporting/templates/index.html +649 -0
- oscura/reporting/templates/index.md +173 -0
- oscura/schemas/__init__.py +158 -0
- oscura/schemas/bus_configuration.json +322 -0
- oscura/schemas/device_mapping.json +182 -0
- oscura/schemas/packet_format.json +418 -0
- oscura/schemas/protocol_definition.json +363 -0
- oscura/search/__init__.py +16 -0
- oscura/search/anomaly.py +292 -0
- oscura/search/context.py +149 -0
- oscura/search/pattern.py +160 -0
- oscura/session/__init__.py +34 -0
- oscura/session/annotations.py +289 -0
- oscura/session/history.py +313 -0
- oscura/session/session.py +445 -0
- oscura/streaming/__init__.py +43 -0
- oscura/streaming/chunked.py +611 -0
- oscura/streaming/progressive.py +393 -0
- oscura/streaming/realtime.py +622 -0
- oscura/testing/__init__.py +54 -0
- oscura/testing/synthetic.py +808 -0
- oscura/triggering/__init__.py +68 -0
- oscura/triggering/base.py +229 -0
- oscura/triggering/edge.py +353 -0
- oscura/triggering/pattern.py +344 -0
- oscura/triggering/pulse.py +581 -0
- oscura/triggering/window.py +453 -0
- oscura/ui/__init__.py +48 -0
- oscura/ui/formatters.py +526 -0
- oscura/ui/progressive_display.py +340 -0
- oscura/utils/__init__.py +99 -0
- oscura/utils/autodetect.py +338 -0
- oscura/utils/buffer.py +389 -0
- oscura/utils/lazy.py +407 -0
- oscura/utils/lazy_imports.py +147 -0
- oscura/utils/memory.py +836 -0
- oscura/utils/memory_advanced.py +1326 -0
- oscura/utils/memory_extensions.py +465 -0
- oscura/utils/progressive.py +352 -0
- oscura/utils/windowing.py +362 -0
- oscura/visualization/__init__.py +321 -0
- oscura/visualization/accessibility.py +526 -0
- oscura/visualization/annotations.py +374 -0
- oscura/visualization/axis_scaling.py +305 -0
- oscura/visualization/colors.py +453 -0
- oscura/visualization/digital.py +337 -0
- oscura/visualization/eye.py +420 -0
- oscura/visualization/histogram.py +281 -0
- oscura/visualization/interactive.py +858 -0
- oscura/visualization/jitter.py +702 -0
- oscura/visualization/keyboard.py +394 -0
- oscura/visualization/layout.py +365 -0
- oscura/visualization/optimization.py +1028 -0
- oscura/visualization/palettes.py +446 -0
- oscura/visualization/plot.py +92 -0
- oscura/visualization/power.py +290 -0
- oscura/visualization/power_extended.py +626 -0
- oscura/visualization/presets.py +467 -0
- oscura/visualization/protocols.py +932 -0
- oscura/visualization/render.py +207 -0
- oscura/visualization/rendering.py +444 -0
- oscura/visualization/reverse_engineering.py +791 -0
- oscura/visualization/signal_integrity.py +808 -0
- oscura/visualization/specialized.py +553 -0
- oscura/visualization/spectral.py +811 -0
- oscura/visualization/styles.py +381 -0
- oscura/visualization/thumbnails.py +311 -0
- oscura/visualization/time_axis.py +351 -0
- oscura/visualization/waveform.py +367 -0
- oscura/workflow/__init__.py +13 -0
- oscura/workflow/dag.py +377 -0
- oscura/workflows/__init__.py +58 -0
- oscura/workflows/compliance.py +280 -0
- oscura/workflows/digital.py +272 -0
- oscura/workflows/multi_trace.py +502 -0
- oscura/workflows/power.py +178 -0
- oscura/workflows/protocol.py +492 -0
- oscura/workflows/reverse_engineering.py +639 -0
- oscura/workflows/signal_integrity.py +227 -0
- oscura-0.1.0.dist-info/METADATA +300 -0
- oscura-0.1.0.dist-info/RECORD +463 -0
- oscura-0.1.0.dist-info/entry_points.txt +2 -0
- {oscura-0.0.1.dist-info → oscura-0.1.0.dist-info}/licenses/LICENSE +1 -1
- oscura-0.0.1.dist-info/METADATA +0 -63
- oscura-0.0.1.dist-info/RECORD +0 -5
- {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
|
+
]
|