oscura 0.5.0__py3-none-any.whl → 0.6.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 +169 -167
- oscura/analyzers/__init__.py +3 -0
- oscura/analyzers/classification.py +659 -0
- oscura/analyzers/digital/__init__.py +0 -48
- oscura/analyzers/digital/edges.py +325 -65
- oscura/analyzers/digital/extraction.py +0 -195
- oscura/analyzers/digital/quality.py +293 -166
- oscura/analyzers/digital/timing.py +260 -115
- oscura/analyzers/digital/timing_numba.py +334 -0
- oscura/analyzers/entropy.py +605 -0
- oscura/analyzers/eye/diagram.py +176 -109
- oscura/analyzers/eye/metrics.py +5 -5
- oscura/analyzers/jitter/__init__.py +6 -4
- oscura/analyzers/jitter/ber.py +52 -52
- oscura/analyzers/jitter/classification.py +156 -0
- oscura/analyzers/jitter/decomposition.py +163 -113
- oscura/analyzers/jitter/spectrum.py +80 -64
- oscura/analyzers/ml/__init__.py +39 -0
- oscura/analyzers/ml/features.py +600 -0
- oscura/analyzers/ml/signal_classifier.py +604 -0
- oscura/analyzers/packet/daq.py +246 -158
- oscura/analyzers/packet/parser.py +12 -1
- oscura/analyzers/packet/payload.py +50 -2110
- oscura/analyzers/packet/payload_analysis.py +361 -181
- oscura/analyzers/packet/payload_patterns.py +133 -70
- oscura/analyzers/packet/stream.py +84 -23
- oscura/analyzers/patterns/__init__.py +26 -5
- oscura/analyzers/patterns/anomaly_detection.py +908 -0
- oscura/analyzers/patterns/clustering.py +169 -108
- oscura/analyzers/patterns/clustering_optimized.py +227 -0
- oscura/analyzers/patterns/discovery.py +1 -1
- oscura/analyzers/patterns/matching.py +581 -197
- oscura/analyzers/patterns/pattern_mining.py +778 -0
- oscura/analyzers/patterns/periodic.py +121 -38
- oscura/analyzers/patterns/sequences.py +175 -78
- oscura/analyzers/power/conduction.py +1 -1
- oscura/analyzers/power/soa.py +6 -6
- oscura/analyzers/power/switching.py +250 -110
- oscura/analyzers/protocol/__init__.py +17 -1
- oscura/analyzers/protocols/__init__.py +1 -22
- oscura/analyzers/protocols/base.py +6 -6
- oscura/analyzers/protocols/ble/__init__.py +38 -0
- oscura/analyzers/protocols/ble/analyzer.py +809 -0
- oscura/analyzers/protocols/ble/uuids.py +288 -0
- oscura/analyzers/protocols/can.py +257 -127
- oscura/analyzers/protocols/can_fd.py +107 -80
- oscura/analyzers/protocols/flexray.py +139 -80
- oscura/analyzers/protocols/hdlc.py +93 -58
- oscura/analyzers/protocols/i2c.py +247 -106
- oscura/analyzers/protocols/i2s.py +138 -86
- oscura/analyzers/protocols/industrial/__init__.py +40 -0
- oscura/analyzers/protocols/industrial/bacnet/__init__.py +33 -0
- oscura/analyzers/protocols/industrial/bacnet/analyzer.py +708 -0
- oscura/analyzers/protocols/industrial/bacnet/encoding.py +412 -0
- oscura/analyzers/protocols/industrial/bacnet/services.py +622 -0
- oscura/analyzers/protocols/industrial/ethercat/__init__.py +30 -0
- oscura/analyzers/protocols/industrial/ethercat/analyzer.py +474 -0
- oscura/analyzers/protocols/industrial/ethercat/mailbox.py +339 -0
- oscura/analyzers/protocols/industrial/ethercat/topology.py +166 -0
- oscura/analyzers/protocols/industrial/modbus/__init__.py +31 -0
- oscura/analyzers/protocols/industrial/modbus/analyzer.py +525 -0
- oscura/analyzers/protocols/industrial/modbus/crc.py +79 -0
- oscura/analyzers/protocols/industrial/modbus/functions.py +436 -0
- oscura/analyzers/protocols/industrial/opcua/__init__.py +21 -0
- oscura/analyzers/protocols/industrial/opcua/analyzer.py +552 -0
- oscura/analyzers/protocols/industrial/opcua/datatypes.py +446 -0
- oscura/analyzers/protocols/industrial/opcua/services.py +264 -0
- oscura/analyzers/protocols/industrial/profinet/__init__.py +23 -0
- oscura/analyzers/protocols/industrial/profinet/analyzer.py +441 -0
- oscura/analyzers/protocols/industrial/profinet/dcp.py +263 -0
- oscura/analyzers/protocols/industrial/profinet/ptcp.py +200 -0
- oscura/analyzers/protocols/jtag.py +180 -98
- oscura/analyzers/protocols/lin.py +219 -114
- oscura/analyzers/protocols/manchester.py +4 -4
- oscura/analyzers/protocols/onewire.py +253 -149
- oscura/analyzers/protocols/parallel_bus/__init__.py +20 -0
- oscura/analyzers/protocols/parallel_bus/centronics.py +92 -0
- oscura/analyzers/protocols/parallel_bus/gpib.py +137 -0
- oscura/analyzers/protocols/spi.py +192 -95
- oscura/analyzers/protocols/swd.py +321 -167
- oscura/analyzers/protocols/uart.py +267 -125
- oscura/analyzers/protocols/usb.py +235 -131
- oscura/analyzers/side_channel/power.py +17 -12
- oscura/analyzers/signal/__init__.py +15 -0
- oscura/analyzers/signal/timing_analysis.py +1086 -0
- oscura/analyzers/signal_integrity/__init__.py +4 -1
- oscura/analyzers/signal_integrity/sparams.py +2 -19
- oscura/analyzers/spectral/chunked.py +129 -60
- oscura/analyzers/spectral/chunked_fft.py +300 -94
- oscura/analyzers/spectral/chunked_wavelet.py +100 -80
- oscura/analyzers/statistical/checksum.py +376 -217
- oscura/analyzers/statistical/classification.py +229 -107
- oscura/analyzers/statistical/entropy.py +78 -53
- oscura/analyzers/statistics/correlation.py +407 -211
- oscura/analyzers/statistics/outliers.py +2 -2
- oscura/analyzers/statistics/streaming.py +30 -5
- oscura/analyzers/validation.py +216 -101
- oscura/analyzers/waveform/measurements.py +9 -0
- oscura/analyzers/waveform/measurements_with_uncertainty.py +31 -15
- oscura/analyzers/waveform/spectral.py +500 -228
- oscura/api/__init__.py +31 -5
- oscura/api/dsl/__init__.py +582 -0
- oscura/{dsl → api/dsl}/commands.py +43 -76
- oscura/{dsl → api/dsl}/interpreter.py +26 -51
- oscura/{dsl → api/dsl}/parser.py +107 -77
- oscura/{dsl → api/dsl}/repl.py +2 -2
- oscura/api/dsl.py +1 -1
- oscura/{integrations → api/integrations}/__init__.py +1 -1
- oscura/{integrations → api/integrations}/llm.py +201 -102
- oscura/api/operators.py +3 -3
- oscura/api/optimization.py +144 -30
- oscura/api/rest_server.py +921 -0
- oscura/api/server/__init__.py +17 -0
- oscura/api/server/dashboard.py +850 -0
- oscura/api/server/static/README.md +34 -0
- oscura/api/server/templates/base.html +181 -0
- oscura/api/server/templates/export.html +120 -0
- oscura/api/server/templates/home.html +284 -0
- oscura/api/server/templates/protocols.html +58 -0
- oscura/api/server/templates/reports.html +43 -0
- oscura/api/server/templates/session_detail.html +89 -0
- oscura/api/server/templates/sessions.html +83 -0
- oscura/api/server/templates/waveforms.html +73 -0
- oscura/automotive/__init__.py +8 -1
- oscura/automotive/can/__init__.py +10 -0
- oscura/automotive/can/checksum.py +3 -1
- oscura/automotive/can/dbc_generator.py +590 -0
- oscura/automotive/can/message_wrapper.py +121 -74
- oscura/automotive/can/patterns.py +98 -21
- oscura/automotive/can/session.py +292 -56
- oscura/automotive/can/state_machine.py +6 -3
- oscura/automotive/can/stimulus_response.py +97 -75
- oscura/automotive/dbc/__init__.py +10 -2
- oscura/automotive/dbc/generator.py +84 -56
- oscura/automotive/dbc/parser.py +6 -6
- oscura/automotive/dtc/data.json +2763 -0
- oscura/automotive/dtc/database.py +2 -2
- oscura/automotive/flexray/__init__.py +31 -0
- oscura/automotive/flexray/analyzer.py +504 -0
- oscura/automotive/flexray/crc.py +185 -0
- oscura/automotive/flexray/fibex.py +449 -0
- oscura/automotive/j1939/__init__.py +45 -8
- oscura/automotive/j1939/analyzer.py +605 -0
- oscura/automotive/j1939/spns.py +326 -0
- oscura/automotive/j1939/transport.py +306 -0
- oscura/automotive/lin/__init__.py +47 -0
- oscura/automotive/lin/analyzer.py +612 -0
- oscura/automotive/loaders/blf.py +13 -2
- oscura/automotive/loaders/csv_can.py +143 -72
- oscura/automotive/loaders/dispatcher.py +50 -2
- oscura/automotive/loaders/mdf.py +86 -45
- oscura/automotive/loaders/pcap.py +111 -61
- oscura/automotive/uds/__init__.py +4 -0
- oscura/automotive/uds/analyzer.py +725 -0
- oscura/automotive/uds/decoder.py +140 -58
- oscura/automotive/uds/models.py +7 -1
- oscura/automotive/visualization.py +1 -1
- oscura/cli/analyze.py +348 -0
- oscura/cli/batch.py +142 -122
- oscura/cli/benchmark.py +275 -0
- oscura/cli/characterize.py +137 -82
- oscura/cli/compare.py +224 -131
- oscura/cli/completion.py +250 -0
- oscura/cli/config_cmd.py +361 -0
- oscura/cli/decode.py +164 -87
- oscura/cli/export.py +286 -0
- oscura/cli/main.py +115 -31
- oscura/{onboarding → cli/onboarding}/__init__.py +3 -3
- oscura/{onboarding → cli/onboarding}/help.py +80 -58
- oscura/{onboarding → cli/onboarding}/tutorials.py +97 -72
- oscura/{onboarding → cli/onboarding}/wizard.py +55 -36
- oscura/cli/progress.py +147 -0
- oscura/cli/shell.py +157 -135
- oscura/cli/validate_cmd.py +204 -0
- oscura/cli/visualize.py +158 -0
- oscura/convenience.py +125 -79
- oscura/core/__init__.py +4 -2
- oscura/core/backend_selector.py +3 -3
- oscura/core/cache.py +126 -15
- oscura/core/cancellation.py +1 -1
- oscura/{config → core/config}/__init__.py +20 -11
- oscura/{config → core/config}/defaults.py +1 -1
- oscura/{config → core/config}/loader.py +7 -5
- oscura/{config → core/config}/memory.py +5 -5
- oscura/{config → core/config}/migration.py +1 -1
- oscura/{config → core/config}/pipeline.py +99 -23
- oscura/{config → core/config}/preferences.py +1 -1
- oscura/{config → core/config}/protocol.py +3 -3
- oscura/{config → core/config}/schema.py +426 -272
- oscura/{config → core/config}/settings.py +1 -1
- oscura/{config → core/config}/thresholds.py +195 -153
- oscura/core/correlation.py +5 -6
- oscura/core/cross_domain.py +0 -2
- oscura/core/debug.py +9 -5
- oscura/{extensibility → core/extensibility}/docs.py +158 -70
- oscura/{extensibility → core/extensibility}/extensions.py +160 -76
- oscura/{extensibility → core/extensibility}/logging.py +1 -1
- oscura/{extensibility → core/extensibility}/measurements.py +1 -1
- oscura/{extensibility → core/extensibility}/plugins.py +1 -1
- oscura/{extensibility → core/extensibility}/templates.py +73 -3
- oscura/{extensibility → core/extensibility}/validation.py +1 -1
- oscura/core/gpu_backend.py +11 -7
- oscura/core/log_query.py +101 -11
- oscura/core/logging.py +126 -54
- oscura/core/logging_advanced.py +5 -5
- oscura/core/memory_limits.py +108 -70
- oscura/core/memory_monitor.py +2 -2
- oscura/core/memory_progress.py +7 -7
- oscura/core/memory_warnings.py +1 -1
- oscura/core/numba_backend.py +13 -13
- oscura/{plugins → core/plugins}/__init__.py +9 -9
- oscura/{plugins → core/plugins}/base.py +7 -7
- oscura/{plugins → core/plugins}/cli.py +3 -3
- oscura/{plugins → core/plugins}/discovery.py +186 -106
- oscura/{plugins → core/plugins}/lifecycle.py +1 -1
- oscura/{plugins → core/plugins}/manager.py +7 -7
- oscura/{plugins → core/plugins}/registry.py +3 -3
- oscura/{plugins → core/plugins}/versioning.py +1 -1
- oscura/core/progress.py +16 -1
- oscura/core/provenance.py +8 -2
- oscura/{schemas → core/schemas}/__init__.py +2 -2
- oscura/core/schemas/bus_configuration.json +322 -0
- oscura/core/schemas/device_mapping.json +182 -0
- oscura/core/schemas/packet_format.json +418 -0
- oscura/core/schemas/protocol_definition.json +363 -0
- oscura/core/types.py +4 -0
- oscura/core/uncertainty.py +3 -3
- oscura/correlation/__init__.py +52 -0
- oscura/correlation/multi_protocol.py +811 -0
- oscura/discovery/auto_decoder.py +117 -35
- oscura/discovery/comparison.py +191 -86
- oscura/discovery/quality_validator.py +155 -68
- oscura/discovery/signal_detector.py +196 -79
- oscura/export/__init__.py +18 -20
- oscura/export/kaitai_struct.py +513 -0
- oscura/export/scapy_layer.py +801 -0
- oscura/export/wireshark/README.md +15 -15
- oscura/export/wireshark/generator.py +1 -1
- oscura/export/wireshark/templates/dissector.lua.j2 +2 -2
- oscura/export/wireshark_dissector.py +746 -0
- oscura/guidance/wizard.py +207 -111
- oscura/hardware/__init__.py +19 -0
- oscura/{acquisition → hardware/acquisition}/__init__.py +4 -4
- oscura/{acquisition → hardware/acquisition}/file.py +2 -2
- oscura/{acquisition → hardware/acquisition}/hardware.py +7 -7
- oscura/{acquisition → hardware/acquisition}/saleae.py +15 -12
- oscura/{acquisition → hardware/acquisition}/socketcan.py +1 -1
- oscura/{acquisition → hardware/acquisition}/streaming.py +2 -2
- oscura/{acquisition → hardware/acquisition}/synthetic.py +3 -3
- oscura/{acquisition → hardware/acquisition}/visa.py +33 -11
- oscura/hardware/firmware/__init__.py +29 -0
- oscura/hardware/firmware/pattern_recognition.py +874 -0
- oscura/hardware/hal_detector.py +736 -0
- oscura/hardware/security/__init__.py +37 -0
- oscura/hardware/security/side_channel_detector.py +1126 -0
- oscura/inference/__init__.py +4 -0
- oscura/inference/active_learning/README.md +7 -7
- oscura/inference/active_learning/observation_table.py +4 -1
- oscura/inference/alignment.py +216 -123
- oscura/inference/bayesian.py +113 -33
- oscura/inference/crc_reverse.py +101 -55
- oscura/inference/logic.py +6 -2
- oscura/inference/message_format.py +342 -183
- oscura/inference/protocol.py +95 -44
- oscura/inference/protocol_dsl.py +180 -82
- oscura/inference/signal_intelligence.py +1439 -706
- oscura/inference/spectral.py +99 -57
- oscura/inference/state_machine.py +810 -158
- oscura/inference/stream.py +270 -110
- oscura/iot/__init__.py +34 -0
- oscura/iot/coap/__init__.py +32 -0
- oscura/iot/coap/analyzer.py +668 -0
- oscura/iot/coap/options.py +212 -0
- oscura/iot/lorawan/__init__.py +21 -0
- oscura/iot/lorawan/crypto.py +206 -0
- oscura/iot/lorawan/decoder.py +801 -0
- oscura/iot/lorawan/mac_commands.py +341 -0
- oscura/iot/mqtt/__init__.py +27 -0
- oscura/iot/mqtt/analyzer.py +999 -0
- oscura/iot/mqtt/properties.py +315 -0
- oscura/iot/zigbee/__init__.py +31 -0
- oscura/iot/zigbee/analyzer.py +615 -0
- oscura/iot/zigbee/security.py +153 -0
- oscura/iot/zigbee/zcl.py +349 -0
- oscura/jupyter/display.py +125 -45
- oscura/{exploratory → jupyter/exploratory}/__init__.py +8 -8
- oscura/{exploratory → jupyter/exploratory}/error_recovery.py +298 -141
- oscura/jupyter/exploratory/fuzzy.py +746 -0
- oscura/{exploratory → jupyter/exploratory}/fuzzy_advanced.py +258 -100
- oscura/{exploratory → jupyter/exploratory}/legacy.py +464 -242
- oscura/{exploratory → jupyter/exploratory}/parse.py +167 -145
- oscura/{exploratory → jupyter/exploratory}/recovery.py +119 -87
- oscura/jupyter/exploratory/sync.py +612 -0
- oscura/{exploratory → jupyter/exploratory}/unknown.py +299 -176
- oscura/jupyter/magic.py +4 -4
- oscura/{ui → jupyter/ui}/__init__.py +2 -2
- oscura/{ui → jupyter/ui}/formatters.py +3 -3
- oscura/{ui → jupyter/ui}/progressive_display.py +153 -82
- oscura/loaders/__init__.py +171 -63
- oscura/loaders/binary.py +88 -1
- oscura/loaders/chipwhisperer.py +153 -137
- oscura/loaders/configurable.py +208 -86
- oscura/loaders/csv_loader.py +458 -215
- oscura/loaders/hdf5_loader.py +278 -119
- oscura/loaders/lazy.py +87 -54
- oscura/loaders/mmap_loader.py +1 -1
- oscura/loaders/numpy_loader.py +253 -116
- oscura/loaders/pcap.py +226 -151
- oscura/loaders/rigol.py +110 -49
- oscura/loaders/sigrok.py +201 -78
- oscura/loaders/tdms.py +81 -58
- oscura/loaders/tektronix.py +291 -174
- oscura/loaders/touchstone.py +182 -87
- oscura/loaders/vcd.py +215 -117
- oscura/loaders/wav.py +155 -68
- oscura/reporting/__init__.py +9 -7
- oscura/reporting/analyze.py +352 -146
- oscura/reporting/argument_preparer.py +69 -14
- oscura/reporting/auto_report.py +97 -61
- oscura/reporting/batch.py +131 -58
- oscura/reporting/chart_selection.py +57 -45
- oscura/reporting/comparison.py +63 -17
- oscura/reporting/content/executive.py +76 -24
- oscura/reporting/core_formats/multi_format.py +11 -8
- oscura/reporting/engine.py +312 -158
- oscura/reporting/enhanced_reports.py +949 -0
- oscura/reporting/export.py +86 -43
- oscura/reporting/formatting/numbers.py +69 -42
- oscura/reporting/html.py +139 -58
- oscura/reporting/index.py +137 -65
- oscura/reporting/output.py +158 -67
- oscura/reporting/pdf.py +67 -102
- oscura/reporting/plots.py +191 -112
- oscura/reporting/sections.py +88 -47
- oscura/reporting/standards.py +104 -61
- oscura/reporting/summary_generator.py +75 -55
- oscura/reporting/tables.py +138 -54
- oscura/reporting/templates/enhanced/protocol_re.html +525 -0
- oscura/reporting/templates/index.md +13 -13
- oscura/sessions/__init__.py +14 -23
- oscura/sessions/base.py +3 -3
- oscura/sessions/blackbox.py +106 -10
- oscura/sessions/generic.py +2 -2
- oscura/sessions/legacy.py +783 -0
- oscura/side_channel/__init__.py +63 -0
- oscura/side_channel/dpa.py +1025 -0
- oscura/utils/__init__.py +15 -1
- oscura/utils/autodetect.py +1 -5
- oscura/utils/bitwise.py +118 -0
- oscura/{builders → utils/builders}/__init__.py +1 -1
- oscura/{comparison → utils/comparison}/__init__.py +6 -6
- oscura/{comparison → utils/comparison}/compare.py +202 -101
- oscura/{comparison → utils/comparison}/golden.py +83 -63
- oscura/{comparison → utils/comparison}/limits.py +313 -89
- oscura/{comparison → utils/comparison}/mask.py +151 -45
- oscura/{comparison → utils/comparison}/trace_diff.py +1 -1
- oscura/{comparison → utils/comparison}/visualization.py +147 -89
- oscura/{component → utils/component}/__init__.py +3 -3
- oscura/{component → utils/component}/impedance.py +122 -58
- oscura/{component → utils/component}/reactive.py +165 -168
- oscura/{component → utils/component}/transmission_line.py +3 -3
- oscura/{filtering → utils/filtering}/__init__.py +6 -6
- oscura/{filtering → utils/filtering}/base.py +1 -1
- oscura/{filtering → utils/filtering}/convenience.py +2 -2
- oscura/{filtering → utils/filtering}/design.py +169 -93
- oscura/{filtering → utils/filtering}/filters.py +2 -2
- oscura/{filtering → utils/filtering}/introspection.py +2 -2
- oscura/utils/geometry.py +31 -0
- oscura/utils/imports.py +184 -0
- oscura/utils/lazy.py +1 -1
- oscura/{math → utils/math}/__init__.py +2 -2
- oscura/{math → utils/math}/arithmetic.py +114 -48
- oscura/{math → utils/math}/interpolation.py +139 -106
- oscura/utils/memory.py +129 -66
- oscura/utils/memory_advanced.py +92 -9
- oscura/utils/memory_extensions.py +10 -8
- oscura/{optimization → utils/optimization}/__init__.py +1 -1
- oscura/{optimization → utils/optimization}/search.py +2 -2
- oscura/utils/performance/__init__.py +58 -0
- oscura/utils/performance/caching.py +889 -0
- oscura/utils/performance/lsh_clustering.py +333 -0
- oscura/utils/performance/memory_optimizer.py +699 -0
- oscura/utils/performance/optimizations.py +675 -0
- oscura/utils/performance/parallel.py +654 -0
- oscura/utils/performance/profiling.py +661 -0
- oscura/{pipeline → utils/pipeline}/base.py +1 -1
- oscura/{pipeline → utils/pipeline}/composition.py +11 -3
- oscura/{pipeline → utils/pipeline}/parallel.py +3 -2
- oscura/{pipeline → utils/pipeline}/pipeline.py +1 -1
- oscura/{pipeline → utils/pipeline}/reverse_engineering.py +412 -221
- oscura/{search → utils/search}/__init__.py +3 -3
- oscura/{search → utils/search}/anomaly.py +188 -58
- oscura/utils/search/context.py +294 -0
- oscura/{search → utils/search}/pattern.py +138 -10
- oscura/utils/serial.py +51 -0
- oscura/utils/storage/__init__.py +61 -0
- oscura/utils/storage/database.py +1166 -0
- oscura/{streaming → utils/streaming}/chunked.py +302 -143
- oscura/{streaming → utils/streaming}/progressive.py +1 -1
- oscura/{streaming → utils/streaming}/realtime.py +3 -2
- oscura/{triggering → utils/triggering}/__init__.py +6 -6
- oscura/{triggering → utils/triggering}/base.py +6 -6
- oscura/{triggering → utils/triggering}/edge.py +2 -2
- oscura/{triggering → utils/triggering}/pattern.py +2 -2
- oscura/{triggering → utils/triggering}/pulse.py +115 -74
- oscura/{triggering → utils/triggering}/window.py +2 -2
- oscura/utils/validation.py +32 -0
- oscura/validation/__init__.py +121 -0
- oscura/{compliance → validation/compliance}/__init__.py +5 -5
- oscura/{compliance → validation/compliance}/advanced.py +5 -5
- oscura/{compliance → validation/compliance}/masks.py +1 -1
- oscura/{compliance → validation/compliance}/reporting.py +127 -53
- oscura/{compliance → validation/compliance}/testing.py +114 -52
- oscura/validation/compliance_tests.py +915 -0
- oscura/validation/fuzzer.py +990 -0
- oscura/validation/grammar_tests.py +596 -0
- oscura/validation/grammar_validator.py +904 -0
- oscura/validation/hil_testing.py +977 -0
- oscura/{quality → validation/quality}/__init__.py +4 -4
- oscura/{quality → validation/quality}/ensemble.py +251 -171
- oscura/{quality → validation/quality}/explainer.py +3 -3
- oscura/{quality → validation/quality}/scoring.py +1 -1
- oscura/{quality → validation/quality}/warnings.py +4 -4
- oscura/validation/regression_suite.py +808 -0
- oscura/validation/replay.py +788 -0
- oscura/{testing → validation/testing}/__init__.py +2 -2
- oscura/{testing → validation/testing}/synthetic.py +5 -5
- oscura/visualization/__init__.py +9 -0
- oscura/visualization/accessibility.py +1 -1
- oscura/visualization/annotations.py +64 -67
- oscura/visualization/colors.py +7 -7
- oscura/visualization/digital.py +180 -81
- oscura/visualization/eye.py +236 -85
- oscura/visualization/interactive.py +320 -143
- oscura/visualization/jitter.py +587 -247
- oscura/visualization/layout.py +169 -134
- oscura/visualization/optimization.py +103 -52
- oscura/visualization/palettes.py +1 -1
- oscura/visualization/power.py +427 -211
- oscura/visualization/power_extended.py +626 -297
- oscura/visualization/presets.py +2 -0
- oscura/visualization/protocols.py +495 -181
- oscura/visualization/render.py +79 -63
- oscura/visualization/reverse_engineering.py +171 -124
- oscura/visualization/signal_integrity.py +460 -279
- oscura/visualization/specialized.py +190 -100
- oscura/visualization/spectral.py +670 -255
- oscura/visualization/thumbnails.py +166 -137
- oscura/visualization/waveform.py +150 -63
- oscura/workflows/__init__.py +3 -0
- oscura/{batch → workflows/batch}/__init__.py +5 -5
- oscura/{batch → workflows/batch}/advanced.py +150 -75
- oscura/workflows/batch/aggregate.py +531 -0
- oscura/workflows/batch/analyze.py +236 -0
- oscura/{batch → workflows/batch}/logging.py +2 -2
- oscura/{batch → workflows/batch}/metrics.py +1 -1
- oscura/workflows/complete_re.py +1144 -0
- oscura/workflows/compliance.py +44 -54
- oscura/workflows/digital.py +197 -51
- oscura/workflows/legacy/__init__.py +12 -0
- oscura/{workflow → workflows/legacy}/dag.py +4 -1
- oscura/workflows/multi_trace.py +9 -9
- oscura/workflows/power.py +42 -62
- oscura/workflows/protocol.py +82 -49
- oscura/workflows/reverse_engineering.py +351 -150
- oscura/workflows/signal_integrity.py +157 -82
- oscura-0.6.0.dist-info/METADATA +643 -0
- oscura-0.6.0.dist-info/RECORD +590 -0
- oscura/analyzers/digital/ic_database.py +0 -498
- oscura/analyzers/digital/timing_paths.py +0 -339
- oscura/analyzers/digital/vintage.py +0 -377
- oscura/analyzers/digital/vintage_result.py +0 -148
- oscura/analyzers/protocols/parallel_bus.py +0 -449
- oscura/batch/aggregate.py +0 -300
- oscura/batch/analyze.py +0 -139
- oscura/dsl/__init__.py +0 -73
- oscura/exceptions.py +0 -59
- oscura/exploratory/fuzzy.py +0 -513
- oscura/exploratory/sync.py +0 -384
- oscura/export/wavedrom.py +0 -430
- oscura/exporters/__init__.py +0 -94
- oscura/exporters/csv.py +0 -303
- oscura/exporters/exporters.py +0 -44
- oscura/exporters/hdf5.py +0 -217
- oscura/exporters/html_export.py +0 -701
- oscura/exporters/json_export.py +0 -338
- oscura/exporters/markdown_export.py +0 -367
- oscura/exporters/matlab_export.py +0 -354
- oscura/exporters/npz_export.py +0 -219
- oscura/exporters/spice_export.py +0 -210
- oscura/exporters/vintage_logic_csv.py +0 -247
- oscura/reporting/vintage_logic_report.py +0 -523
- oscura/search/context.py +0 -149
- oscura/session/__init__.py +0 -34
- oscura/session/annotations.py +0 -289
- oscura/session/history.py +0 -313
- oscura/session/session.py +0 -520
- oscura/visualization/digital_advanced.py +0 -718
- oscura/visualization/figure_manager.py +0 -156
- oscura/workflow/__init__.py +0 -13
- oscura-0.5.0.dist-info/METADATA +0 -407
- oscura-0.5.0.dist-info/RECORD +0 -486
- /oscura/core/{config.py → config/legacy.py} +0 -0
- /oscura/{extensibility → core/extensibility}/__init__.py +0 -0
- /oscura/{extensibility → core/extensibility}/registry.py +0 -0
- /oscura/{plugins → core/plugins}/isolation.py +0 -0
- /oscura/{builders → utils/builders}/signal_builder.py +0 -0
- /oscura/{optimization → utils/optimization}/parallel.py +0 -0
- /oscura/{pipeline → utils/pipeline}/__init__.py +0 -0
- /oscura/{streaming → utils/streaming}/__init__.py +0 -0
- {oscura-0.5.0.dist-info → oscura-0.6.0.dist-info}/WHEEL +0 -0
- {oscura-0.5.0.dist-info → oscura-0.6.0.dist-info}/entry_points.txt +0 -0
- {oscura-0.5.0.dist-info → oscura-0.6.0.dist-info}/licenses/LICENSE +0 -0
oscura/visualization/spectral.py
CHANGED
|
@@ -16,7 +16,7 @@ References:
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
18
|
from pathlib import Path
|
|
19
|
-
from typing import TYPE_CHECKING, Any,
|
|
19
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
20
20
|
|
|
21
21
|
import numpy as np
|
|
22
22
|
|
|
@@ -37,6 +37,157 @@ if TYPE_CHECKING:
|
|
|
37
37
|
from oscura.core.types import WaveformTrace
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def _get_fft_data(
|
|
41
|
+
trace: WaveformTrace,
|
|
42
|
+
fft_result: tuple[Any, Any] | None,
|
|
43
|
+
window: str,
|
|
44
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
45
|
+
"""Get FFT data, either from cache or by computing.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
trace: Waveform trace.
|
|
49
|
+
fft_result: Pre-computed FFT result.
|
|
50
|
+
window: Window function name.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Tuple of (frequencies, magnitudes_db).
|
|
54
|
+
"""
|
|
55
|
+
if fft_result is not None:
|
|
56
|
+
return fft_result
|
|
57
|
+
|
|
58
|
+
from oscura.analyzers.waveform.spectral import fft
|
|
59
|
+
|
|
60
|
+
return fft(trace, window=window) # type: ignore[return-value]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _scale_frequencies(
|
|
64
|
+
freq: NDArray[np.float64], freq_unit: str
|
|
65
|
+
) -> tuple[NDArray[np.float64], float, str]:
|
|
66
|
+
"""Scale frequencies to appropriate unit.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
freq: Frequency array in Hz.
|
|
70
|
+
freq_unit: Requested unit or "auto".
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Tuple of (scaled_frequencies, divisor, unit_name).
|
|
74
|
+
"""
|
|
75
|
+
if freq_unit == "auto":
|
|
76
|
+
max_freq = freq[-1]
|
|
77
|
+
freq_unit = _auto_select_freq_unit(max_freq)
|
|
78
|
+
|
|
79
|
+
freq_divisors = {"Hz": 1.0, "kHz": 1e3, "MHz": 1e6, "GHz": 1e9}
|
|
80
|
+
divisor = freq_divisors.get(freq_unit, 1.0)
|
|
81
|
+
return freq / divisor, divisor, freq_unit
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _set_auto_ylimits(ax: Axes, mag_db: NDArray[np.float64]) -> None:
|
|
85
|
+
"""Set reasonable y-axis limits based on data.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
ax: Matplotlib axes.
|
|
89
|
+
mag_db: Magnitude data in dB.
|
|
90
|
+
"""
|
|
91
|
+
valid_db = mag_db[np.isfinite(mag_db)]
|
|
92
|
+
if len(valid_db) == 0:
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
y_max = np.max(valid_db)
|
|
96
|
+
y_min = max(np.min(valid_db), y_max - 120) # Limit dynamic range
|
|
97
|
+
ax.set_ylim(y_min, y_max + 5)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _apply_axis_limits(
|
|
101
|
+
ax: Axes,
|
|
102
|
+
divisor: float,
|
|
103
|
+
freq_range: tuple[float, float] | None,
|
|
104
|
+
xlim: tuple[float, float] | None,
|
|
105
|
+
ylim: tuple[float, float] | None,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Apply custom axis limits if specified.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
ax: Matplotlib axes.
|
|
111
|
+
divisor: Frequency divisor for unit conversion.
|
|
112
|
+
freq_range: Frequency range in Hz (will be converted to display units).
|
|
113
|
+
xlim: X-axis limits in display units.
|
|
114
|
+
ylim: Y-axis limits.
|
|
115
|
+
"""
|
|
116
|
+
if freq_range is not None and len(freq_range) == 2:
|
|
117
|
+
# freq_range is in Hz, convert to display units
|
|
118
|
+
freq_min = freq_range[0] / divisor
|
|
119
|
+
freq_max = freq_range[1] / divisor
|
|
120
|
+
|
|
121
|
+
# For log scale, ensure minimum is positive (avoid 0 on log axis)
|
|
122
|
+
if ax.get_xscale() == "log" and freq_min <= 0:
|
|
123
|
+
freq_min = freq_max / 1000 # Use a small positive value
|
|
124
|
+
|
|
125
|
+
ax.set_xlim(freq_min, freq_max)
|
|
126
|
+
elif xlim is not None:
|
|
127
|
+
ax.set_xlim(xlim)
|
|
128
|
+
|
|
129
|
+
if ylim is not None:
|
|
130
|
+
ax.set_ylim(ylim)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _prepare_spectrum_data(
|
|
134
|
+
trace: WaveformTrace,
|
|
135
|
+
fft_result: tuple[Any, Any] | None,
|
|
136
|
+
window: str,
|
|
137
|
+
db_ref: float | None,
|
|
138
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
139
|
+
"""Prepare spectrum data with FFT and dB scaling.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
trace: Waveform trace to analyze.
|
|
143
|
+
fft_result: Pre-computed FFT result or None.
|
|
144
|
+
window: Window function name.
|
|
145
|
+
db_ref: Reference for dB scaling or None.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Tuple of (frequencies, magnitudes_db).
|
|
149
|
+
"""
|
|
150
|
+
freq, mag_db = _get_fft_data(trace, fft_result, window)
|
|
151
|
+
|
|
152
|
+
# Adjust dB reference if specified
|
|
153
|
+
if db_ref is not None:
|
|
154
|
+
mag_db = mag_db - db_ref
|
|
155
|
+
|
|
156
|
+
return freq, mag_db
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _render_spectrum_plot(
|
|
160
|
+
ax: Axes,
|
|
161
|
+
freq_scaled: NDArray[np.float64],
|
|
162
|
+
mag_db: NDArray[np.float64],
|
|
163
|
+
freq_unit: str,
|
|
164
|
+
color: str,
|
|
165
|
+
title: str | None,
|
|
166
|
+
log_scale: bool,
|
|
167
|
+
show_grid: bool,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Render spectrum plot on axes.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
ax: Matplotlib axes to plot on.
|
|
173
|
+
freq_scaled: Scaled frequency array.
|
|
174
|
+
mag_db: Magnitude array in dB.
|
|
175
|
+
freq_unit: Frequency unit string.
|
|
176
|
+
color: Line color.
|
|
177
|
+
title: Plot title.
|
|
178
|
+
log_scale: Use logarithmic frequency scale.
|
|
179
|
+
show_grid: Show grid lines.
|
|
180
|
+
"""
|
|
181
|
+
ax.plot(freq_scaled, mag_db, color=color, linewidth=0.8)
|
|
182
|
+
ax.set_xlabel(f"Frequency ({freq_unit})")
|
|
183
|
+
ax.set_ylabel("Magnitude (dB)")
|
|
184
|
+
ax.set_xscale("log" if log_scale else "linear")
|
|
185
|
+
ax.set_title(title if title else "Magnitude Spectrum")
|
|
186
|
+
|
|
187
|
+
if show_grid:
|
|
188
|
+
ax.grid(True, alpha=0.3, which="both")
|
|
189
|
+
|
|
190
|
+
|
|
40
191
|
def plot_spectrum(
|
|
41
192
|
trace: WaveformTrace,
|
|
42
193
|
*,
|
|
@@ -48,7 +199,6 @@ def plot_spectrum(
|
|
|
48
199
|
color: str = "C0",
|
|
49
200
|
title: str | None = None,
|
|
50
201
|
window: str = "hann",
|
|
51
|
-
xscale: Literal["linear", "log"] = "log",
|
|
52
202
|
show: bool = True,
|
|
53
203
|
save_path: str | None = None,
|
|
54
204
|
figsize: tuple[float, float] = (10, 6),
|
|
@@ -56,7 +206,6 @@ def plot_spectrum(
|
|
|
56
206
|
ylim: tuple[float, float] | None = None,
|
|
57
207
|
fft_result: tuple[Any, Any] | None = None,
|
|
58
208
|
log_scale: bool = True,
|
|
59
|
-
db_scale: bool | None = None,
|
|
60
209
|
) -> Figure:
|
|
61
210
|
"""Plot magnitude spectrum.
|
|
62
211
|
|
|
@@ -70,7 +219,6 @@ def plot_spectrum(
|
|
|
70
219
|
color: Line color.
|
|
71
220
|
title: Plot title.
|
|
72
221
|
window: Window function for FFT.
|
|
73
|
-
xscale: X-axis scale ("linear" or "log"). Deprecated, use log_scale instead.
|
|
74
222
|
show: If True, call plt.show() to display the plot.
|
|
75
223
|
save_path: Path to save the figure. If None, figure is not saved.
|
|
76
224
|
figsize: Figure size (width, height) in inches. Only used if ax is None.
|
|
@@ -78,7 +226,6 @@ def plot_spectrum(
|
|
|
78
226
|
ylim: Y-axis limits (min, max) in dB.
|
|
79
227
|
fft_result: Pre-computed FFT result (frequencies, magnitudes). If None, computes FFT.
|
|
80
228
|
log_scale: Use logarithmic scale for frequency axis (default True).
|
|
81
|
-
db_scale: Deprecated alias for log_scale. If provided, overrides log_scale.
|
|
82
229
|
|
|
83
230
|
Returns:
|
|
84
231
|
Matplotlib Figure object.
|
|
@@ -100,12 +247,7 @@ def plot_spectrum(
|
|
|
100
247
|
if not HAS_MATPLOTLIB:
|
|
101
248
|
raise ImportError("matplotlib is required for visualization")
|
|
102
249
|
|
|
103
|
-
#
|
|
104
|
-
if db_scale is not None:
|
|
105
|
-
log_scale = db_scale
|
|
106
|
-
|
|
107
|
-
from oscura.analyzers.waveform.spectral import fft
|
|
108
|
-
|
|
250
|
+
# Figure/axes creation
|
|
109
251
|
if ax is None:
|
|
110
252
|
fig, ax = plt.subplots(figsize=figsize)
|
|
111
253
|
else:
|
|
@@ -114,77 +256,217 @@ def plot_spectrum(
|
|
|
114
256
|
raise ValueError("Axes must have an associated figure")
|
|
115
257
|
fig = cast("Figure", fig_temp)
|
|
116
258
|
|
|
117
|
-
#
|
|
118
|
-
|
|
119
|
-
freq, mag_db = fft_result
|
|
120
|
-
else:
|
|
121
|
-
freq, mag_db = fft(trace, window=window) # type: ignore[misc]
|
|
259
|
+
# Data preparation
|
|
260
|
+
freq, mag_db = _prepare_spectrum_data(trace, fft_result, window, db_ref)
|
|
122
261
|
|
|
123
|
-
#
|
|
124
|
-
|
|
125
|
-
max_freq = freq[-1]
|
|
126
|
-
if max_freq >= 1e9:
|
|
127
|
-
freq_unit = "GHz"
|
|
128
|
-
elif max_freq >= 1e6:
|
|
129
|
-
freq_unit = "MHz"
|
|
130
|
-
elif max_freq >= 1e3:
|
|
131
|
-
freq_unit = "kHz"
|
|
132
|
-
else:
|
|
133
|
-
freq_unit = "Hz"
|
|
262
|
+
# Unit/scale selection
|
|
263
|
+
freq_scaled, divisor, freq_unit = _scale_frequencies(freq, freq_unit)
|
|
134
264
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
freq_scaled = freq / divisor
|
|
265
|
+
# Plotting/rendering
|
|
266
|
+
_render_spectrum_plot(ax, freq_scaled, mag_db, freq_unit, color, title, log_scale, show_grid)
|
|
138
267
|
|
|
139
|
-
#
|
|
140
|
-
|
|
141
|
-
|
|
268
|
+
# Set limits
|
|
269
|
+
_set_auto_ylimits(ax, mag_db)
|
|
270
|
+
_apply_axis_limits(ax, divisor, freq_range, xlim, ylim)
|
|
142
271
|
|
|
143
|
-
#
|
|
144
|
-
|
|
272
|
+
# Layout/formatting
|
|
273
|
+
fig.tight_layout()
|
|
145
274
|
|
|
146
|
-
|
|
147
|
-
|
|
275
|
+
if save_path is not None:
|
|
276
|
+
fig.savefig(save_path, dpi=300, bbox_inches="tight")
|
|
148
277
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
ax.set_xscale("log" if log_scale else "linear")
|
|
278
|
+
if show:
|
|
279
|
+
plt.show()
|
|
152
280
|
|
|
153
|
-
|
|
154
|
-
|
|
281
|
+
return fig
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _auto_select_time_unit(max_time: float) -> str:
|
|
285
|
+
"""Select appropriate time unit based on maximum time value.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
max_time: Maximum time value in seconds.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Time unit string ("s", "ms", "us", or "ns").
|
|
292
|
+
"""
|
|
293
|
+
if max_time < 1e-6:
|
|
294
|
+
return "ns"
|
|
295
|
+
elif max_time < 1e-3:
|
|
296
|
+
return "us"
|
|
297
|
+
elif max_time < 1:
|
|
298
|
+
return "ms"
|
|
155
299
|
else:
|
|
156
|
-
|
|
300
|
+
return "s"
|
|
157
301
|
|
|
158
|
-
if show_grid:
|
|
159
|
-
ax.grid(True, alpha=0.3, which="both")
|
|
160
302
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if len(valid_db) > 0:
|
|
164
|
-
y_max = np.max(valid_db)
|
|
165
|
-
y_min = max(np.min(valid_db), y_max - 120) # Limit dynamic range
|
|
166
|
-
ax.set_ylim(y_min, y_max + 5)
|
|
167
|
-
|
|
168
|
-
# Apply custom limits if specified
|
|
169
|
-
if freq_range is not None:
|
|
170
|
-
ax.set_xlim(freq_range[0] / divisor, freq_range[1] / divisor)
|
|
171
|
-
elif xlim is not None:
|
|
172
|
-
ax.set_xlim(xlim)
|
|
303
|
+
def _auto_select_freq_unit(max_freq: float) -> str:
|
|
304
|
+
"""Select appropriate frequency unit based on maximum frequency.
|
|
173
305
|
|
|
174
|
-
|
|
175
|
-
|
|
306
|
+
Args:
|
|
307
|
+
max_freq: Maximum frequency in Hz.
|
|
176
308
|
|
|
177
|
-
|
|
309
|
+
Returns:
|
|
310
|
+
Frequency unit string ("Hz", "kHz", "MHz", or "GHz").
|
|
311
|
+
"""
|
|
312
|
+
if max_freq >= 1e9:
|
|
313
|
+
return "GHz"
|
|
314
|
+
elif max_freq >= 1e6:
|
|
315
|
+
return "MHz"
|
|
316
|
+
elif max_freq >= 1e3:
|
|
317
|
+
return "kHz"
|
|
318
|
+
else:
|
|
319
|
+
return "Hz"
|
|
178
320
|
|
|
179
|
-
# Save if path provided
|
|
180
|
-
if save_path is not None:
|
|
181
|
-
fig.savefig(save_path, dpi=300, bbox_inches="tight")
|
|
182
321
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
322
|
+
def _get_unit_multipliers() -> tuple[dict[str, float], dict[str, float]]:
|
|
323
|
+
"""Get time and frequency unit multipliers.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
Tuple of (time_multipliers, freq_divisors).
|
|
327
|
+
"""
|
|
328
|
+
time_mult = {"s": 1.0, "ms": 1e3, "us": 1e6, "ns": 1e9}
|
|
329
|
+
freq_div = {"Hz": 1.0, "kHz": 1e3, "MHz": 1e6, "GHz": 1e9}
|
|
330
|
+
return time_mult, freq_div
|
|
186
331
|
|
|
187
|
-
|
|
332
|
+
|
|
333
|
+
def _auto_color_limits(
|
|
334
|
+
data: NDArray[np.float64], vmin: float | None, vmax: float | None
|
|
335
|
+
) -> tuple[float | None, float | None]:
|
|
336
|
+
"""Automatically determine color limits for spectrogram.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
data: Spectrogram data in dB.
|
|
340
|
+
vmin: Minimum dB value (if None, auto-computed).
|
|
341
|
+
vmax: Maximum dB value (if None, auto-computed).
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
Tuple of (vmin, vmax).
|
|
345
|
+
"""
|
|
346
|
+
if vmin is not None and vmax is not None:
|
|
347
|
+
return vmin, vmax
|
|
348
|
+
|
|
349
|
+
valid_db = data[np.isfinite(data)]
|
|
350
|
+
if len(valid_db) == 0:
|
|
351
|
+
return vmin, vmax
|
|
352
|
+
|
|
353
|
+
if vmax is None:
|
|
354
|
+
vmax = np.max(valid_db)
|
|
355
|
+
if vmin is None:
|
|
356
|
+
vmin = max(np.min(valid_db), vmax - 80)
|
|
357
|
+
|
|
358
|
+
return vmin, vmax
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _compute_spectrogram_data(
|
|
362
|
+
trace: WaveformTrace,
|
|
363
|
+
window: str,
|
|
364
|
+
nperseg: int | None,
|
|
365
|
+
nfft: int | None,
|
|
366
|
+
overlap: float | None,
|
|
367
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
|
|
368
|
+
"""Compute spectrogram data using STFT.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
trace: Waveform trace to analyze.
|
|
372
|
+
window: Window function name.
|
|
373
|
+
nperseg: Segment length for STFT.
|
|
374
|
+
nfft: FFT length (overrides nperseg if specified).
|
|
375
|
+
overlap: Overlap fraction (0.0 to 1.0).
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
Tuple of (times, frequencies, Sxx_db).
|
|
379
|
+
"""
|
|
380
|
+
from oscura.analyzers.waveform.spectral import spectrogram
|
|
381
|
+
|
|
382
|
+
if nfft is not None:
|
|
383
|
+
nperseg = nfft
|
|
384
|
+
noverlap = int(nperseg * overlap) if overlap is not None and nperseg is not None else None
|
|
385
|
+
|
|
386
|
+
return spectrogram(trace, window=window, nperseg=nperseg, noverlap=noverlap)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _scale_spectrogram_axes(
|
|
390
|
+
times: NDArray[np.float64],
|
|
391
|
+
freq: NDArray[np.float64],
|
|
392
|
+
time_unit: str,
|
|
393
|
+
freq_unit: str,
|
|
394
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64], str, str]:
|
|
395
|
+
"""Scale time and frequency axes to appropriate units.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
times: Time array in seconds.
|
|
399
|
+
freq: Frequency array in Hz.
|
|
400
|
+
time_unit: Time unit ("auto" or specific).
|
|
401
|
+
freq_unit: Frequency unit ("auto" or specific).
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
Tuple of (times_scaled, freq_scaled, time_unit, freq_unit).
|
|
405
|
+
"""
|
|
406
|
+
if time_unit == "auto":
|
|
407
|
+
max_time = times[-1] if len(times) > 0 else 0
|
|
408
|
+
time_unit = _auto_select_time_unit(max_time)
|
|
409
|
+
|
|
410
|
+
if freq_unit == "auto":
|
|
411
|
+
max_freq = freq[-1] if len(freq) > 0 else 0
|
|
412
|
+
freq_unit = _auto_select_freq_unit(max_freq)
|
|
413
|
+
|
|
414
|
+
time_multipliers, freq_divisors = _get_unit_multipliers()
|
|
415
|
+
times_scaled = times * time_multipliers.get(time_unit, 1.0)
|
|
416
|
+
freq_scaled = freq / freq_divisors.get(freq_unit, 1.0)
|
|
417
|
+
|
|
418
|
+
return times_scaled, freq_scaled, time_unit, freq_unit
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _render_spectrogram_plot(
|
|
422
|
+
ax: Axes,
|
|
423
|
+
times_scaled: NDArray[np.float64],
|
|
424
|
+
freq_scaled: NDArray[np.float64],
|
|
425
|
+
Sxx_db: NDArray[np.float64],
|
|
426
|
+
time_unit: str,
|
|
427
|
+
freq_unit: str,
|
|
428
|
+
cmap: str,
|
|
429
|
+
vmin: float | None,
|
|
430
|
+
vmax: float | None,
|
|
431
|
+
title: str | None,
|
|
432
|
+
) -> None:
|
|
433
|
+
"""Render spectrogram plot on axes.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
ax: Matplotlib axes to plot on.
|
|
437
|
+
times_scaled: Scaled time array.
|
|
438
|
+
freq_scaled: Scaled frequency array.
|
|
439
|
+
Sxx_db: Spectrogram data in dB.
|
|
440
|
+
time_unit: Time unit string.
|
|
441
|
+
freq_unit: Frequency unit string.
|
|
442
|
+
cmap: Colormap name.
|
|
443
|
+
vmin: Minimum dB value for color scaling.
|
|
444
|
+
vmax: Maximum dB value for color scaling.
|
|
445
|
+
title: Plot title.
|
|
446
|
+
"""
|
|
447
|
+
# Auto color limits
|
|
448
|
+
vmin, vmax = _auto_color_limits(Sxx_db, vmin, vmax)
|
|
449
|
+
|
|
450
|
+
# Plot
|
|
451
|
+
pcm = ax.pcolormesh(
|
|
452
|
+
times_scaled,
|
|
453
|
+
freq_scaled,
|
|
454
|
+
Sxx_db,
|
|
455
|
+
shading="auto",
|
|
456
|
+
cmap=cmap,
|
|
457
|
+
vmin=vmin,
|
|
458
|
+
vmax=vmax,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
ax.set_xlabel(f"Time ({time_unit})")
|
|
462
|
+
ax.set_ylabel(f"Frequency ({freq_unit})")
|
|
463
|
+
ax.set_title(title if title else "Spectrogram")
|
|
464
|
+
|
|
465
|
+
# Colorbar
|
|
466
|
+
fig = ax.get_figure()
|
|
467
|
+
if fig is not None:
|
|
468
|
+
cbar = fig.colorbar(pcm, ax=ax)
|
|
469
|
+
cbar.set_label("Magnitude (dB)")
|
|
188
470
|
|
|
189
471
|
|
|
190
472
|
def plot_spectrogram(
|
|
@@ -232,8 +514,7 @@ def plot_spectrogram(
|
|
|
232
514
|
if not HAS_MATPLOTLIB:
|
|
233
515
|
raise ImportError("matplotlib is required for visualization")
|
|
234
516
|
|
|
235
|
-
|
|
236
|
-
|
|
517
|
+
# Figure/axes creation
|
|
237
518
|
if ax is None:
|
|
238
519
|
fig, ax = plt.subplots(figsize=(10, 4))
|
|
239
520
|
else:
|
|
@@ -242,80 +523,20 @@ def plot_spectrogram(
|
|
|
242
523
|
raise ValueError("Axes must have an associated figure")
|
|
243
524
|
fig = cast("Figure", fig_temp)
|
|
244
525
|
|
|
245
|
-
#
|
|
246
|
-
|
|
247
|
-
nperseg = nfft
|
|
248
|
-
|
|
249
|
-
# Compute spectrogram with optional overlap
|
|
250
|
-
noverlap = None
|
|
251
|
-
if overlap is not None and nperseg is not None:
|
|
252
|
-
noverlap = int(nperseg * overlap)
|
|
253
|
-
times, freq, Sxx_db = spectrogram(trace, window=window, nperseg=nperseg, noverlap=noverlap)
|
|
254
|
-
|
|
255
|
-
# Auto-select units
|
|
256
|
-
if time_unit == "auto":
|
|
257
|
-
max_time = times[-1] if len(times) > 0 else 0
|
|
258
|
-
if max_time < 1e-6:
|
|
259
|
-
time_unit = "ns"
|
|
260
|
-
elif max_time < 1e-3:
|
|
261
|
-
time_unit = "us"
|
|
262
|
-
elif max_time < 1:
|
|
263
|
-
time_unit = "ms"
|
|
264
|
-
else:
|
|
265
|
-
time_unit = "s"
|
|
266
|
-
|
|
267
|
-
if freq_unit == "auto":
|
|
268
|
-
max_freq = freq[-1] if len(freq) > 0 else 0
|
|
269
|
-
if max_freq >= 1e9:
|
|
270
|
-
freq_unit = "GHz"
|
|
271
|
-
elif max_freq >= 1e6:
|
|
272
|
-
freq_unit = "MHz"
|
|
273
|
-
elif max_freq >= 1e3:
|
|
274
|
-
freq_unit = "kHz"
|
|
275
|
-
else:
|
|
276
|
-
freq_unit = "Hz"
|
|
277
|
-
|
|
278
|
-
time_multipliers = {"s": 1.0, "ms": 1e3, "us": 1e6, "ns": 1e9}
|
|
279
|
-
freq_divisors = {"Hz": 1.0, "kHz": 1e3, "MHz": 1e6, "GHz": 1e9}
|
|
280
|
-
|
|
281
|
-
time_mult = time_multipliers.get(time_unit, 1.0)
|
|
282
|
-
freq_div = freq_divisors.get(freq_unit, 1.0)
|
|
526
|
+
# Data preparation/computation
|
|
527
|
+
times, freq, Sxx_db = _compute_spectrogram_data(trace, window, nperseg, nfft, overlap)
|
|
283
528
|
|
|
284
|
-
|
|
285
|
-
freq_scaled
|
|
286
|
-
|
|
287
|
-
# Auto color limits
|
|
288
|
-
if vmin is None or vmax is None:
|
|
289
|
-
valid_db = Sxx_db[np.isfinite(Sxx_db)]
|
|
290
|
-
if len(valid_db) > 0:
|
|
291
|
-
if vmax is None:
|
|
292
|
-
vmax = np.max(valid_db)
|
|
293
|
-
if vmin is None:
|
|
294
|
-
vmin = max(np.min(valid_db), vmax - 80)
|
|
295
|
-
|
|
296
|
-
# Plot
|
|
297
|
-
pcm = ax.pcolormesh(
|
|
298
|
-
times_scaled,
|
|
299
|
-
freq_scaled,
|
|
300
|
-
Sxx_db,
|
|
301
|
-
shading="auto",
|
|
302
|
-
cmap=cmap,
|
|
303
|
-
vmin=vmin,
|
|
304
|
-
vmax=vmax,
|
|
529
|
+
# Unit/scale selection
|
|
530
|
+
times_scaled, freq_scaled, time_unit, freq_unit = _scale_spectrogram_axes(
|
|
531
|
+
times, freq, time_unit, freq_unit
|
|
305
532
|
)
|
|
306
533
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
ax.set_title(title)
|
|
312
|
-
else:
|
|
313
|
-
ax.set_title("Spectrogram")
|
|
314
|
-
|
|
315
|
-
# Colorbar
|
|
316
|
-
cbar = fig.colorbar(pcm, ax=ax)
|
|
317
|
-
cbar.set_label("Magnitude (dB)")
|
|
534
|
+
# Plotting/rendering
|
|
535
|
+
_render_spectrogram_plot(
|
|
536
|
+
ax, times_scaled, freq_scaled, Sxx_db, time_unit, freq_unit, cmap, vmin, vmax, title
|
|
537
|
+
)
|
|
318
538
|
|
|
539
|
+
# Layout/formatting
|
|
319
540
|
fig.tight_layout()
|
|
320
541
|
return fig
|
|
321
542
|
|
|
@@ -329,7 +550,7 @@ def plot_psd(
|
|
|
329
550
|
color: str = "C0",
|
|
330
551
|
title: str | None = None,
|
|
331
552
|
window: str = "hann",
|
|
332
|
-
|
|
553
|
+
log_scale: bool = True,
|
|
333
554
|
) -> Figure:
|
|
334
555
|
"""Plot Power Spectral Density.
|
|
335
556
|
|
|
@@ -341,7 +562,7 @@ def plot_psd(
|
|
|
341
562
|
color: Line color.
|
|
342
563
|
title: Plot title.
|
|
343
564
|
window: Window function.
|
|
344
|
-
|
|
565
|
+
log_scale: Use logarithmic scale for frequency axis (default True).
|
|
345
566
|
|
|
346
567
|
Returns:
|
|
347
568
|
Matplotlib Figure object.
|
|
@@ -391,7 +612,7 @@ def plot_psd(
|
|
|
391
612
|
|
|
392
613
|
ax.set_xlabel(f"Frequency ({freq_unit})")
|
|
393
614
|
ax.set_ylabel("PSD (dB/Hz)")
|
|
394
|
-
ax.set_xscale(
|
|
615
|
+
ax.set_xscale("log" if log_scale else "linear")
|
|
395
616
|
|
|
396
617
|
if title:
|
|
397
618
|
ax.set_title(title)
|
|
@@ -472,17 +693,10 @@ def plot_fft(
|
|
|
472
693
|
if not HAS_MATPLOTLIB:
|
|
473
694
|
raise ImportError("matplotlib is required for visualization")
|
|
474
695
|
|
|
475
|
-
#
|
|
476
|
-
|
|
477
|
-
fig, ax = plt.subplots(figsize=figsize)
|
|
478
|
-
else:
|
|
479
|
-
fig_temp = ax.get_figure()
|
|
480
|
-
if fig_temp is None:
|
|
481
|
-
raise ValueError("Axes must have an associated figure")
|
|
482
|
-
fig = cast("Figure", fig_temp)
|
|
696
|
+
# Setup figure and axes
|
|
697
|
+
fig, ax = _setup_plot_figure(ax, figsize)
|
|
483
698
|
|
|
484
|
-
#
|
|
485
|
-
xscale_value: Literal["linear", "log"] = "log" if log_scale else "linear"
|
|
699
|
+
# Plot spectrum using main plotting function
|
|
486
700
|
plot_spectrum(
|
|
487
701
|
trace,
|
|
488
702
|
ax=ax,
|
|
@@ -491,12 +705,47 @@ def plot_fft(
|
|
|
491
705
|
color=color,
|
|
492
706
|
title=title if title else "FFT Magnitude Spectrum",
|
|
493
707
|
window=window,
|
|
494
|
-
|
|
708
|
+
log_scale=log_scale,
|
|
495
709
|
)
|
|
496
710
|
|
|
497
|
-
# Apply custom labels
|
|
711
|
+
# Apply custom labels and limits
|
|
712
|
+
_apply_custom_labels(ax, xlabel, ylabel)
|
|
713
|
+
_apply_axis_limits_simple(ax, xlim, ylim)
|
|
714
|
+
|
|
715
|
+
# Output handling
|
|
716
|
+
_handle_plot_output(fig, save_path, show)
|
|
717
|
+
|
|
718
|
+
return fig
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _setup_plot_figure(ax: Axes | None, figsize: tuple[float, float]) -> tuple[Figure, Axes]:
|
|
722
|
+
"""Setup figure and axes for plotting.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
ax: Existing axes or None.
|
|
726
|
+
figsize: Figure size if creating new.
|
|
727
|
+
|
|
728
|
+
Returns:
|
|
729
|
+
Tuple of (Figure, Axes).
|
|
730
|
+
"""
|
|
731
|
+
if ax is None:
|
|
732
|
+
return plt.subplots(figsize=figsize)
|
|
733
|
+
|
|
734
|
+
fig_temp = ax.get_figure()
|
|
735
|
+
if fig_temp is None:
|
|
736
|
+
raise ValueError("Axes must have an associated figure")
|
|
737
|
+
return cast("Figure", fig_temp), ax
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _apply_custom_labels(ax: Axes, xlabel: str, ylabel: str) -> None:
|
|
741
|
+
"""Apply custom labels to plot axes.
|
|
742
|
+
|
|
743
|
+
Args:
|
|
744
|
+
ax: Matplotlib axes.
|
|
745
|
+
xlabel: X-axis label.
|
|
746
|
+
ylabel: Y-axis label.
|
|
747
|
+
"""
|
|
498
748
|
if xlabel != "Frequency":
|
|
499
|
-
# Get current label to preserve unit
|
|
500
749
|
current_label = ax.get_xlabel()
|
|
501
750
|
if "(" in current_label and ")" in current_label:
|
|
502
751
|
unit = current_label[current_label.find("(") : current_label.find(")") + 1]
|
|
@@ -507,22 +756,123 @@ def plot_fft(
|
|
|
507
756
|
if ylabel != "Magnitude (dB)":
|
|
508
757
|
ax.set_ylabel(ylabel)
|
|
509
758
|
|
|
510
|
-
|
|
759
|
+
|
|
760
|
+
def _apply_axis_limits_simple(
|
|
761
|
+
ax: Axes, xlim: tuple[float, float] | None, ylim: tuple[float, float] | None
|
|
762
|
+
) -> None:
|
|
763
|
+
"""Apply axis limits if specified.
|
|
764
|
+
|
|
765
|
+
Args:
|
|
766
|
+
ax: Matplotlib axes.
|
|
767
|
+
xlim: X-axis limits.
|
|
768
|
+
ylim: Y-axis limits.
|
|
769
|
+
"""
|
|
511
770
|
if xlim is not None:
|
|
512
771
|
ax.set_xlim(xlim)
|
|
513
|
-
|
|
514
772
|
if ylim is not None:
|
|
515
773
|
ax.set_ylim(ylim)
|
|
516
774
|
|
|
517
|
-
|
|
775
|
+
|
|
776
|
+
def _handle_plot_output(fig: Figure, save_path: str | None, show: bool) -> None:
|
|
777
|
+
"""Handle plot output (save and/or show).
|
|
778
|
+
|
|
779
|
+
Args:
|
|
780
|
+
fig: Matplotlib figure.
|
|
781
|
+
save_path: Path to save figure.
|
|
782
|
+
show: Whether to display plot.
|
|
783
|
+
"""
|
|
518
784
|
if save_path is not None:
|
|
519
785
|
fig.savefig(save_path, dpi=300, bbox_inches="tight")
|
|
520
|
-
|
|
521
|
-
# Show if requested
|
|
522
786
|
if show:
|
|
523
787
|
plt.show()
|
|
524
788
|
|
|
525
|
-
|
|
789
|
+
|
|
790
|
+
def _create_harmonic_labels(
|
|
791
|
+
n_harmonics: int,
|
|
792
|
+
fundamental_freq: float | None,
|
|
793
|
+
) -> list[str]:
|
|
794
|
+
"""Create x-axis labels for harmonics.
|
|
795
|
+
|
|
796
|
+
Args:
|
|
797
|
+
n_harmonics: Number of harmonics.
|
|
798
|
+
fundamental_freq: Fundamental frequency in Hz or None.
|
|
799
|
+
|
|
800
|
+
Returns:
|
|
801
|
+
List of label strings.
|
|
802
|
+
"""
|
|
803
|
+
if fundamental_freq is not None:
|
|
804
|
+
labels = [
|
|
805
|
+
f"H{i + 1}\n({(i + 1) * fundamental_freq / 1e3:.1f} kHz)"
|
|
806
|
+
if fundamental_freq >= 1000
|
|
807
|
+
else f"H{i + 1}\n({(i + 1) * fundamental_freq:.0f} Hz)"
|
|
808
|
+
for i in range(n_harmonics)
|
|
809
|
+
]
|
|
810
|
+
labels[0] = (
|
|
811
|
+
f"Fund\n({fundamental_freq / 1e3:.1f} kHz)"
|
|
812
|
+
if fundamental_freq >= 1000
|
|
813
|
+
else f"Fund\n({fundamental_freq:.0f} Hz)"
|
|
814
|
+
)
|
|
815
|
+
else:
|
|
816
|
+
labels = [f"H{i + 1}" for i in range(n_harmonics)]
|
|
817
|
+
labels[0] = "Fund"
|
|
818
|
+
|
|
819
|
+
return labels
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _assign_harmonic_colors(
|
|
823
|
+
harmonic_magnitudes: NDArray[np.floating[Any]],
|
|
824
|
+
) -> list[str]:
|
|
825
|
+
"""Assign colors to harmonics based on magnitude.
|
|
826
|
+
|
|
827
|
+
Args:
|
|
828
|
+
harmonic_magnitudes: Array of harmonic magnitudes in dB.
|
|
829
|
+
|
|
830
|
+
Returns:
|
|
831
|
+
List of color strings.
|
|
832
|
+
"""
|
|
833
|
+
colors = []
|
|
834
|
+
for i, mag in enumerate(harmonic_magnitudes):
|
|
835
|
+
if i == 0:
|
|
836
|
+
colors.append("#3498DB") # Blue for fundamental
|
|
837
|
+
elif mag > -30:
|
|
838
|
+
colors.append("#E74C3C") # Red for significant harmonics
|
|
839
|
+
elif mag > -50:
|
|
840
|
+
colors.append("#F39C12") # Orange for moderate
|
|
841
|
+
else:
|
|
842
|
+
colors.append("#95A5A6") # Gray for low
|
|
843
|
+
|
|
844
|
+
return colors
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _add_thd_annotation(
|
|
848
|
+
ax: Axes,
|
|
849
|
+
thd_value: float | None,
|
|
850
|
+
show_thd: bool,
|
|
851
|
+
) -> None:
|
|
852
|
+
"""Add THD annotation to plot.
|
|
853
|
+
|
|
854
|
+
Args:
|
|
855
|
+
ax: Matplotlib axes to annotate.
|
|
856
|
+
thd_value: THD value in dB or %.
|
|
857
|
+
show_thd: Show annotation flag.
|
|
858
|
+
"""
|
|
859
|
+
if show_thd and thd_value is not None:
|
|
860
|
+
if thd_value > 0:
|
|
861
|
+
thd_text = f"THD: {thd_value:.2f}%"
|
|
862
|
+
else:
|
|
863
|
+
thd_text = f"THD: {thd_value:.1f} dB"
|
|
864
|
+
|
|
865
|
+
ax.text(
|
|
866
|
+
0.98,
|
|
867
|
+
0.98,
|
|
868
|
+
thd_text,
|
|
869
|
+
transform=ax.transAxes,
|
|
870
|
+
fontsize=12,
|
|
871
|
+
fontweight="bold",
|
|
872
|
+
ha="right",
|
|
873
|
+
va="top",
|
|
874
|
+
bbox={"boxstyle": "round,pad=0.5", "facecolor": "wheat", "alpha": 0.9},
|
|
875
|
+
)
|
|
526
876
|
|
|
527
877
|
|
|
528
878
|
def plot_thd_bars(
|
|
@@ -571,6 +921,7 @@ def plot_thd_bars(
|
|
|
571
921
|
if not HAS_MATPLOTLIB:
|
|
572
922
|
raise ImportError("matplotlib is required for visualization")
|
|
573
923
|
|
|
924
|
+
# Figure/axes creation
|
|
574
925
|
if ax is None:
|
|
575
926
|
fig, ax = plt.subplots(figsize=figsize)
|
|
576
927
|
else:
|
|
@@ -579,76 +930,27 @@ def plot_thd_bars(
|
|
|
579
930
|
raise ValueError("Axes must have an associated figure")
|
|
580
931
|
fig = cast("Figure", fig_temp)
|
|
581
932
|
|
|
933
|
+
# Data preparation
|
|
582
934
|
n_harmonics = len(harmonic_magnitudes)
|
|
583
|
-
|
|
584
|
-
# Create x-positions for harmonics
|
|
585
935
|
x_pos = np.arange(n_harmonics)
|
|
936
|
+
labels = _create_harmonic_labels(n_harmonics, fundamental_freq)
|
|
937
|
+
colors = _assign_harmonic_colors(harmonic_magnitudes)
|
|
586
938
|
|
|
587
|
-
#
|
|
588
|
-
if fundamental_freq is not None:
|
|
589
|
-
labels = [
|
|
590
|
-
f"H{i + 1}\n({(i + 1) * fundamental_freq / 1e3:.1f} kHz)"
|
|
591
|
-
if fundamental_freq >= 1000
|
|
592
|
-
else f"H{i + 1}\n({(i + 1) * fundamental_freq:.0f} Hz)"
|
|
593
|
-
for i in range(n_harmonics)
|
|
594
|
-
]
|
|
595
|
-
labels[0] = (
|
|
596
|
-
f"Fund\n({fundamental_freq / 1e3:.1f} kHz)"
|
|
597
|
-
if fundamental_freq >= 1000
|
|
598
|
-
else f"Fund\n({fundamental_freq:.0f} Hz)"
|
|
599
|
-
)
|
|
600
|
-
else:
|
|
601
|
-
labels = [f"H{i + 1}" for i in range(n_harmonics)]
|
|
602
|
-
labels[0] = "Fund"
|
|
603
|
-
|
|
604
|
-
# Color code: fundamental in blue, harmonics in orange/red based on magnitude
|
|
605
|
-
colors = []
|
|
606
|
-
for i, mag in enumerate(harmonic_magnitudes):
|
|
607
|
-
if i == 0:
|
|
608
|
-
colors.append("#3498DB") # Blue for fundamental
|
|
609
|
-
elif mag > -30:
|
|
610
|
-
colors.append("#E74C3C") # Red for significant harmonics
|
|
611
|
-
elif mag > -50:
|
|
612
|
-
colors.append("#F39C12") # Orange for moderate
|
|
613
|
-
else:
|
|
614
|
-
colors.append("#95A5A6") # Gray for low
|
|
615
|
-
|
|
616
|
-
# Plot bars
|
|
939
|
+
# Plotting/rendering
|
|
617
940
|
ax.bar(
|
|
618
941
|
x_pos, harmonic_magnitudes - reference_db, color=colors, edgecolor="black", linewidth=0.5
|
|
619
942
|
)
|
|
620
|
-
|
|
621
|
-
# Reference line at fundamental level
|
|
622
943
|
ax.axhline(0, color="gray", linestyle="--", linewidth=1, alpha=0.7)
|
|
623
944
|
|
|
624
|
-
#
|
|
625
|
-
|
|
626
|
-
# Position in upper right
|
|
627
|
-
if thd_value > 0:
|
|
628
|
-
thd_text = f"THD: {thd_value:.2f}%"
|
|
629
|
-
else:
|
|
630
|
-
thd_text = f"THD: {thd_value:.1f} dB"
|
|
631
|
-
|
|
632
|
-
ax.text(
|
|
633
|
-
0.98,
|
|
634
|
-
0.98,
|
|
635
|
-
thd_text,
|
|
636
|
-
transform=ax.transAxes,
|
|
637
|
-
fontsize=12,
|
|
638
|
-
fontweight="bold",
|
|
639
|
-
ha="right",
|
|
640
|
-
va="top",
|
|
641
|
-
bbox={"boxstyle": "round,pad=0.5", "facecolor": "wheat", "alpha": 0.9},
|
|
642
|
-
)
|
|
643
|
-
|
|
644
|
-
# Labels
|
|
945
|
+
# Annotation/labeling
|
|
946
|
+
_add_thd_annotation(ax, thd_value, show_thd)
|
|
645
947
|
ax.set_xticks(x_pos)
|
|
646
948
|
ax.set_xticklabels(labels, fontsize=9)
|
|
647
949
|
ax.set_xlabel("Harmonic", fontsize=11)
|
|
648
950
|
ax.set_ylabel("Magnitude (dB rel. to fundamental)", fontsize=11)
|
|
649
951
|
ax.grid(True, axis="y", alpha=0.3)
|
|
650
952
|
|
|
651
|
-
#
|
|
953
|
+
# Layout/formatting
|
|
652
954
|
min_mag = min(harmonic_magnitudes) - reference_db
|
|
653
955
|
ax.set_ylim(min(min_mag - 10, -80), 10)
|
|
654
956
|
|
|
@@ -706,42 +1008,122 @@ def plot_quality_summary(
|
|
|
706
1008
|
if not HAS_MATPLOTLIB:
|
|
707
1009
|
raise ImportError("matplotlib is required for visualization")
|
|
708
1010
|
|
|
1011
|
+
# Setup figure and axes
|
|
1012
|
+
fig, ax = _setup_quality_plot_axes(ax, figsize)
|
|
1013
|
+
|
|
1014
|
+
# Define metric metadata
|
|
1015
|
+
metric_info = _get_metric_info()
|
|
1016
|
+
|
|
1017
|
+
# Filter to available metrics
|
|
1018
|
+
available_metrics = [(k, v) for k, v in metrics.items() if k in metric_info]
|
|
1019
|
+
|
|
1020
|
+
if len(available_metrics) == 0:
|
|
1021
|
+
ax.text(0.5, 0.5, "No metrics available", ha="center", va="center", fontsize=14)
|
|
1022
|
+
ax.axis("off")
|
|
1023
|
+
return fig
|
|
1024
|
+
|
|
1025
|
+
# Plot metrics with pass/fail coloring
|
|
1026
|
+
_plot_quality_bars(ax, available_metrics, metric_info, show_specs)
|
|
1027
|
+
|
|
1028
|
+
# Add value labels and spec markers
|
|
1029
|
+
_add_quality_labels(ax, available_metrics, metric_info)
|
|
1030
|
+
_add_spec_markers(ax, available_metrics, show_specs)
|
|
1031
|
+
|
|
1032
|
+
# Configure axes
|
|
1033
|
+
_configure_quality_axes(ax, available_metrics, metric_info, title)
|
|
1034
|
+
|
|
1035
|
+
fig.tight_layout()
|
|
1036
|
+
|
|
1037
|
+
if save_path is not None:
|
|
1038
|
+
fig.savefig(save_path, dpi=300, bbox_inches="tight")
|
|
1039
|
+
|
|
1040
|
+
if show:
|
|
1041
|
+
plt.show()
|
|
1042
|
+
|
|
1043
|
+
return fig
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _setup_quality_plot_axes(
|
|
1047
|
+
ax: Axes | None,
|
|
1048
|
+
figsize: tuple[float, float],
|
|
1049
|
+
) -> tuple[Figure, Axes]:
|
|
1050
|
+
"""Setup figure and axes for quality summary plot.
|
|
1051
|
+
|
|
1052
|
+
Args:
|
|
1053
|
+
ax: Existing axes or None.
|
|
1054
|
+
figsize: Figure size if creating new figure.
|
|
1055
|
+
|
|
1056
|
+
Returns:
|
|
1057
|
+
Tuple of (Figure, Axes).
|
|
1058
|
+
|
|
1059
|
+
Raises:
|
|
1060
|
+
ValueError: If provided axes has no associated figure.
|
|
1061
|
+
"""
|
|
709
1062
|
if ax is None:
|
|
710
|
-
fig,
|
|
1063
|
+
fig, ax_obj = plt.subplots(figsize=figsize)
|
|
1064
|
+
return fig, ax_obj
|
|
711
1065
|
else:
|
|
712
1066
|
fig_temp = ax.get_figure()
|
|
713
1067
|
if fig_temp is None:
|
|
714
1068
|
raise ValueError("Axes must have an associated figure")
|
|
715
|
-
|
|
1069
|
+
return cast("Figure", fig_temp), ax
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def _get_metric_info() -> dict[str, dict[str, Any]]:
|
|
1073
|
+
"""Get metric display information.
|
|
716
1074
|
|
|
717
|
-
|
|
718
|
-
|
|
1075
|
+
Returns:
|
|
1076
|
+
Dictionary mapping metric keys to display info (name, unit, higher_better).
|
|
1077
|
+
"""
|
|
1078
|
+
return {
|
|
719
1079
|
"snr": {"name": "SNR", "unit": "dB", "higher_better": True},
|
|
720
1080
|
"sinad": {"name": "SINAD", "unit": "dB", "higher_better": True},
|
|
721
|
-
"thd": {
|
|
722
|
-
"name": "THD",
|
|
723
|
-
"unit": "dB",
|
|
724
|
-
"higher_better": False,
|
|
725
|
-
}, # Lower (more negative) is better
|
|
1081
|
+
"thd": {"name": "THD", "unit": "dB", "higher_better": False},
|
|
726
1082
|
"enob": {"name": "ENOB", "unit": "bits", "higher_better": True},
|
|
727
1083
|
"sfdr": {"name": "SFDR", "unit": "dBc", "higher_better": True},
|
|
728
1084
|
}
|
|
729
1085
|
|
|
730
|
-
# Filter to available metrics
|
|
731
|
-
available_metrics = [(k, v) for k, v in metrics.items() if k in metric_info]
|
|
732
|
-
n_metrics = len(available_metrics)
|
|
733
1086
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1087
|
+
def _plot_quality_bars(
|
|
1088
|
+
ax: Axes,
|
|
1089
|
+
available_metrics: list[tuple[str, float]],
|
|
1090
|
+
metric_info: dict[str, dict[str, Any]],
|
|
1091
|
+
show_specs: dict[str, float] | None,
|
|
1092
|
+
) -> None:
|
|
1093
|
+
"""Plot horizontal bars for quality metrics.
|
|
738
1094
|
|
|
739
|
-
|
|
1095
|
+
Args:
|
|
1096
|
+
ax: Matplotlib axes.
|
|
1097
|
+
available_metrics: List of (key, value) tuples for available metrics.
|
|
1098
|
+
metric_info: Metric display information.
|
|
1099
|
+
show_specs: Specification values for pass/fail coloring.
|
|
1100
|
+
"""
|
|
1101
|
+
n_metrics = len(available_metrics)
|
|
740
1102
|
y_pos = np.arange(n_metrics)
|
|
741
1103
|
values = [v for _, v in available_metrics]
|
|
742
|
-
names = [metric_info[k]["name"] for k, _ in available_metrics]
|
|
743
1104
|
|
|
744
1105
|
# Determine colors based on pass/fail
|
|
1106
|
+
colors = _determine_bar_colors(available_metrics, metric_info, show_specs)
|
|
1107
|
+
|
|
1108
|
+
# Plot horizontal bars
|
|
1109
|
+
ax.barh(y_pos, values, color=colors, edgecolor="black", linewidth=0.5)
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _determine_bar_colors(
|
|
1113
|
+
available_metrics: list[tuple[str, float]],
|
|
1114
|
+
metric_info: dict[str, dict[str, Any]],
|
|
1115
|
+
show_specs: dict[str, float] | None,
|
|
1116
|
+
) -> list[str]:
|
|
1117
|
+
"""Determine bar colors based on pass/fail status.
|
|
1118
|
+
|
|
1119
|
+
Args:
|
|
1120
|
+
available_metrics: List of (key, value) tuples.
|
|
1121
|
+
metric_info: Metric display information.
|
|
1122
|
+
show_specs: Specification values.
|
|
1123
|
+
|
|
1124
|
+
Returns:
|
|
1125
|
+
List of color strings (hex codes).
|
|
1126
|
+
"""
|
|
745
1127
|
colors = []
|
|
746
1128
|
for key, value in available_metrics:
|
|
747
1129
|
if show_specs and key in show_specs:
|
|
@@ -755,11 +1137,21 @@ def plot_quality_summary(
|
|
|
755
1137
|
colors.append("#27AE60" if passed else "#E74C3C")
|
|
756
1138
|
else:
|
|
757
1139
|
colors.append("#3498DB")
|
|
1140
|
+
return colors
|
|
758
1141
|
|
|
759
|
-
# Plot horizontal bars
|
|
760
|
-
ax.barh(y_pos, values, color=colors, edgecolor="black", linewidth=0.5)
|
|
761
1142
|
|
|
762
|
-
|
|
1143
|
+
def _add_quality_labels(
|
|
1144
|
+
ax: Axes,
|
|
1145
|
+
available_metrics: list[tuple[str, float]],
|
|
1146
|
+
metric_info: dict[str, dict[str, Any]],
|
|
1147
|
+
) -> None:
|
|
1148
|
+
"""Add value labels to quality metric bars.
|
|
1149
|
+
|
|
1150
|
+
Args:
|
|
1151
|
+
ax: Matplotlib axes.
|
|
1152
|
+
available_metrics: List of (key, value) tuples.
|
|
1153
|
+
metric_info: Metric display information.
|
|
1154
|
+
"""
|
|
763
1155
|
for i, (key, value) in enumerate(available_metrics):
|
|
764
1156
|
unit = metric_info[key]["unit"]
|
|
765
1157
|
label_text = f"{value:.1f} {unit}"
|
|
@@ -773,13 +1165,46 @@ def plot_quality_summary(
|
|
|
773
1165
|
fontweight="bold",
|
|
774
1166
|
)
|
|
775
1167
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1168
|
+
|
|
1169
|
+
def _add_spec_markers(
|
|
1170
|
+
ax: Axes,
|
|
1171
|
+
available_metrics: list[tuple[str, float]],
|
|
1172
|
+
show_specs: dict[str, float] | None,
|
|
1173
|
+
) -> None:
|
|
1174
|
+
"""Add specification markers to quality plot.
|
|
1175
|
+
|
|
1176
|
+
Args:
|
|
1177
|
+
ax: Matplotlib axes.
|
|
1178
|
+
available_metrics: List of (key, value) tuples.
|
|
1179
|
+
show_specs: Specification values.
|
|
1180
|
+
"""
|
|
1181
|
+
if not show_specs:
|
|
1182
|
+
return
|
|
1183
|
+
|
|
1184
|
+
for i, (key, _) in enumerate(available_metrics):
|
|
1185
|
+
if key in show_specs:
|
|
1186
|
+
spec = show_specs[key]
|
|
1187
|
+
ax.plot(spec, i, "k|", markersize=20, markeredgewidth=2)
|
|
1188
|
+
ax.text(spec, i + 0.3, f"Spec: {spec}", fontsize=8, ha="center")
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _configure_quality_axes(
|
|
1192
|
+
ax: Axes,
|
|
1193
|
+
available_metrics: list[tuple[str, float]],
|
|
1194
|
+
metric_info: dict[str, dict[str, Any]],
|
|
1195
|
+
title: str | None,
|
|
1196
|
+
) -> None:
|
|
1197
|
+
"""Configure axes for quality summary plot.
|
|
1198
|
+
|
|
1199
|
+
Args:
|
|
1200
|
+
ax: Matplotlib axes.
|
|
1201
|
+
available_metrics: List of (key, value) tuples.
|
|
1202
|
+
metric_info: Metric display information.
|
|
1203
|
+
title: Plot title.
|
|
1204
|
+
"""
|
|
1205
|
+
n_metrics = len(available_metrics)
|
|
1206
|
+
y_pos = np.arange(n_metrics)
|
|
1207
|
+
names = [metric_info[k]["name"] for k, _ in available_metrics]
|
|
783
1208
|
|
|
784
1209
|
ax.set_yticks(y_pos)
|
|
785
1210
|
ax.set_yticklabels([str(name) for name in names], fontsize=11)
|
|
@@ -792,16 +1217,6 @@ def plot_quality_summary(
|
|
|
792
1217
|
else:
|
|
793
1218
|
ax.set_title("Signal Quality Summary (IEEE 1241-2010)", fontsize=12, fontweight="bold")
|
|
794
1219
|
|
|
795
|
-
fig.tight_layout()
|
|
796
|
-
|
|
797
|
-
if save_path is not None:
|
|
798
|
-
fig.savefig(save_path, dpi=300, bbox_inches="tight")
|
|
799
|
-
|
|
800
|
-
if show:
|
|
801
|
-
plt.show()
|
|
802
|
-
|
|
803
|
-
return fig
|
|
804
|
-
|
|
805
1220
|
|
|
806
1221
|
__all__ = [
|
|
807
1222
|
"plot_fft",
|