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,858 @@
|
|
|
1
|
+
"""Interactive visualization features.
|
|
2
|
+
|
|
3
|
+
This module provides interactive plotting capabilities including zoom,
|
|
4
|
+
pan, cursors, and specialized plot types.
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from oscura.visualization.interactive import (
|
|
9
|
+
... plot_with_cursors, plot_phase, plot_bode,
|
|
10
|
+
... plot_waterfall, plot_histogram
|
|
11
|
+
... )
|
|
12
|
+
>>> fig, ax = plot_with_cursors(trace)
|
|
13
|
+
>>> plot_bode(frequencies, magnitude, phase)
|
|
14
|
+
|
|
15
|
+
References:
|
|
16
|
+
matplotlib interactive features
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
from scipy import signal as scipy_signal
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from matplotlib.axes import Axes
|
|
29
|
+
from matplotlib.backend_bases import MouseEvent
|
|
30
|
+
from matplotlib.figure import Figure
|
|
31
|
+
from numpy.typing import NDArray
|
|
32
|
+
|
|
33
|
+
from oscura.core.types import WaveformTrace
|
|
34
|
+
|
|
35
|
+
# Optional matplotlib import
|
|
36
|
+
try:
|
|
37
|
+
import matplotlib.pyplot as plt
|
|
38
|
+
from matplotlib.widgets import Cursor, MultiCursor, SpanSelector # noqa: F401
|
|
39
|
+
|
|
40
|
+
MATPLOTLIB_AVAILABLE = True
|
|
41
|
+
except ImportError:
|
|
42
|
+
MATPLOTLIB_AVAILABLE = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class CursorMeasurement:
|
|
47
|
+
"""Measurement result from cursors.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
x1: First cursor X position.
|
|
51
|
+
x2: Second cursor X position.
|
|
52
|
+
y1: First cursor Y position.
|
|
53
|
+
y2: Second cursor Y position.
|
|
54
|
+
delta_x: X difference (x2 - x1).
|
|
55
|
+
delta_y: Y difference (y2 - y1).
|
|
56
|
+
frequency: 1/delta_x if delta_x > 0.
|
|
57
|
+
slope: delta_y/delta_x if delta_x != 0.
|
|
58
|
+
|
|
59
|
+
References:
|
|
60
|
+
VIS-008
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
x1: float
|
|
64
|
+
x2: float
|
|
65
|
+
y1: float
|
|
66
|
+
y2: float
|
|
67
|
+
delta_x: float
|
|
68
|
+
delta_y: float
|
|
69
|
+
frequency: float | None = None
|
|
70
|
+
slope: float | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class ZoomState:
|
|
75
|
+
"""Current zoom/pan state.
|
|
76
|
+
|
|
77
|
+
Attributes:
|
|
78
|
+
xlim: Current X-axis limits.
|
|
79
|
+
ylim: Current Y-axis limits.
|
|
80
|
+
history: Stack of previous zoom states.
|
|
81
|
+
home_xlim: Original X-axis limits.
|
|
82
|
+
home_ylim: Original Y-axis limits.
|
|
83
|
+
|
|
84
|
+
References:
|
|
85
|
+
VIS-007
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
xlim: tuple[float, float]
|
|
89
|
+
ylim: tuple[float, float]
|
|
90
|
+
history: list[tuple[tuple[float, float], tuple[float, float]]] = field(default_factory=list)
|
|
91
|
+
home_xlim: tuple[float, float] | None = None
|
|
92
|
+
home_ylim: tuple[float, float] | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def enable_zoom_pan(
|
|
96
|
+
ax: Axes,
|
|
97
|
+
*,
|
|
98
|
+
enable_zoom: bool = True,
|
|
99
|
+
enable_pan: bool = True,
|
|
100
|
+
zoom_factor: float = 1.5,
|
|
101
|
+
) -> ZoomState:
|
|
102
|
+
"""Enable interactive zoom and pan on an axes.
|
|
103
|
+
|
|
104
|
+
Adds scroll wheel zoom and click-drag pan functionality.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
ax: Matplotlib axes to enable zoom/pan on.
|
|
108
|
+
enable_zoom: Enable scroll wheel zoom.
|
|
109
|
+
enable_pan: Enable click-drag pan.
|
|
110
|
+
zoom_factor: Zoom factor per scroll step.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
ZoomState object tracking zoom history.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
ImportError: If matplotlib is not available.
|
|
117
|
+
|
|
118
|
+
Example:
|
|
119
|
+
>>> fig, ax = plt.subplots()
|
|
120
|
+
>>> ax.plot(trace.time_vector, trace.data)
|
|
121
|
+
>>> state = enable_zoom_pan(ax)
|
|
122
|
+
|
|
123
|
+
References:
|
|
124
|
+
VIS-007
|
|
125
|
+
"""
|
|
126
|
+
if not MATPLOTLIB_AVAILABLE:
|
|
127
|
+
raise ImportError("matplotlib is required for interactive visualization")
|
|
128
|
+
|
|
129
|
+
# Store initial state
|
|
130
|
+
xlim = ax.get_xlim()
|
|
131
|
+
ylim = ax.get_ylim()
|
|
132
|
+
state = ZoomState(
|
|
133
|
+
xlim=xlim,
|
|
134
|
+
ylim=ylim,
|
|
135
|
+
home_xlim=xlim,
|
|
136
|
+
home_ylim=ylim,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def on_scroll(event): # type: ignore[no-untyped-def]
|
|
140
|
+
if event.inaxes != ax:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
# Get mouse position
|
|
144
|
+
x_data = event.xdata
|
|
145
|
+
y_data = event.ydata
|
|
146
|
+
|
|
147
|
+
if x_data is None or y_data is None:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
# Determine zoom direction
|
|
151
|
+
if event.button == "up":
|
|
152
|
+
factor = 1 / zoom_factor
|
|
153
|
+
elif event.button == "down":
|
|
154
|
+
factor = zoom_factor
|
|
155
|
+
else:
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
# Save current state
|
|
159
|
+
state.history.append((state.xlim, state.ylim))
|
|
160
|
+
|
|
161
|
+
# Calculate new limits centered on mouse position
|
|
162
|
+
cur_xlim = ax.get_xlim()
|
|
163
|
+
cur_ylim = ax.get_ylim()
|
|
164
|
+
|
|
165
|
+
new_width = (cur_xlim[1] - cur_xlim[0]) * factor
|
|
166
|
+
new_height = (cur_ylim[1] - cur_ylim[0]) * factor
|
|
167
|
+
|
|
168
|
+
rel_x = (x_data - cur_xlim[0]) / (cur_xlim[1] - cur_xlim[0])
|
|
169
|
+
rel_y = (y_data - cur_ylim[0]) / (cur_ylim[1] - cur_ylim[0])
|
|
170
|
+
|
|
171
|
+
new_xlim = (
|
|
172
|
+
x_data - new_width * rel_x,
|
|
173
|
+
x_data + new_width * (1 - rel_x),
|
|
174
|
+
)
|
|
175
|
+
new_ylim = (
|
|
176
|
+
y_data - new_height * rel_y,
|
|
177
|
+
y_data + new_height * (1 - rel_y),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
ax.set_xlim(new_xlim)
|
|
181
|
+
ax.set_ylim(new_ylim)
|
|
182
|
+
state.xlim = new_xlim
|
|
183
|
+
state.ylim = new_ylim
|
|
184
|
+
|
|
185
|
+
ax.figure.canvas.draw_idle()
|
|
186
|
+
|
|
187
|
+
if enable_zoom:
|
|
188
|
+
ax.figure.canvas.mpl_connect("scroll_event", on_scroll)
|
|
189
|
+
|
|
190
|
+
# Pan state
|
|
191
|
+
pan_active = [False]
|
|
192
|
+
pan_start: list[float | None] = [None, None]
|
|
193
|
+
|
|
194
|
+
def on_press(event): # type: ignore[no-untyped-def]
|
|
195
|
+
if event.inaxes != ax:
|
|
196
|
+
return
|
|
197
|
+
if event.button == 1: # Left click
|
|
198
|
+
pan_active[0] = True
|
|
199
|
+
pan_start[0] = event.xdata
|
|
200
|
+
pan_start[1] = event.ydata
|
|
201
|
+
|
|
202
|
+
def on_release(event: MouseEvent) -> None:
|
|
203
|
+
pan_active[0] = False
|
|
204
|
+
|
|
205
|
+
def on_motion(event: MouseEvent) -> None:
|
|
206
|
+
if not pan_active[0]:
|
|
207
|
+
return
|
|
208
|
+
if event.inaxes != ax:
|
|
209
|
+
return
|
|
210
|
+
if event.xdata is None or event.ydata is None:
|
|
211
|
+
return
|
|
212
|
+
if pan_start[0] is None or pan_start[1] is None:
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
dx = pan_start[0] - event.xdata
|
|
216
|
+
dy = pan_start[1] - event.ydata
|
|
217
|
+
|
|
218
|
+
cur_xlim = ax.get_xlim()
|
|
219
|
+
cur_ylim = ax.get_ylim()
|
|
220
|
+
|
|
221
|
+
new_xlim = (cur_xlim[0] + dx, cur_xlim[1] + dx)
|
|
222
|
+
new_ylim = (cur_ylim[0] + dy, cur_ylim[1] + dy)
|
|
223
|
+
|
|
224
|
+
ax.set_xlim(new_xlim)
|
|
225
|
+
ax.set_ylim(new_ylim)
|
|
226
|
+
state.xlim = new_xlim
|
|
227
|
+
state.ylim = new_ylim
|
|
228
|
+
|
|
229
|
+
ax.figure.canvas.draw_idle()
|
|
230
|
+
|
|
231
|
+
if enable_pan:
|
|
232
|
+
ax.figure.canvas.mpl_connect("button_press_event", on_press)
|
|
233
|
+
ax.figure.canvas.mpl_connect("button_release_event", on_release) # type: ignore[arg-type]
|
|
234
|
+
ax.figure.canvas.mpl_connect("motion_notify_event", on_motion) # type: ignore[arg-type]
|
|
235
|
+
|
|
236
|
+
return state
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def plot_with_cursors(
|
|
240
|
+
trace: WaveformTrace | NDArray[np.floating[Any]],
|
|
241
|
+
*,
|
|
242
|
+
sample_rate: float | None = None,
|
|
243
|
+
cursor_type: Literal["vertical", "horizontal", "cross"] = "cross",
|
|
244
|
+
ax: Axes | None = None,
|
|
245
|
+
**plot_kwargs: Any,
|
|
246
|
+
) -> tuple[Figure, Axes, Cursor]:
|
|
247
|
+
"""Plot waveform with interactive measurement cursors.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
trace: Input trace or numpy array.
|
|
251
|
+
sample_rate: Sample rate (required for arrays).
|
|
252
|
+
cursor_type: Type of cursor lines.
|
|
253
|
+
ax: Existing axes to plot on.
|
|
254
|
+
**plot_kwargs: Additional arguments to plot().
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Tuple of (figure, axes, cursor widget).
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
ImportError: If matplotlib is not available.
|
|
261
|
+
ValueError: If axes has no associated figure.
|
|
262
|
+
|
|
263
|
+
Example:
|
|
264
|
+
>>> fig, ax, cursor = plot_with_cursors(trace)
|
|
265
|
+
>>> plt.show()
|
|
266
|
+
|
|
267
|
+
References:
|
|
268
|
+
VIS-008
|
|
269
|
+
"""
|
|
270
|
+
if not MATPLOTLIB_AVAILABLE:
|
|
271
|
+
raise ImportError("matplotlib is required for interactive visualization")
|
|
272
|
+
|
|
273
|
+
# Get data and time vector
|
|
274
|
+
if isinstance(trace, WaveformTrace):
|
|
275
|
+
data = trace.data
|
|
276
|
+
time = trace.time_vector
|
|
277
|
+
else:
|
|
278
|
+
data = np.asarray(trace)
|
|
279
|
+
if sample_rate is None:
|
|
280
|
+
sample_rate = 1.0
|
|
281
|
+
time = np.arange(len(data)) / sample_rate
|
|
282
|
+
|
|
283
|
+
# Create figure if needed
|
|
284
|
+
if ax is None:
|
|
285
|
+
fig, ax = plt.subplots(figsize=(10, 6))
|
|
286
|
+
else:
|
|
287
|
+
fig_temp = ax.figure
|
|
288
|
+
if fig_temp is None:
|
|
289
|
+
raise ValueError("Axes must have an associated figure")
|
|
290
|
+
fig = cast("Figure", fig_temp)
|
|
291
|
+
|
|
292
|
+
# Plot data
|
|
293
|
+
ax.plot(time, data, **plot_kwargs)
|
|
294
|
+
ax.set_xlabel("Time (s)")
|
|
295
|
+
ax.set_ylabel("Amplitude")
|
|
296
|
+
ax.grid(True, alpha=0.3)
|
|
297
|
+
|
|
298
|
+
# Create cursor
|
|
299
|
+
if cursor_type == "vertical":
|
|
300
|
+
cursor = Cursor(ax, useblit=True, color="red", linewidth=1, vertOn=True, horizOn=False)
|
|
301
|
+
elif cursor_type == "horizontal":
|
|
302
|
+
cursor = Cursor(ax, useblit=True, color="red", linewidth=1, vertOn=False, horizOn=True)
|
|
303
|
+
else: # cross
|
|
304
|
+
cursor = Cursor(ax, useblit=True, color="red", linewidth=1)
|
|
305
|
+
|
|
306
|
+
return fig, ax, cursor
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def add_measurement_cursors(
|
|
310
|
+
ax: Axes,
|
|
311
|
+
*,
|
|
312
|
+
color: str = "red",
|
|
313
|
+
linestyle: str = "--",
|
|
314
|
+
) -> dict: # type: ignore[type-arg]
|
|
315
|
+
"""Add dual measurement cursors to an axes.
|
|
316
|
+
|
|
317
|
+
Click and drag to define measurement region. Returns measurement
|
|
318
|
+
data in the callback.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
ax: Axes to add cursors to.
|
|
322
|
+
color: Cursor line color.
|
|
323
|
+
linestyle: Cursor line style.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
Dictionary with cursor state and get_measurement() function.
|
|
327
|
+
|
|
328
|
+
Raises:
|
|
329
|
+
ImportError: If matplotlib is not available.
|
|
330
|
+
|
|
331
|
+
Example:
|
|
332
|
+
>>> cursors = add_measurement_cursors(ax)
|
|
333
|
+
>>> measurement = cursors['get_measurement']()
|
|
334
|
+
>>> print(f"Delta X: {measurement.delta_x}")
|
|
335
|
+
|
|
336
|
+
References:
|
|
337
|
+
VIS-008
|
|
338
|
+
"""
|
|
339
|
+
if not MATPLOTLIB_AVAILABLE:
|
|
340
|
+
raise ImportError("matplotlib is required for interactive visualization")
|
|
341
|
+
|
|
342
|
+
state: dict[str, float | None | Any] = {
|
|
343
|
+
"x1": None,
|
|
344
|
+
"x2": None,
|
|
345
|
+
"y1": None,
|
|
346
|
+
"y2": None,
|
|
347
|
+
"line1": None,
|
|
348
|
+
"line2": None,
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
def onselect(xmin: float, xmax: float) -> None:
|
|
352
|
+
state["x1"] = xmin
|
|
353
|
+
state["x2"] = xmax
|
|
354
|
+
|
|
355
|
+
# Get Y values at cursor positions
|
|
356
|
+
for line in ax.get_lines():
|
|
357
|
+
xdata = line.get_xdata()
|
|
358
|
+
ydata = line.get_ydata()
|
|
359
|
+
# Type narrowing: these return ArrayLike from Line2D
|
|
360
|
+
xdata_arr = np.asarray(xdata)
|
|
361
|
+
ydata_arr = np.asarray(ydata)
|
|
362
|
+
if len(xdata_arr) > 0:
|
|
363
|
+
# Interpolate Y at cursor positions
|
|
364
|
+
y1_interp: float = float(np.interp(xmin, xdata_arr, ydata_arr))
|
|
365
|
+
y2_interp: float = float(np.interp(xmax, xdata_arr, ydata_arr))
|
|
366
|
+
state["y1"] = y1_interp
|
|
367
|
+
state["y2"] = y2_interp
|
|
368
|
+
break
|
|
369
|
+
|
|
370
|
+
span = SpanSelector(
|
|
371
|
+
ax,
|
|
372
|
+
onselect,
|
|
373
|
+
"horizontal",
|
|
374
|
+
useblit=True,
|
|
375
|
+
props={"alpha": 0.3, "facecolor": color},
|
|
376
|
+
interactive=True,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def get_measurement() -> CursorMeasurement | None:
|
|
380
|
+
x1 = state["x1"]
|
|
381
|
+
x2 = state["x2"]
|
|
382
|
+
y1 = state["y1"]
|
|
383
|
+
y2 = state["y2"]
|
|
384
|
+
|
|
385
|
+
if (
|
|
386
|
+
x1 is None
|
|
387
|
+
or x2 is None
|
|
388
|
+
or not isinstance(x1, int | float)
|
|
389
|
+
or not isinstance(x2, int | float)
|
|
390
|
+
):
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
delta_x = x2 - x1
|
|
394
|
+
y1_val = float(y1) if y1 is not None else 0.0
|
|
395
|
+
y2_val = float(y2) if y2 is not None else 0.0
|
|
396
|
+
delta_y = y2_val - y1_val
|
|
397
|
+
|
|
398
|
+
return CursorMeasurement(
|
|
399
|
+
x1=x1,
|
|
400
|
+
x2=x2,
|
|
401
|
+
y1=y1_val,
|
|
402
|
+
y2=y2_val,
|
|
403
|
+
delta_x=delta_x,
|
|
404
|
+
delta_y=delta_y,
|
|
405
|
+
frequency=1 / delta_x if delta_x > 0 else None,
|
|
406
|
+
slope=delta_y / delta_x if delta_x != 0 else None,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
"span": span,
|
|
411
|
+
"state": state,
|
|
412
|
+
"get_measurement": get_measurement,
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def plot_phase(
|
|
417
|
+
trace1: WaveformTrace | NDArray[np.floating[Any]],
|
|
418
|
+
trace2: WaveformTrace | NDArray[np.floating[Any]] | None = None,
|
|
419
|
+
*,
|
|
420
|
+
delay: int = 1,
|
|
421
|
+
delay_samples: int | None = None,
|
|
422
|
+
ax: Axes | None = None,
|
|
423
|
+
**plot_kwargs: Any,
|
|
424
|
+
) -> tuple[Figure, Axes]:
|
|
425
|
+
"""Create phase plot (X-Y plot) of two signals.
|
|
426
|
+
|
|
427
|
+
Plots trace1 on X-axis vs trace2 on Y-axis, useful for
|
|
428
|
+
visualizing phase relationships and Lissajous figures.
|
|
429
|
+
If trace2 is not provided, creates a self-phase plot using
|
|
430
|
+
time-delayed version of trace1.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
trace1: Signal for X-axis.
|
|
434
|
+
trace2: Signal for Y-axis. If None, uses delayed trace1.
|
|
435
|
+
delay: Sample delay for self-phase plot (when trace2=None).
|
|
436
|
+
delay_samples: Alias for delay parameter.
|
|
437
|
+
ax: Existing axes to plot on.
|
|
438
|
+
**plot_kwargs: Additional arguments to plot().
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
Tuple of (figure, axes).
|
|
442
|
+
|
|
443
|
+
Raises:
|
|
444
|
+
ImportError: If matplotlib is not available.
|
|
445
|
+
ValueError: If axes has no associated figure.
|
|
446
|
+
|
|
447
|
+
Example:
|
|
448
|
+
>>> fig, ax = plot_phase(signal_x, signal_y)
|
|
449
|
+
>>> plt.show()
|
|
450
|
+
>>> # Self-phase plot
|
|
451
|
+
>>> fig, ax = plot_phase(signal, delay_samples=10)
|
|
452
|
+
|
|
453
|
+
References:
|
|
454
|
+
VIS-009
|
|
455
|
+
"""
|
|
456
|
+
if not MATPLOTLIB_AVAILABLE:
|
|
457
|
+
raise ImportError("matplotlib is required for interactive visualization")
|
|
458
|
+
|
|
459
|
+
# Handle delay_samples alias
|
|
460
|
+
if delay_samples is not None:
|
|
461
|
+
delay = delay_samples
|
|
462
|
+
|
|
463
|
+
# Get data
|
|
464
|
+
data1 = trace1.data if isinstance(trace1, WaveformTrace) else np.asarray(trace1)
|
|
465
|
+
|
|
466
|
+
# If trace2 not provided, create self-phase plot with delay
|
|
467
|
+
if trace2 is None:
|
|
468
|
+
data2 = np.roll(data1, -delay)
|
|
469
|
+
else:
|
|
470
|
+
data2 = trace2.data if isinstance(trace2, WaveformTrace) else np.asarray(trace2)
|
|
471
|
+
|
|
472
|
+
# Ensure same length
|
|
473
|
+
n = min(len(data1), len(data2))
|
|
474
|
+
data1 = data1[:n]
|
|
475
|
+
data2 = data2[:n]
|
|
476
|
+
|
|
477
|
+
# Create figure if needed
|
|
478
|
+
if ax is None:
|
|
479
|
+
fig, ax = plt.subplots(figsize=(8, 8))
|
|
480
|
+
else:
|
|
481
|
+
fig_temp = ax.figure
|
|
482
|
+
if fig_temp is None:
|
|
483
|
+
raise ValueError("Axes must have an associated figure")
|
|
484
|
+
fig = cast("Figure", fig_temp)
|
|
485
|
+
|
|
486
|
+
# Plot
|
|
487
|
+
defaults: dict[str, Any] = {"alpha": 0.5, "marker": ".", "linestyle": "-", "markersize": 2}
|
|
488
|
+
defaults.update(plot_kwargs)
|
|
489
|
+
ax.plot(data1, data2, **defaults)
|
|
490
|
+
|
|
491
|
+
# Equal aspect ratio for proper phase visualization
|
|
492
|
+
ax.set_aspect("equal", adjustable="datalim")
|
|
493
|
+
ax.set_xlabel("Signal 1")
|
|
494
|
+
ax.set_ylabel("Signal 2")
|
|
495
|
+
ax.set_title("Phase Plot (X-Y)")
|
|
496
|
+
ax.grid(True, alpha=0.3)
|
|
497
|
+
|
|
498
|
+
return fig, ax
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def plot_bode(
|
|
502
|
+
frequencies: NDArray[np.floating[Any]],
|
|
503
|
+
magnitude: NDArray[np.floating[Any]] | NDArray[np.complexfloating[Any, Any]],
|
|
504
|
+
phase: NDArray[np.floating[Any]] | None = None,
|
|
505
|
+
*,
|
|
506
|
+
magnitude_db: bool = True,
|
|
507
|
+
phase_degrees: bool = True,
|
|
508
|
+
fig: Figure | None = None,
|
|
509
|
+
**plot_kwargs: Any,
|
|
510
|
+
) -> Figure:
|
|
511
|
+
"""Create Bode plot with magnitude and phase.
|
|
512
|
+
|
|
513
|
+
Standard frequency response visualization with logarithmic
|
|
514
|
+
frequency axis.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
frequencies: Frequency array in Hz.
|
|
518
|
+
magnitude: Magnitude array (linear or dB), or complex transfer function H(s).
|
|
519
|
+
If complex, magnitude and phase are extracted automatically.
|
|
520
|
+
phase: Phase array in radians (optional). Ignored if magnitude is complex.
|
|
521
|
+
magnitude_db: If True, magnitude is already in dB. Ignored if complex input.
|
|
522
|
+
phase_degrees: If True, convert phase to degrees.
|
|
523
|
+
fig: Existing figure to plot on.
|
|
524
|
+
**plot_kwargs: Additional arguments to plot().
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
Matplotlib Figure object with magnitude and optionally phase axes.
|
|
528
|
+
|
|
529
|
+
Raises:
|
|
530
|
+
ImportError: If matplotlib is not available.
|
|
531
|
+
|
|
532
|
+
Example:
|
|
533
|
+
>>> # With complex transfer function
|
|
534
|
+
>>> H = 1 / (1 + 1j * freqs / 1000)
|
|
535
|
+
>>> fig = plot_bode(freqs, H)
|
|
536
|
+
>>> ax_mag, ax_phase = fig.axes[:2] # Access axes from figure
|
|
537
|
+
>>> plt.show()
|
|
538
|
+
|
|
539
|
+
References:
|
|
540
|
+
VIS-010
|
|
541
|
+
"""
|
|
542
|
+
if not MATPLOTLIB_AVAILABLE:
|
|
543
|
+
raise ImportError("matplotlib is required for interactive visualization")
|
|
544
|
+
|
|
545
|
+
frequencies = np.asarray(frequencies)
|
|
546
|
+
magnitude = np.asarray(magnitude)
|
|
547
|
+
|
|
548
|
+
# Handle complex transfer function input
|
|
549
|
+
if np.iscomplexobj(magnitude):
|
|
550
|
+
# Extract phase from complex input
|
|
551
|
+
phase = np.angle(magnitude)
|
|
552
|
+
# Convert to magnitude in dB
|
|
553
|
+
with np.errstate(divide="ignore"):
|
|
554
|
+
magnitude = 20 * np.log10(np.abs(magnitude))
|
|
555
|
+
magnitude = np.nan_to_num(magnitude, neginf=-200)
|
|
556
|
+
elif not magnitude_db:
|
|
557
|
+
# Convert magnitude to dB if needed
|
|
558
|
+
with np.errstate(divide="ignore"):
|
|
559
|
+
magnitude = 20 * np.log10(np.abs(magnitude))
|
|
560
|
+
magnitude = np.nan_to_num(magnitude, neginf=-200)
|
|
561
|
+
|
|
562
|
+
# Create figure
|
|
563
|
+
if phase is not None:
|
|
564
|
+
if fig is None:
|
|
565
|
+
fig, (ax_mag, ax_phase) = plt.subplots(2, 1, figsize=(10, 8), sharex=True)
|
|
566
|
+
else:
|
|
567
|
+
axes = fig.subplots(2, 1, sharex=True)
|
|
568
|
+
ax_mag, ax_phase = axes
|
|
569
|
+
else:
|
|
570
|
+
if fig is None:
|
|
571
|
+
fig, ax_mag = plt.subplots(figsize=(10, 5))
|
|
572
|
+
else:
|
|
573
|
+
ax_mag = fig.subplots()
|
|
574
|
+
ax_phase = None
|
|
575
|
+
|
|
576
|
+
# Plot magnitude
|
|
577
|
+
ax_mag.semilogx(frequencies, magnitude, **plot_kwargs)
|
|
578
|
+
ax_mag.set_ylabel("Magnitude (dB)")
|
|
579
|
+
ax_mag.grid(True, which="both", alpha=0.3)
|
|
580
|
+
ax_mag.set_title("Bode Plot")
|
|
581
|
+
|
|
582
|
+
# Plot phase if provided
|
|
583
|
+
if phase is not None and ax_phase is not None:
|
|
584
|
+
phase = np.asarray(phase)
|
|
585
|
+
if phase_degrees:
|
|
586
|
+
phase = np.degrees(phase)
|
|
587
|
+
ylabel = "Phase (degrees)"
|
|
588
|
+
else:
|
|
589
|
+
ylabel = "Phase (radians)"
|
|
590
|
+
|
|
591
|
+
ax_phase.semilogx(frequencies, phase, **plot_kwargs)
|
|
592
|
+
ax_phase.set_ylabel(ylabel)
|
|
593
|
+
ax_phase.set_xlabel("Frequency (Hz)")
|
|
594
|
+
ax_phase.grid(True, which="both", alpha=0.3)
|
|
595
|
+
else:
|
|
596
|
+
ax_mag.set_xlabel("Frequency (Hz)")
|
|
597
|
+
|
|
598
|
+
fig.tight_layout()
|
|
599
|
+
|
|
600
|
+
return fig
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def plot_waterfall(
|
|
604
|
+
data: NDArray[np.floating[Any]],
|
|
605
|
+
*,
|
|
606
|
+
time_axis: NDArray[np.floating[Any]] | None = None,
|
|
607
|
+
freq_axis: NDArray[np.floating[Any]] | None = None,
|
|
608
|
+
sample_rate: float = 1.0,
|
|
609
|
+
nperseg: int = 256,
|
|
610
|
+
noverlap: int | None = None,
|
|
611
|
+
cmap: str = "viridis",
|
|
612
|
+
ax: Axes | None = None,
|
|
613
|
+
**kwargs: Any,
|
|
614
|
+
) -> tuple[Figure, Axes]:
|
|
615
|
+
"""Create 3D waterfall plot (spectrogram with depth).
|
|
616
|
+
|
|
617
|
+
Shows spectrum evolution over time as stacked frequency slices.
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
data: Input signal array (1D) or pre-computed spectrogram (2D).
|
|
621
|
+
If 2D, treated as (n_traces, n_points) spectrogram data.
|
|
622
|
+
time_axis: Time axis for signal.
|
|
623
|
+
freq_axis: Frequency axis (if pre-computed).
|
|
624
|
+
sample_rate: Sample rate in Hz.
|
|
625
|
+
nperseg: Segment length for FFT.
|
|
626
|
+
noverlap: Overlap between segments.
|
|
627
|
+
cmap: Colormap for amplitude coloring.
|
|
628
|
+
ax: Existing 3D axes to plot on.
|
|
629
|
+
**kwargs: Additional arguments.
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
Tuple of (figure, axes).
|
|
633
|
+
|
|
634
|
+
Raises:
|
|
635
|
+
ImportError: If matplotlib is not available.
|
|
636
|
+
TypeError: If axes is not a 3D axes.
|
|
637
|
+
ValueError: If axes has no associated figure.
|
|
638
|
+
|
|
639
|
+
Example:
|
|
640
|
+
>>> fig, ax = plot_waterfall(signal, sample_rate=1e6)
|
|
641
|
+
>>> plt.show()
|
|
642
|
+
>>> # With 2D precomputed data
|
|
643
|
+
>>> fig, ax = plot_waterfall(spectrogram_data)
|
|
644
|
+
|
|
645
|
+
References:
|
|
646
|
+
VIS-011
|
|
647
|
+
"""
|
|
648
|
+
if not MATPLOTLIB_AVAILABLE:
|
|
649
|
+
raise ImportError("matplotlib is required for interactive visualization")
|
|
650
|
+
|
|
651
|
+
data = np.asarray(data)
|
|
652
|
+
|
|
653
|
+
# Check if data is 2D (precomputed spectrogram)
|
|
654
|
+
if data.ndim == 2:
|
|
655
|
+
# Treat as precomputed spectrogram (n_traces, n_points)
|
|
656
|
+
Sxx_db = data
|
|
657
|
+
n_traces, n_points = data.shape
|
|
658
|
+
frequencies = freq_axis if freq_axis is not None else np.arange(n_points)
|
|
659
|
+
times = time_axis if time_axis is not None else np.arange(n_traces)
|
|
660
|
+
elif freq_axis is not None:
|
|
661
|
+
# 1D data with explicit freq_axis means precomputed
|
|
662
|
+
Sxx_db = data
|
|
663
|
+
frequencies = freq_axis
|
|
664
|
+
times = (
|
|
665
|
+
time_axis
|
|
666
|
+
if time_axis is not None
|
|
667
|
+
else np.arange(Sxx_db.shape[1] if Sxx_db.ndim > 1 else 1)
|
|
668
|
+
)
|
|
669
|
+
else:
|
|
670
|
+
# Compute spectrogram from 1D signal
|
|
671
|
+
if noverlap is None:
|
|
672
|
+
noverlap = nperseg // 2
|
|
673
|
+
|
|
674
|
+
frequencies, times, Sxx = scipy_signal.spectrogram(
|
|
675
|
+
data, fs=sample_rate, nperseg=nperseg, noverlap=noverlap
|
|
676
|
+
)
|
|
677
|
+
Sxx_db = 10 * np.log10(Sxx + 1e-10)
|
|
678
|
+
times = time_axis if time_axis is not None else np.arange(Sxx_db.shape[1])
|
|
679
|
+
|
|
680
|
+
# Create 3D figure
|
|
681
|
+
if ax is None:
|
|
682
|
+
fig = plt.figure(figsize=(12, 8))
|
|
683
|
+
ax = fig.add_subplot(111, projection="3d")
|
|
684
|
+
else:
|
|
685
|
+
fig_temp = ax.figure
|
|
686
|
+
if fig_temp is None:
|
|
687
|
+
raise ValueError("Axes must have an associated figure")
|
|
688
|
+
fig = cast("Figure", fig_temp)
|
|
689
|
+
|
|
690
|
+
# Create meshgrid
|
|
691
|
+
T, F = np.meshgrid(times, frequencies)
|
|
692
|
+
|
|
693
|
+
# Ensure Sxx_db matches meshgrid shape (n_frequencies, n_times)
|
|
694
|
+
if Sxx_db.shape != T.shape:
|
|
695
|
+
if Sxx_db.T.shape == T.shape:
|
|
696
|
+
Sxx_db = Sxx_db.T
|
|
697
|
+
# If still mismatched, the data dimensions may be incompatible
|
|
698
|
+
# but we'll let plot_surface raise a more informative error
|
|
699
|
+
|
|
700
|
+
# Plot surface
|
|
701
|
+
# Type checking: ax must be a 3D axes at this point
|
|
702
|
+
if not hasattr(ax, "plot_surface"):
|
|
703
|
+
raise TypeError("Axes must be a 3D axes for waterfall plot")
|
|
704
|
+
surf = ax.plot_surface( # type: ignore[attr-defined,union-attr]
|
|
705
|
+
T,
|
|
706
|
+
F,
|
|
707
|
+
Sxx_db,
|
|
708
|
+
cmap=cmap,
|
|
709
|
+
linewidth=0,
|
|
710
|
+
antialiased=True,
|
|
711
|
+
alpha=0.8,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
ax.set_xlabel("Time (s)")
|
|
715
|
+
ax.set_ylabel("Frequency (Hz)")
|
|
716
|
+
if hasattr(ax, "set_zlabel"):
|
|
717
|
+
ax.set_zlabel("Power (dB)") # type: ignore[attr-defined]
|
|
718
|
+
ax.set_title("Waterfall Plot (Spectrogram)")
|
|
719
|
+
|
|
720
|
+
fig.colorbar(surf, ax=ax, label="Power (dB)", shrink=0.5)
|
|
721
|
+
|
|
722
|
+
return fig, ax
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def plot_histogram(
|
|
726
|
+
trace: WaveformTrace | NDArray[np.floating[Any]],
|
|
727
|
+
*,
|
|
728
|
+
bins: int | str | NDArray[np.floating[Any]] = "auto",
|
|
729
|
+
density: bool = True,
|
|
730
|
+
show_stats: bool = True,
|
|
731
|
+
show_kde: bool = False,
|
|
732
|
+
ax: Axes | None = None,
|
|
733
|
+
save_path: str | None = None,
|
|
734
|
+
show: bool = True,
|
|
735
|
+
**hist_kwargs: Any,
|
|
736
|
+
) -> tuple[Figure, Axes, dict[str, Any]]:
|
|
737
|
+
"""Create histogram plot of signal amplitude distribution.
|
|
738
|
+
|
|
739
|
+
Optionally overlays kernel density estimate and statistics.
|
|
740
|
+
|
|
741
|
+
Args:
|
|
742
|
+
trace: Input trace or numpy array.
|
|
743
|
+
bins: Number of bins or binning strategy.
|
|
744
|
+
density: If True, normalize to probability density.
|
|
745
|
+
show_stats: Show mean and standard deviation lines.
|
|
746
|
+
show_kde: Overlay kernel density estimate.
|
|
747
|
+
ax: Existing axes to plot on.
|
|
748
|
+
save_path: Path to save figure. If None, figure is not saved.
|
|
749
|
+
show: If True, display the figure. If False, close it.
|
|
750
|
+
**hist_kwargs: Additional arguments to hist().
|
|
751
|
+
|
|
752
|
+
Returns:
|
|
753
|
+
Tuple of (Figure, Axes, statistics dict).
|
|
754
|
+
|
|
755
|
+
Raises:
|
|
756
|
+
ImportError: If matplotlib is not available.
|
|
757
|
+
ValueError: If axes has no associated figure.
|
|
758
|
+
|
|
759
|
+
Example:
|
|
760
|
+
>>> fig = plot_histogram(trace, bins=50, show_kde=True)
|
|
761
|
+
>>> # With save
|
|
762
|
+
>>> fig = plot_histogram(trace, save_path="hist.png", show=False)
|
|
763
|
+
|
|
764
|
+
References:
|
|
765
|
+
VIS-012
|
|
766
|
+
"""
|
|
767
|
+
if not MATPLOTLIB_AVAILABLE:
|
|
768
|
+
raise ImportError("matplotlib is required for interactive visualization")
|
|
769
|
+
|
|
770
|
+
# Get data
|
|
771
|
+
data = trace.data if isinstance(trace, WaveformTrace) else np.asarray(trace)
|
|
772
|
+
|
|
773
|
+
# Create figure if needed
|
|
774
|
+
if ax is None:
|
|
775
|
+
fig, ax = plt.subplots(figsize=(10, 6))
|
|
776
|
+
else:
|
|
777
|
+
fig_temp = ax.figure
|
|
778
|
+
if fig_temp is None:
|
|
779
|
+
raise ValueError("Axes must have an associated figure")
|
|
780
|
+
fig = cast("Figure", fig_temp)
|
|
781
|
+
|
|
782
|
+
# Calculate statistics
|
|
783
|
+
mean = float(np.mean(data))
|
|
784
|
+
std = float(np.std(data))
|
|
785
|
+
median = float(np.median(data))
|
|
786
|
+
min_val = float(np.min(data))
|
|
787
|
+
max_val = float(np.max(data))
|
|
788
|
+
|
|
789
|
+
stats = {
|
|
790
|
+
"mean": mean,
|
|
791
|
+
"std": std,
|
|
792
|
+
"median": median,
|
|
793
|
+
"min": min_val,
|
|
794
|
+
"max": max_val,
|
|
795
|
+
"count": len(data),
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
# Plot histogram
|
|
799
|
+
defaults: dict[str, Any] = {"alpha": 0.7, "edgecolor": "black", "linewidth": 0.5}
|
|
800
|
+
defaults.update(hist_kwargs)
|
|
801
|
+
_counts, bin_edges, _patches = ax.hist(data, bins=bins, density=density, **defaults) # type: ignore[arg-type]
|
|
802
|
+
|
|
803
|
+
stats["bins"] = len(bin_edges) - 1
|
|
804
|
+
|
|
805
|
+
# Show statistics lines
|
|
806
|
+
if show_stats:
|
|
807
|
+
ax.get_ylim()
|
|
808
|
+
ax.axvline(mean, color="red", linestyle="--", linewidth=2, label=f"Mean: {mean:.3g}")
|
|
809
|
+
ax.axvline(mean - std, color="orange", linestyle=":", linewidth=1.5, label="Mean - Std")
|
|
810
|
+
ax.axvline(mean + std, color="orange", linestyle=":", linewidth=1.5, label="Mean + Std")
|
|
811
|
+
|
|
812
|
+
# Show KDE
|
|
813
|
+
if show_kde:
|
|
814
|
+
from scipy.stats import gaussian_kde
|
|
815
|
+
|
|
816
|
+
kde = gaussian_kde(data)
|
|
817
|
+
x_kde = np.linspace(min_val, max_val, 200)
|
|
818
|
+
y_kde = kde(x_kde)
|
|
819
|
+
|
|
820
|
+
if density:
|
|
821
|
+
ax.plot(x_kde, y_kde, "r-", linewidth=2, label="KDE")
|
|
822
|
+
else:
|
|
823
|
+
# Scale KDE to histogram
|
|
824
|
+
bin_width = bin_edges[1] - bin_edges[0]
|
|
825
|
+
ax.plot(x_kde, y_kde * len(data) * bin_width, "r-", linewidth=2, label="KDE")
|
|
826
|
+
|
|
827
|
+
ax.set_xlabel("Amplitude")
|
|
828
|
+
ax.set_ylabel("Density" if density else "Count")
|
|
829
|
+
ax.set_title("Amplitude Distribution")
|
|
830
|
+
# Only show legend if there are labeled artists
|
|
831
|
+
if show_stats or show_kde:
|
|
832
|
+
ax.legend(loc="upper right")
|
|
833
|
+
ax.grid(True, alpha=0.3)
|
|
834
|
+
|
|
835
|
+
# Save if requested
|
|
836
|
+
if save_path is not None:
|
|
837
|
+
fig.savefig(save_path, dpi=150, bbox_inches="tight")
|
|
838
|
+
|
|
839
|
+
# Show or close
|
|
840
|
+
if show:
|
|
841
|
+
plt.show()
|
|
842
|
+
else:
|
|
843
|
+
plt.close(fig)
|
|
844
|
+
|
|
845
|
+
return fig, ax, stats
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
__all__ = [
|
|
849
|
+
"CursorMeasurement",
|
|
850
|
+
"ZoomState",
|
|
851
|
+
"add_measurement_cursors",
|
|
852
|
+
"enable_zoom_pan",
|
|
853
|
+
"plot_bode",
|
|
854
|
+
"plot_histogram",
|
|
855
|
+
"plot_phase",
|
|
856
|
+
"plot_waterfall",
|
|
857
|
+
"plot_with_cursors",
|
|
858
|
+
]
|