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,1131 @@
|
|
|
1
|
+
"""Fluent SignalBuilder for composable signal generation.
|
|
2
|
+
|
|
3
|
+
This module provides the main SignalBuilder class that allows fluent
|
|
4
|
+
composition of signals for test data generation, demos, and protocol testing.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> from oscura import SignalBuilder
|
|
8
|
+
>>> signal = (SignalBuilder()
|
|
9
|
+
... .sample_rate(10e6)
|
|
10
|
+
... .duration(0.01)
|
|
11
|
+
... .add_sine(frequency=1000, amplitude=1.0)
|
|
12
|
+
... .add_noise(snr_db=40)
|
|
13
|
+
... .build())
|
|
14
|
+
|
|
15
|
+
The builder supports:
|
|
16
|
+
- Analog waveforms (sine, square, triangle, chirp, multitone)
|
|
17
|
+
- Protocol signals (UART, SPI, I2C, CAN)
|
|
18
|
+
- Noise and impairments (gaussian, pink, jitter, quantization)
|
|
19
|
+
- Multi-channel signals
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Literal
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
from scipy import signal as scipy_signal
|
|
30
|
+
|
|
31
|
+
from oscura.core.types import TraceMetadata, WaveformTrace
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class SignalMetadata:
|
|
36
|
+
"""Metadata for generated signals.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
sample_rate: Sample rate in Hz.
|
|
40
|
+
duration: Signal duration in seconds.
|
|
41
|
+
channel_names: List of channel names.
|
|
42
|
+
description: Human-readable description.
|
|
43
|
+
generator: Name of generator that created this signal.
|
|
44
|
+
parameters: Dictionary of generation parameters.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
sample_rate: float
|
|
48
|
+
duration: float
|
|
49
|
+
channel_names: list[str] = field(default_factory=lambda: ["ch1"])
|
|
50
|
+
description: str = ""
|
|
51
|
+
generator: str = "SignalBuilder"
|
|
52
|
+
parameters: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class GeneratedSignal:
|
|
57
|
+
"""Container for generated signal data.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
data: Dictionary mapping channel names to signal arrays.
|
|
61
|
+
metadata: Signal metadata.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
data: dict[str, np.ndarray[Any, np.dtype[np.float64]]]
|
|
65
|
+
metadata: SignalMetadata
|
|
66
|
+
_time: np.ndarray[Any, np.dtype[np.float64]] | None = field(default=None, repr=False)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def time(self) -> np.ndarray[Any, np.dtype[np.float64]]:
|
|
70
|
+
"""Get time array, computing if necessary."""
|
|
71
|
+
if self._time is None:
|
|
72
|
+
n_samples = len(next(iter(self.data.values())))
|
|
73
|
+
self._time = np.arange(n_samples) / self.metadata.sample_rate
|
|
74
|
+
return self._time
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def num_channels(self) -> int:
|
|
78
|
+
"""Number of channels in signal."""
|
|
79
|
+
return len(self.data)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def num_samples(self) -> int:
|
|
83
|
+
"""Number of samples per channel."""
|
|
84
|
+
return len(next(iter(self.data.values())))
|
|
85
|
+
|
|
86
|
+
def get_channel(self, name: str) -> np.ndarray[Any, np.dtype[np.float64]]:
|
|
87
|
+
"""Get signal data for a specific channel.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
name: Channel name.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Signal array for the channel.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
KeyError: If channel name not found.
|
|
97
|
+
"""
|
|
98
|
+
if name not in self.data:
|
|
99
|
+
available = list(self.data.keys())
|
|
100
|
+
raise KeyError(f"Channel '{name}' not found. Available: {available}")
|
|
101
|
+
return self.data[name]
|
|
102
|
+
|
|
103
|
+
def to_trace(self, channel: str | None = None) -> WaveformTrace:
|
|
104
|
+
"""Convert to WaveformTrace for TraceKit analysis.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
channel: Channel name to convert. If None, uses first channel.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
WaveformTrace instance ready for analysis.
|
|
111
|
+
"""
|
|
112
|
+
if channel is None:
|
|
113
|
+
channel = self.metadata.channel_names[0]
|
|
114
|
+
|
|
115
|
+
data = self.get_channel(channel)
|
|
116
|
+
trace_meta = TraceMetadata(
|
|
117
|
+
sample_rate=self.metadata.sample_rate,
|
|
118
|
+
channel_name=channel,
|
|
119
|
+
)
|
|
120
|
+
return WaveformTrace(data=data, metadata=trace_meta)
|
|
121
|
+
|
|
122
|
+
def save_npz(self, path: Path | str) -> None:
|
|
123
|
+
"""Save signal to NPZ format.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
path: Output file path.
|
|
127
|
+
"""
|
|
128
|
+
path = Path(path)
|
|
129
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
|
|
131
|
+
save_dict: dict[str, Any] = {
|
|
132
|
+
"sample_rate": self.metadata.sample_rate,
|
|
133
|
+
"duration": self.metadata.duration,
|
|
134
|
+
"channel_names": np.array(self.metadata.channel_names),
|
|
135
|
+
"description": self.metadata.description,
|
|
136
|
+
"generator": self.metadata.generator,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# Add channel data
|
|
140
|
+
for name, data in self.data.items():
|
|
141
|
+
save_dict[name] = data
|
|
142
|
+
|
|
143
|
+
# Add parameters as JSON-serializable
|
|
144
|
+
for key, value in self.metadata.parameters.items():
|
|
145
|
+
if isinstance(value, (int, float, str, bool)):
|
|
146
|
+
save_dict[f"param_{key}"] = value
|
|
147
|
+
|
|
148
|
+
np.savez_compressed(path, **save_dict)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def load_npz(cls, path: Path | str) -> GeneratedSignal:
|
|
152
|
+
"""Load signal from NPZ format.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
path: Input file path.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
GeneratedSignal instance.
|
|
159
|
+
"""
|
|
160
|
+
path = Path(path)
|
|
161
|
+
loaded = np.load(path, allow_pickle=True)
|
|
162
|
+
|
|
163
|
+
sample_rate = float(loaded["sample_rate"])
|
|
164
|
+
duration = float(loaded["duration"])
|
|
165
|
+
channel_names = list(loaded.get("channel_names", ["ch1"]))
|
|
166
|
+
description = str(loaded.get("description", ""))
|
|
167
|
+
generator = str(loaded.get("generator", "unknown"))
|
|
168
|
+
|
|
169
|
+
# Extract channel data
|
|
170
|
+
data = {}
|
|
171
|
+
for name in channel_names:
|
|
172
|
+
if name in loaded:
|
|
173
|
+
data[name] = loaded[name]
|
|
174
|
+
|
|
175
|
+
# Extract parameters
|
|
176
|
+
parameters: dict[str, Any] = {}
|
|
177
|
+
for key in loaded.files:
|
|
178
|
+
if key.startswith("param_"):
|
|
179
|
+
param_name = key[6:] # Remove "param_" prefix
|
|
180
|
+
value = loaded[key]
|
|
181
|
+
parameters[param_name] = value.item() if value.ndim == 0 else value
|
|
182
|
+
|
|
183
|
+
metadata = SignalMetadata(
|
|
184
|
+
sample_rate=sample_rate,
|
|
185
|
+
duration=duration,
|
|
186
|
+
channel_names=channel_names,
|
|
187
|
+
description=description,
|
|
188
|
+
generator=generator,
|
|
189
|
+
parameters=parameters,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return cls(data=data, metadata=metadata)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class SignalBuilder:
|
|
196
|
+
"""Fluent builder for composable signal generation.
|
|
197
|
+
|
|
198
|
+
This class provides a chainable API for building complex test signals
|
|
199
|
+
by combining basic waveforms, protocol signals, noise, and impairments.
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
>>> # Simple sine wave with noise
|
|
203
|
+
>>> signal = (SignalBuilder()
|
|
204
|
+
... .sample_rate(1e6)
|
|
205
|
+
... .duration(0.01)
|
|
206
|
+
... .add_sine(frequency=1000, amplitude=1.0)
|
|
207
|
+
... .add_noise(snr_db=40)
|
|
208
|
+
... .build())
|
|
209
|
+
>>>
|
|
210
|
+
>>> # UART signal with realistic characteristics
|
|
211
|
+
>>> uart_signal = (SignalBuilder()
|
|
212
|
+
... .sample_rate(10e6)
|
|
213
|
+
... .add_uart(baud_rate=115200, data=b"Hello TraceKit!", config="8N1")
|
|
214
|
+
... .add_noise(snr_db=30)
|
|
215
|
+
... .build())
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def __init__(self, sample_rate: float = 1e6, duration: float = 0.01):
|
|
219
|
+
"""Initialize builder with default parameters.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
sample_rate: Sample rate in Hz (default 1 MHz).
|
|
223
|
+
duration: Signal duration in seconds (default 10 ms).
|
|
224
|
+
"""
|
|
225
|
+
self._sample_rate = sample_rate
|
|
226
|
+
self._duration = duration
|
|
227
|
+
self._channels: dict[str, np.ndarray[Any, np.dtype[np.float64]]] = {}
|
|
228
|
+
self._description = ""
|
|
229
|
+
self._parameters: dict[str, Any] = {}
|
|
230
|
+
|
|
231
|
+
# ========== Configuration Methods ==========
|
|
232
|
+
|
|
233
|
+
def sample_rate(self, rate: float) -> SignalBuilder:
|
|
234
|
+
"""Set sample rate in Hz.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
rate: Sample rate in Hz.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Self for chaining.
|
|
241
|
+
"""
|
|
242
|
+
self._sample_rate = rate
|
|
243
|
+
return self
|
|
244
|
+
|
|
245
|
+
def duration(self, seconds: float) -> SignalBuilder:
|
|
246
|
+
"""Set signal duration.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
seconds: Duration in seconds.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Self for chaining.
|
|
253
|
+
"""
|
|
254
|
+
self._duration = seconds
|
|
255
|
+
return self
|
|
256
|
+
|
|
257
|
+
def description(self, desc: str) -> SignalBuilder:
|
|
258
|
+
"""Set signal description.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
desc: Human-readable description.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Self for chaining.
|
|
265
|
+
"""
|
|
266
|
+
self._description = desc
|
|
267
|
+
return self
|
|
268
|
+
|
|
269
|
+
# ========== Analog Signal Methods ==========
|
|
270
|
+
|
|
271
|
+
def add_sine(
|
|
272
|
+
self,
|
|
273
|
+
frequency: float = 1e3,
|
|
274
|
+
amplitude: float = 1.0,
|
|
275
|
+
phase: float = 0.0,
|
|
276
|
+
dc_offset: float = 0.0,
|
|
277
|
+
channel: str = "ch1",
|
|
278
|
+
) -> SignalBuilder:
|
|
279
|
+
"""Add sinusoidal component.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
frequency: Signal frequency in Hz.
|
|
283
|
+
amplitude: Signal amplitude.
|
|
284
|
+
phase: Phase offset in radians.
|
|
285
|
+
dc_offset: DC offset.
|
|
286
|
+
channel: Channel name.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Self for chaining.
|
|
290
|
+
"""
|
|
291
|
+
t = self._get_time()
|
|
292
|
+
signal = amplitude * np.sin(2 * np.pi * frequency * t + phase) + dc_offset
|
|
293
|
+
|
|
294
|
+
self._add_to_channel(channel, signal)
|
|
295
|
+
self._parameters[f"{channel}_sine_freq"] = frequency
|
|
296
|
+
return self
|
|
297
|
+
|
|
298
|
+
def add_harmonics(
|
|
299
|
+
self,
|
|
300
|
+
fundamental: float = 1e3,
|
|
301
|
+
thd_percent: float = 1.0,
|
|
302
|
+
harmonics: list[tuple[int, float]] | None = None,
|
|
303
|
+
channel: str = "ch1",
|
|
304
|
+
) -> SignalBuilder:
|
|
305
|
+
"""Add harmonic distortion.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
fundamental: Fundamental frequency in Hz.
|
|
309
|
+
thd_percent: Total harmonic distortion percentage (if harmonics not specified).
|
|
310
|
+
harmonics: List of (harmonic_number, relative_amplitude) tuples.
|
|
311
|
+
channel: Channel name.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Self for chaining.
|
|
315
|
+
"""
|
|
316
|
+
t = self._get_time()
|
|
317
|
+
|
|
318
|
+
if harmonics is None:
|
|
319
|
+
# Generate typical harmonic profile
|
|
320
|
+
thd_linear = thd_percent / 100
|
|
321
|
+
harmonics = [
|
|
322
|
+
(2, thd_linear * 0.7), # 2nd harmonic
|
|
323
|
+
(3, thd_linear * 0.5), # 3rd harmonic
|
|
324
|
+
(4, thd_linear * 0.3), # 4th harmonic
|
|
325
|
+
(5, thd_linear * 0.2), # 5th harmonic
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
signal = np.zeros_like(t)
|
|
329
|
+
for harm_num, rel_amp in harmonics:
|
|
330
|
+
signal += rel_amp * np.sin(2 * np.pi * harm_num * fundamental * t)
|
|
331
|
+
|
|
332
|
+
self._add_to_channel(channel, signal)
|
|
333
|
+
return self
|
|
334
|
+
|
|
335
|
+
def add_square(
|
|
336
|
+
self,
|
|
337
|
+
frequency: float = 1e3,
|
|
338
|
+
amplitude: float = 1.0,
|
|
339
|
+
duty_cycle: float = 0.5,
|
|
340
|
+
rise_time: float | None = None,
|
|
341
|
+
channel: str = "ch1",
|
|
342
|
+
) -> SignalBuilder:
|
|
343
|
+
"""Add square wave with optional edge rate.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
frequency: Signal frequency in Hz.
|
|
347
|
+
amplitude: Signal amplitude.
|
|
348
|
+
duty_cycle: Duty cycle 0-1 (default 0.5 = 50%).
|
|
349
|
+
rise_time: Rise time in seconds (None for ideal edges).
|
|
350
|
+
channel: Channel name.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Self for chaining.
|
|
354
|
+
"""
|
|
355
|
+
t = self._get_time()
|
|
356
|
+
signal = amplitude * scipy_signal.square(2 * np.pi * frequency * t, duty=duty_cycle)
|
|
357
|
+
|
|
358
|
+
# Apply finite rise time if specified
|
|
359
|
+
if rise_time is not None and rise_time > 0:
|
|
360
|
+
tau = rise_time / 2.2 # 10-90% rise time
|
|
361
|
+
alpha = 1 / (tau * self._sample_rate + 1)
|
|
362
|
+
filtered = np.zeros_like(signal)
|
|
363
|
+
filtered[0] = signal[0]
|
|
364
|
+
for i in range(1, len(signal)):
|
|
365
|
+
filtered[i] = alpha * signal[i] + (1 - alpha) * filtered[i - 1]
|
|
366
|
+
signal = filtered
|
|
367
|
+
|
|
368
|
+
self._add_to_channel(channel, signal)
|
|
369
|
+
self._parameters[f"{channel}_square_freq"] = frequency
|
|
370
|
+
return self
|
|
371
|
+
|
|
372
|
+
def add_triangle(
|
|
373
|
+
self,
|
|
374
|
+
frequency: float = 1e3,
|
|
375
|
+
amplitude: float = 1.0,
|
|
376
|
+
channel: str = "ch1",
|
|
377
|
+
) -> SignalBuilder:
|
|
378
|
+
"""Add triangle wave.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
frequency: Signal frequency in Hz.
|
|
382
|
+
amplitude: Signal amplitude.
|
|
383
|
+
channel: Channel name.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Self for chaining.
|
|
387
|
+
"""
|
|
388
|
+
t = self._get_time()
|
|
389
|
+
signal = amplitude * scipy_signal.sawtooth(2 * np.pi * frequency * t, width=0.5)
|
|
390
|
+
self._add_to_channel(channel, signal)
|
|
391
|
+
return self
|
|
392
|
+
|
|
393
|
+
def add_sawtooth(
|
|
394
|
+
self,
|
|
395
|
+
frequency: float = 1e3,
|
|
396
|
+
amplitude: float = 1.0,
|
|
397
|
+
channel: str = "ch1",
|
|
398
|
+
) -> SignalBuilder:
|
|
399
|
+
"""Add sawtooth wave.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
frequency: Signal frequency in Hz.
|
|
403
|
+
amplitude: Signal amplitude.
|
|
404
|
+
channel: Channel name.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
Self for chaining.
|
|
408
|
+
"""
|
|
409
|
+
t = self._get_time()
|
|
410
|
+
signal = amplitude * scipy_signal.sawtooth(2 * np.pi * frequency * t)
|
|
411
|
+
self._add_to_channel(channel, signal)
|
|
412
|
+
return self
|
|
413
|
+
|
|
414
|
+
def add_chirp(
|
|
415
|
+
self,
|
|
416
|
+
f0: float = 1e3,
|
|
417
|
+
f1: float = 10e3,
|
|
418
|
+
method: Literal["linear", "quadratic", "logarithmic"] = "linear",
|
|
419
|
+
amplitude: float = 1.0,
|
|
420
|
+
channel: str = "ch1",
|
|
421
|
+
) -> SignalBuilder:
|
|
422
|
+
"""Add chirp (frequency sweep) signal.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
f0: Starting frequency in Hz.
|
|
426
|
+
f1: Ending frequency in Hz.
|
|
427
|
+
method: Sweep type.
|
|
428
|
+
amplitude: Signal amplitude.
|
|
429
|
+
channel: Channel name.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
Self for chaining.
|
|
433
|
+
"""
|
|
434
|
+
t = self._get_time()
|
|
435
|
+
signal = amplitude * scipy_signal.chirp(t, f0, self._duration, f1, method=method)
|
|
436
|
+
self._add_to_channel(channel, signal)
|
|
437
|
+
return self
|
|
438
|
+
|
|
439
|
+
def add_multitone(
|
|
440
|
+
self,
|
|
441
|
+
frequencies: list[float],
|
|
442
|
+
amplitudes: list[float] | None = None,
|
|
443
|
+
channel: str = "ch1",
|
|
444
|
+
) -> SignalBuilder:
|
|
445
|
+
"""Add multi-tone signal.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
frequencies: List of frequencies in Hz.
|
|
449
|
+
amplitudes: List of amplitudes (default: all 1.0).
|
|
450
|
+
channel: Channel name.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
Self for chaining.
|
|
454
|
+
"""
|
|
455
|
+
t = self._get_time()
|
|
456
|
+
if amplitudes is None:
|
|
457
|
+
amplitudes = [1.0] * len(frequencies)
|
|
458
|
+
|
|
459
|
+
signal = np.zeros_like(t)
|
|
460
|
+
for freq, amp in zip(frequencies, amplitudes, strict=False):
|
|
461
|
+
signal += amp * np.sin(2 * np.pi * freq * t)
|
|
462
|
+
|
|
463
|
+
self._add_to_channel(channel, signal)
|
|
464
|
+
return self
|
|
465
|
+
|
|
466
|
+
def add_dc(
|
|
467
|
+
self,
|
|
468
|
+
level: float = 1.0,
|
|
469
|
+
channel: str = "ch1",
|
|
470
|
+
) -> SignalBuilder:
|
|
471
|
+
"""Add DC level.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
level: DC voltage level.
|
|
475
|
+
channel: Channel name.
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
Self for chaining.
|
|
479
|
+
"""
|
|
480
|
+
n_samples = self._get_num_samples()
|
|
481
|
+
signal = np.full(n_samples, level)
|
|
482
|
+
self._add_to_channel(channel, signal)
|
|
483
|
+
return self
|
|
484
|
+
|
|
485
|
+
def add_pulse(
|
|
486
|
+
self,
|
|
487
|
+
width: float = 1e-6,
|
|
488
|
+
amplitude: float = 1.0,
|
|
489
|
+
delay: float = 0.0,
|
|
490
|
+
rise_time: float = 0.0,
|
|
491
|
+
fall_time: float = 0.0,
|
|
492
|
+
channel: str = "ch1",
|
|
493
|
+
) -> SignalBuilder:
|
|
494
|
+
"""Add single pulse.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
width: Pulse width in seconds.
|
|
498
|
+
amplitude: Pulse amplitude.
|
|
499
|
+
delay: Delay before pulse in seconds.
|
|
500
|
+
rise_time: Rise time in seconds.
|
|
501
|
+
fall_time: Fall time in seconds.
|
|
502
|
+
channel: Channel name.
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
Self for chaining.
|
|
506
|
+
"""
|
|
507
|
+
t = self._get_time()
|
|
508
|
+
signal = np.zeros_like(t)
|
|
509
|
+
|
|
510
|
+
start_idx = int(delay * self._sample_rate)
|
|
511
|
+
width_samples = int(width * self._sample_rate)
|
|
512
|
+
end_idx = min(start_idx + width_samples, len(signal))
|
|
513
|
+
|
|
514
|
+
if start_idx < len(signal):
|
|
515
|
+
signal[start_idx:end_idx] = amplitude
|
|
516
|
+
|
|
517
|
+
# Apply rise/fall times with simple filtering
|
|
518
|
+
if rise_time > 0 or fall_time > 0:
|
|
519
|
+
tau = max(rise_time, fall_time) / 2.2
|
|
520
|
+
if tau > 0:
|
|
521
|
+
alpha = 1 / (tau * self._sample_rate + 1)
|
|
522
|
+
filtered = np.zeros_like(signal)
|
|
523
|
+
filtered[0] = signal[0]
|
|
524
|
+
for i in range(1, len(signal)):
|
|
525
|
+
filtered[i] = alpha * signal[i] + (1 - alpha) * filtered[i - 1]
|
|
526
|
+
signal = filtered
|
|
527
|
+
|
|
528
|
+
self._add_to_channel(channel, signal)
|
|
529
|
+
return self
|
|
530
|
+
|
|
531
|
+
# ========== Noise Methods ==========
|
|
532
|
+
|
|
533
|
+
def add_noise(
|
|
534
|
+
self,
|
|
535
|
+
snr_db: float = 40.0,
|
|
536
|
+
noise_type: Literal["gaussian", "white", "pink"] = "gaussian",
|
|
537
|
+
channel: str = "ch1",
|
|
538
|
+
) -> SignalBuilder:
|
|
539
|
+
"""Add noise at specified SNR.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
snr_db: Target signal-to-noise ratio in dB.
|
|
543
|
+
noise_type: Type of noise.
|
|
544
|
+
channel: Channel name.
|
|
545
|
+
|
|
546
|
+
Returns:
|
|
547
|
+
Self for chaining.
|
|
548
|
+
"""
|
|
549
|
+
if channel not in self._channels:
|
|
550
|
+
raise ValueError(f"Channel '{channel}' does not exist. Add a signal first.")
|
|
551
|
+
|
|
552
|
+
signal = self._channels[channel]
|
|
553
|
+
signal_power = np.mean(signal**2)
|
|
554
|
+
|
|
555
|
+
if signal_power == 0:
|
|
556
|
+
signal_power = 1.0
|
|
557
|
+
|
|
558
|
+
noise_power = signal_power / (10 ** (snr_db / 10))
|
|
559
|
+
n_samples = len(signal)
|
|
560
|
+
|
|
561
|
+
if noise_type in ["gaussian", "white"]:
|
|
562
|
+
noise = np.sqrt(noise_power) * np.random.randn(n_samples)
|
|
563
|
+
elif noise_type == "pink":
|
|
564
|
+
white = np.random.randn(n_samples)
|
|
565
|
+
fft_white = np.fft.rfft(white)
|
|
566
|
+
freqs = np.fft.rfftfreq(n_samples)
|
|
567
|
+
freqs[0] = 1 # Avoid division by zero
|
|
568
|
+
pink_filter = 1 / np.sqrt(freqs)
|
|
569
|
+
fft_pink = fft_white * pink_filter
|
|
570
|
+
noise = np.fft.irfft(fft_pink, n=n_samples)
|
|
571
|
+
noise = noise * np.sqrt(noise_power / np.mean(noise**2))
|
|
572
|
+
else:
|
|
573
|
+
raise ValueError(f"Unknown noise type: {noise_type}")
|
|
574
|
+
|
|
575
|
+
self._channels[channel] = signal + noise
|
|
576
|
+
self._parameters[f"{channel}_snr_db"] = snr_db
|
|
577
|
+
return self
|
|
578
|
+
|
|
579
|
+
def add_white_noise(
|
|
580
|
+
self,
|
|
581
|
+
amplitude: float = 1.0,
|
|
582
|
+
channel: str = "ch1",
|
|
583
|
+
) -> SignalBuilder:
|
|
584
|
+
"""Add white noise signal.
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
amplitude: Noise amplitude (standard deviation).
|
|
588
|
+
channel: Channel name.
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
Self for chaining.
|
|
592
|
+
"""
|
|
593
|
+
n_samples = self._get_num_samples()
|
|
594
|
+
noise = amplitude * np.random.randn(n_samples)
|
|
595
|
+
self._add_to_channel(channel, noise)
|
|
596
|
+
return self
|
|
597
|
+
|
|
598
|
+
# ========== Protocol Signal Methods ==========
|
|
599
|
+
|
|
600
|
+
def add_uart(
|
|
601
|
+
self,
|
|
602
|
+
baud_rate: int = 115200,
|
|
603
|
+
data: bytes = b"Hello",
|
|
604
|
+
config: str = "8N1",
|
|
605
|
+
amplitude: float = 3.3,
|
|
606
|
+
idle_high: bool = True,
|
|
607
|
+
channel: str = "uart",
|
|
608
|
+
) -> SignalBuilder:
|
|
609
|
+
"""Add UART transmission signal.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
baud_rate: UART baud rate.
|
|
613
|
+
data: Data bytes to transmit.
|
|
614
|
+
config: Configuration string "XYZ" where X=data bits, Y=parity, Z=stop bits.
|
|
615
|
+
amplitude: Logic high voltage level.
|
|
616
|
+
idle_high: If True, idle state is high (standard UART).
|
|
617
|
+
channel: Channel name.
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
Self for chaining.
|
|
621
|
+
"""
|
|
622
|
+
# Parse config
|
|
623
|
+
data_bits = int(config[0])
|
|
624
|
+
parity = config[1].upper() # N, O, E
|
|
625
|
+
stop_bits = int(config[2])
|
|
626
|
+
|
|
627
|
+
samples_per_bit = int(self._sample_rate / baud_rate)
|
|
628
|
+
bits: list[int] = []
|
|
629
|
+
|
|
630
|
+
# Add initial idle
|
|
631
|
+
idle_level = 1 if idle_high else 0
|
|
632
|
+
bits.extend([idle_level] * (samples_per_bit * 2))
|
|
633
|
+
|
|
634
|
+
for byte_val in data:
|
|
635
|
+
# Start bit (opposite of idle)
|
|
636
|
+
bits.extend([1 - idle_level] * samples_per_bit)
|
|
637
|
+
|
|
638
|
+
# Data bits (LSB first)
|
|
639
|
+
ones_count = 0
|
|
640
|
+
for i in range(data_bits):
|
|
641
|
+
bit = (byte_val >> i) & 1
|
|
642
|
+
ones_count += bit
|
|
643
|
+
bits.extend([bit] * samples_per_bit)
|
|
644
|
+
|
|
645
|
+
# Parity bit
|
|
646
|
+
if parity == "O": # Odd parity
|
|
647
|
+
parity_bit = (ones_count + 1) % 2
|
|
648
|
+
bits.extend([parity_bit] * samples_per_bit)
|
|
649
|
+
elif parity == "E": # Even parity
|
|
650
|
+
parity_bit = ones_count % 2
|
|
651
|
+
bits.extend([parity_bit] * samples_per_bit)
|
|
652
|
+
# N = no parity
|
|
653
|
+
|
|
654
|
+
# Stop bits
|
|
655
|
+
bits.extend([idle_level] * (samples_per_bit * stop_bits))
|
|
656
|
+
|
|
657
|
+
# Inter-frame gap
|
|
658
|
+
bits.extend([idle_level] * samples_per_bit)
|
|
659
|
+
|
|
660
|
+
# Final idle
|
|
661
|
+
bits.extend([idle_level] * (samples_per_bit * 2))
|
|
662
|
+
|
|
663
|
+
signal = np.array(bits, dtype=np.float64) * amplitude
|
|
664
|
+
self._add_to_channel(channel, signal)
|
|
665
|
+
self._parameters["uart_baud_rate"] = baud_rate
|
|
666
|
+
self._parameters["uart_data"] = data.hex()
|
|
667
|
+
return self
|
|
668
|
+
|
|
669
|
+
def add_spi(
|
|
670
|
+
self,
|
|
671
|
+
clock_freq: float = 1e6,
|
|
672
|
+
mode: int = 0,
|
|
673
|
+
data_mosi: bytes = b"\x00",
|
|
674
|
+
data_miso: bytes | None = None,
|
|
675
|
+
amplitude: float = 3.3,
|
|
676
|
+
channels: tuple[str, str, str, str] = ("sck", "mosi", "miso", "cs"),
|
|
677
|
+
) -> SignalBuilder:
|
|
678
|
+
"""Add SPI transaction signals.
|
|
679
|
+
|
|
680
|
+
Args:
|
|
681
|
+
clock_freq: SPI clock frequency in Hz.
|
|
682
|
+
mode: SPI mode (0-3, combines CPOL and CPHA).
|
|
683
|
+
data_mosi: MOSI data bytes.
|
|
684
|
+
data_miso: MISO data bytes (default: same as MOSI).
|
|
685
|
+
amplitude: Logic high voltage level.
|
|
686
|
+
channels: Tuple of channel names (SCK, MOSI, MISO, CS).
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
Self for chaining.
|
|
690
|
+
"""
|
|
691
|
+
if data_miso is None:
|
|
692
|
+
data_miso = data_mosi
|
|
693
|
+
|
|
694
|
+
cpol = (mode >> 1) & 1
|
|
695
|
+
cpha = mode & 1
|
|
696
|
+
|
|
697
|
+
samples_per_half_clock = int(self._sample_rate / clock_freq / 2)
|
|
698
|
+
total_bits = len(data_mosi) * 8
|
|
699
|
+
total_samples = samples_per_half_clock * 2 * total_bits + samples_per_half_clock * 4
|
|
700
|
+
|
|
701
|
+
# Initialize signals
|
|
702
|
+
sck = np.full(total_samples, amplitude if cpol else 0.0)
|
|
703
|
+
mosi = np.zeros(total_samples)
|
|
704
|
+
miso = np.zeros(total_samples)
|
|
705
|
+
cs = np.full(total_samples, amplitude) # Active low
|
|
706
|
+
|
|
707
|
+
idx = samples_per_half_clock # Start after idle
|
|
708
|
+
cs[idx:] = 0.0 # Activate CS
|
|
709
|
+
|
|
710
|
+
for byte_idx in range(len(data_mosi)):
|
|
711
|
+
mosi_byte = data_mosi[byte_idx]
|
|
712
|
+
miso_byte = data_miso[byte_idx] if byte_idx < len(data_miso) else 0
|
|
713
|
+
|
|
714
|
+
for bit_idx in range(8):
|
|
715
|
+
mosi_bit = (mosi_byte >> (7 - bit_idx)) & 1
|
|
716
|
+
miso_bit = (miso_byte >> (7 - bit_idx)) & 1
|
|
717
|
+
|
|
718
|
+
if cpha == 0:
|
|
719
|
+
mosi[idx : idx + samples_per_half_clock * 2] = amplitude if mosi_bit else 0.0
|
|
720
|
+
miso[idx : idx + samples_per_half_clock * 2] = amplitude if miso_bit else 0.0
|
|
721
|
+
|
|
722
|
+
# Clock edge
|
|
723
|
+
if cpol == 0:
|
|
724
|
+
sck[idx + samples_per_half_clock : idx + samples_per_half_clock * 2] = amplitude
|
|
725
|
+
else:
|
|
726
|
+
sck[idx + samples_per_half_clock : idx + samples_per_half_clock * 2] = 0.0
|
|
727
|
+
|
|
728
|
+
if cpha == 1:
|
|
729
|
+
mosi[idx + samples_per_half_clock : idx + samples_per_half_clock * 2] = (
|
|
730
|
+
amplitude if mosi_bit else 0.0
|
|
731
|
+
)
|
|
732
|
+
miso[idx + samples_per_half_clock : idx + samples_per_half_clock * 2] = (
|
|
733
|
+
amplitude if miso_bit else 0.0
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
idx += samples_per_half_clock * 2
|
|
737
|
+
|
|
738
|
+
cs[idx:] = amplitude # Deactivate CS
|
|
739
|
+
|
|
740
|
+
self._channels[channels[0]] = sck
|
|
741
|
+
self._channels[channels[1]] = mosi
|
|
742
|
+
self._channels[channels[2]] = miso
|
|
743
|
+
self._channels[channels[3]] = cs
|
|
744
|
+
|
|
745
|
+
self._parameters["spi_clock_freq"] = clock_freq
|
|
746
|
+
self._parameters["spi_mode"] = mode
|
|
747
|
+
return self
|
|
748
|
+
|
|
749
|
+
def add_i2c(
|
|
750
|
+
self,
|
|
751
|
+
clock_freq: float = 100e3,
|
|
752
|
+
address: int = 0x50,
|
|
753
|
+
data: bytes = b"\x00",
|
|
754
|
+
read: bool = False,
|
|
755
|
+
amplitude: float = 3.3,
|
|
756
|
+
channels: tuple[str, str] = ("scl", "sda"),
|
|
757
|
+
) -> SignalBuilder:
|
|
758
|
+
"""Add I2C transaction signals.
|
|
759
|
+
|
|
760
|
+
Args:
|
|
761
|
+
clock_freq: I2C clock frequency in Hz.
|
|
762
|
+
address: 7-bit I2C address.
|
|
763
|
+
data: Data bytes to transmit.
|
|
764
|
+
read: True for read, False for write.
|
|
765
|
+
amplitude: Logic high voltage level.
|
|
766
|
+
channels: Tuple of channel names (SCL, SDA).
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
Self for chaining.
|
|
770
|
+
"""
|
|
771
|
+
samples_per_bit = int(self._sample_rate / clock_freq)
|
|
772
|
+
half_bit = samples_per_bit // 2
|
|
773
|
+
|
|
774
|
+
# Calculate total samples
|
|
775
|
+
total_bits = 1 + 8 + 1 + len(data) * 9 + 1
|
|
776
|
+
total_samples = samples_per_bit * total_bits + samples_per_bit * 2
|
|
777
|
+
|
|
778
|
+
scl = np.full(total_samples, amplitude)
|
|
779
|
+
sda = np.full(total_samples, amplitude)
|
|
780
|
+
|
|
781
|
+
idx = samples_per_bit # Start after idle
|
|
782
|
+
|
|
783
|
+
# START: SDA falls while SCL high
|
|
784
|
+
sda[idx : idx + half_bit] = 0.0
|
|
785
|
+
idx += half_bit
|
|
786
|
+
|
|
787
|
+
# Address + R/W bit
|
|
788
|
+
addr_byte = (address << 1) | (1 if read else 0)
|
|
789
|
+
|
|
790
|
+
for bit_idx in range(8):
|
|
791
|
+
bit = (addr_byte >> (7 - bit_idx)) & 1
|
|
792
|
+
scl[idx : idx + half_bit] = 0.0
|
|
793
|
+
sda[idx : idx + samples_per_bit] = amplitude if bit else 0.0
|
|
794
|
+
idx += half_bit
|
|
795
|
+
scl[idx : idx + half_bit] = amplitude
|
|
796
|
+
idx += half_bit
|
|
797
|
+
|
|
798
|
+
# ACK bit
|
|
799
|
+
scl[idx : idx + half_bit] = 0.0
|
|
800
|
+
sda[idx : idx + samples_per_bit] = 0.0 # ACK (low)
|
|
801
|
+
idx += half_bit
|
|
802
|
+
scl[idx : idx + half_bit] = amplitude
|
|
803
|
+
idx += half_bit
|
|
804
|
+
|
|
805
|
+
# Data bytes
|
|
806
|
+
for byte_val in data:
|
|
807
|
+
for bit_idx in range(8):
|
|
808
|
+
bit = (byte_val >> (7 - bit_idx)) & 1
|
|
809
|
+
scl[idx : idx + half_bit] = 0.0
|
|
810
|
+
sda[idx : idx + samples_per_bit] = amplitude if bit else 0.0
|
|
811
|
+
idx += half_bit
|
|
812
|
+
scl[idx : idx + half_bit] = amplitude
|
|
813
|
+
idx += half_bit
|
|
814
|
+
|
|
815
|
+
# ACK
|
|
816
|
+
scl[idx : idx + half_bit] = 0.0
|
|
817
|
+
sda[idx : idx + samples_per_bit] = 0.0
|
|
818
|
+
idx += half_bit
|
|
819
|
+
scl[idx : idx + half_bit] = amplitude
|
|
820
|
+
idx += half_bit
|
|
821
|
+
|
|
822
|
+
# STOP: SDA rises while SCL high
|
|
823
|
+
scl[idx : idx + half_bit] = 0.0
|
|
824
|
+
sda[idx : idx + half_bit] = 0.0
|
|
825
|
+
idx += half_bit
|
|
826
|
+
scl[idx:] = amplitude
|
|
827
|
+
sda[idx:] = amplitude
|
|
828
|
+
|
|
829
|
+
self._channels[channels[0]] = scl[:idx]
|
|
830
|
+
self._channels[channels[1]] = sda[:idx]
|
|
831
|
+
|
|
832
|
+
self._parameters["i2c_clock_freq"] = clock_freq
|
|
833
|
+
self._parameters["i2c_address"] = address
|
|
834
|
+
return self
|
|
835
|
+
|
|
836
|
+
def add_can(
|
|
837
|
+
self,
|
|
838
|
+
bitrate: int = 500000,
|
|
839
|
+
arbitration_id: int = 0x100,
|
|
840
|
+
data: bytes = b"\x00",
|
|
841
|
+
extended: bool = False,
|
|
842
|
+
amplitude: float = 2.5,
|
|
843
|
+
channel: str = "can",
|
|
844
|
+
) -> SignalBuilder:
|
|
845
|
+
"""Add CAN message signal.
|
|
846
|
+
|
|
847
|
+
Args:
|
|
848
|
+
bitrate: CAN bit rate.
|
|
849
|
+
arbitration_id: Message arbitration ID.
|
|
850
|
+
data: Data bytes (max 8).
|
|
851
|
+
extended: True for extended (29-bit) ID.
|
|
852
|
+
amplitude: Logic high voltage level.
|
|
853
|
+
channel: Channel name.
|
|
854
|
+
|
|
855
|
+
Returns:
|
|
856
|
+
Self for chaining.
|
|
857
|
+
"""
|
|
858
|
+
samples_per_bit = int(self._sample_rate / bitrate)
|
|
859
|
+
bits: list[int] = []
|
|
860
|
+
|
|
861
|
+
# Start of frame (dominant = 0)
|
|
862
|
+
bits.append(0)
|
|
863
|
+
|
|
864
|
+
# Arbitration ID
|
|
865
|
+
id_bits = 29 if extended else 11
|
|
866
|
+
for i in range(id_bits - 1, -1, -1):
|
|
867
|
+
bits.append((arbitration_id >> i) & 1)
|
|
868
|
+
|
|
869
|
+
# RTR (0 for data frame)
|
|
870
|
+
bits.append(0)
|
|
871
|
+
|
|
872
|
+
# IDE (0 for standard, 1 for extended)
|
|
873
|
+
if not extended:
|
|
874
|
+
bits.append(0)
|
|
875
|
+
|
|
876
|
+
# Reserved bit
|
|
877
|
+
bits.append(0)
|
|
878
|
+
|
|
879
|
+
# DLC (data length code)
|
|
880
|
+
dlc = min(len(data), 8)
|
|
881
|
+
for i in range(3, -1, -1):
|
|
882
|
+
bits.append((dlc >> i) & 1)
|
|
883
|
+
|
|
884
|
+
# Data bytes
|
|
885
|
+
for byte_val in data[:8]:
|
|
886
|
+
for i in range(7, -1, -1):
|
|
887
|
+
bits.append((byte_val >> i) & 1)
|
|
888
|
+
|
|
889
|
+
# CRC (simplified placeholder)
|
|
890
|
+
for _ in range(15):
|
|
891
|
+
bits.append(0)
|
|
892
|
+
|
|
893
|
+
# CRC delimiter
|
|
894
|
+
bits.append(1)
|
|
895
|
+
|
|
896
|
+
# ACK slot and delimiter
|
|
897
|
+
bits.append(0) # ACK
|
|
898
|
+
bits.append(1) # ACK delimiter
|
|
899
|
+
|
|
900
|
+
# End of frame (7 recessive bits)
|
|
901
|
+
bits.extend([1] * 7)
|
|
902
|
+
|
|
903
|
+
# Inter-frame space
|
|
904
|
+
bits.extend([1] * 3)
|
|
905
|
+
|
|
906
|
+
# Convert to signal
|
|
907
|
+
signal_bits: list[float] = []
|
|
908
|
+
for bit in bits:
|
|
909
|
+
level = amplitude if bit else 0.0
|
|
910
|
+
signal_bits.extend([level] * samples_per_bit)
|
|
911
|
+
|
|
912
|
+
signal = np.array(signal_bits, dtype=np.float64)
|
|
913
|
+
self._add_to_channel(channel, signal)
|
|
914
|
+
self._parameters["can_bitrate"] = bitrate
|
|
915
|
+
self._parameters["can_id"] = arbitration_id
|
|
916
|
+
return self
|
|
917
|
+
|
|
918
|
+
def add_digital_pattern(
|
|
919
|
+
self,
|
|
920
|
+
pattern: str = "01010101",
|
|
921
|
+
bit_rate: float = 1e6,
|
|
922
|
+
amplitude: float = 3.3,
|
|
923
|
+
channel: str = "digital",
|
|
924
|
+
) -> SignalBuilder:
|
|
925
|
+
"""Add digital bit pattern.
|
|
926
|
+
|
|
927
|
+
Args:
|
|
928
|
+
pattern: Binary pattern string (e.g., "01010101").
|
|
929
|
+
bit_rate: Bit rate in bps.
|
|
930
|
+
amplitude: Logic high voltage level.
|
|
931
|
+
channel: Channel name.
|
|
932
|
+
|
|
933
|
+
Returns:
|
|
934
|
+
Self for chaining.
|
|
935
|
+
"""
|
|
936
|
+
samples_per_bit = int(self._sample_rate / bit_rate)
|
|
937
|
+
bits: list[float] = []
|
|
938
|
+
|
|
939
|
+
for bit_char in pattern:
|
|
940
|
+
level = amplitude if bit_char == "1" else 0.0
|
|
941
|
+
bits.extend([level] * samples_per_bit)
|
|
942
|
+
|
|
943
|
+
signal = np.array(bits, dtype=np.float64)
|
|
944
|
+
self._add_to_channel(channel, signal)
|
|
945
|
+
return self
|
|
946
|
+
|
|
947
|
+
def add_clock(
|
|
948
|
+
self,
|
|
949
|
+
frequency: float = 1e6,
|
|
950
|
+
duty_cycle: float = 0.5,
|
|
951
|
+
amplitude: float = 3.3,
|
|
952
|
+
channel: str = "clk",
|
|
953
|
+
) -> SignalBuilder:
|
|
954
|
+
"""Add clock signal.
|
|
955
|
+
|
|
956
|
+
Args:
|
|
957
|
+
frequency: Clock frequency in Hz.
|
|
958
|
+
duty_cycle: Duty cycle (0-1).
|
|
959
|
+
amplitude: Logic high voltage level.
|
|
960
|
+
channel: Channel name.
|
|
961
|
+
|
|
962
|
+
Returns:
|
|
963
|
+
Self for chaining.
|
|
964
|
+
"""
|
|
965
|
+
return self.add_square(
|
|
966
|
+
frequency=frequency,
|
|
967
|
+
amplitude=amplitude,
|
|
968
|
+
duty_cycle=duty_cycle,
|
|
969
|
+
channel=channel,
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
# ========== Impairment Methods ==========
|
|
973
|
+
|
|
974
|
+
def add_jitter(
|
|
975
|
+
self,
|
|
976
|
+
rj_rms: float = 0.0,
|
|
977
|
+
dj_pp: float = 0.0,
|
|
978
|
+
channel: str = "ch1",
|
|
979
|
+
) -> SignalBuilder:
|
|
980
|
+
"""Add jitter to digital signal.
|
|
981
|
+
|
|
982
|
+
Args:
|
|
983
|
+
rj_rms: Random jitter RMS in seconds.
|
|
984
|
+
dj_pp: Deterministic jitter peak-to-peak in seconds.
|
|
985
|
+
channel: Channel name.
|
|
986
|
+
|
|
987
|
+
Returns:
|
|
988
|
+
Self for chaining.
|
|
989
|
+
"""
|
|
990
|
+
if channel not in self._channels:
|
|
991
|
+
raise ValueError(f"Channel '{channel}' does not exist.")
|
|
992
|
+
|
|
993
|
+
if rj_rms == 0 and dj_pp == 0:
|
|
994
|
+
return self
|
|
995
|
+
|
|
996
|
+
signal = self._channels[channel]
|
|
997
|
+
threshold = (np.max(signal) + np.min(signal)) / 2
|
|
998
|
+
edges = np.where(np.diff((signal > threshold).astype(int)))[0]
|
|
999
|
+
|
|
1000
|
+
if len(edges) == 0:
|
|
1001
|
+
return self
|
|
1002
|
+
|
|
1003
|
+
t_original = np.arange(len(signal)) / self._sample_rate
|
|
1004
|
+
t_jittered = t_original.copy()
|
|
1005
|
+
|
|
1006
|
+
for edge_idx in edges:
|
|
1007
|
+
jitter = 0.0
|
|
1008
|
+
if rj_rms > 0:
|
|
1009
|
+
jitter += np.random.randn() * rj_rms
|
|
1010
|
+
if dj_pp > 0:
|
|
1011
|
+
jitter += (dj_pp / 2) * np.sin(2 * np.pi * edge_idx / max(len(edges), 1))
|
|
1012
|
+
|
|
1013
|
+
edge_region = slice(max(0, edge_idx - 5), min(len(signal), edge_idx + 6))
|
|
1014
|
+
t_jittered[edge_region] += jitter
|
|
1015
|
+
|
|
1016
|
+
self._channels[channel] = np.interp(t_original, t_jittered, signal)
|
|
1017
|
+
return self
|
|
1018
|
+
|
|
1019
|
+
def add_quantization(
|
|
1020
|
+
self,
|
|
1021
|
+
bits: int = 8,
|
|
1022
|
+
full_scale: float = 2.0,
|
|
1023
|
+
channel: str = "ch1",
|
|
1024
|
+
) -> SignalBuilder:
|
|
1025
|
+
"""Apply ADC quantization effects.
|
|
1026
|
+
|
|
1027
|
+
Args:
|
|
1028
|
+
bits: Number of ADC bits.
|
|
1029
|
+
full_scale: Full scale range.
|
|
1030
|
+
channel: Channel name.
|
|
1031
|
+
|
|
1032
|
+
Returns:
|
|
1033
|
+
Self for chaining.
|
|
1034
|
+
"""
|
|
1035
|
+
if channel not in self._channels:
|
|
1036
|
+
raise ValueError(f"Channel '{channel}' does not exist.")
|
|
1037
|
+
|
|
1038
|
+
signal = self._channels[channel]
|
|
1039
|
+
levels = 2**bits
|
|
1040
|
+
lsb = full_scale / levels
|
|
1041
|
+
|
|
1042
|
+
# Quantize
|
|
1043
|
+
quantized = np.round(signal / lsb) * lsb
|
|
1044
|
+
# Clip to full scale
|
|
1045
|
+
quantized = np.clip(quantized, -full_scale / 2, full_scale / 2 - lsb)
|
|
1046
|
+
|
|
1047
|
+
self._channels[channel] = quantized
|
|
1048
|
+
self._parameters[f"{channel}_adc_bits"] = bits
|
|
1049
|
+
return self
|
|
1050
|
+
|
|
1051
|
+
# ========== Build Methods ==========
|
|
1052
|
+
|
|
1053
|
+
def build(self) -> GeneratedSignal:
|
|
1054
|
+
"""Build and return signal.
|
|
1055
|
+
|
|
1056
|
+
Returns:
|
|
1057
|
+
GeneratedSignal containing all channels and metadata.
|
|
1058
|
+
|
|
1059
|
+
Raises:
|
|
1060
|
+
ValueError: If no signals have been added.
|
|
1061
|
+
"""
|
|
1062
|
+
if not self._channels:
|
|
1063
|
+
raise ValueError("No signals added. Call add_* methods before build().")
|
|
1064
|
+
|
|
1065
|
+
# Ensure all channels have same length (pad if necessary)
|
|
1066
|
+
max_len = max(len(s) for s in self._channels.values())
|
|
1067
|
+
for name, signal in self._channels.items():
|
|
1068
|
+
if len(signal) < max_len:
|
|
1069
|
+
self._channels[name] = np.pad(signal, (0, max_len - len(signal)), mode="edge")
|
|
1070
|
+
|
|
1071
|
+
# Calculate actual duration from signal length
|
|
1072
|
+
actual_duration = max_len / self._sample_rate
|
|
1073
|
+
|
|
1074
|
+
metadata = SignalMetadata(
|
|
1075
|
+
sample_rate=self._sample_rate,
|
|
1076
|
+
duration=actual_duration,
|
|
1077
|
+
channel_names=list(self._channels.keys()),
|
|
1078
|
+
description=self._description,
|
|
1079
|
+
generator="SignalBuilder",
|
|
1080
|
+
parameters=self._parameters,
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
return GeneratedSignal(data=self._channels.copy(), metadata=metadata)
|
|
1084
|
+
|
|
1085
|
+
def build_trace(self, channel: str | None = None) -> WaveformTrace:
|
|
1086
|
+
"""Build and return as WaveformTrace for direct use with TraceKit.
|
|
1087
|
+
|
|
1088
|
+
Args:
|
|
1089
|
+
channel: Channel to return as trace. If None, uses first channel.
|
|
1090
|
+
|
|
1091
|
+
Returns:
|
|
1092
|
+
WaveformTrace ready for TraceKit analysis.
|
|
1093
|
+
"""
|
|
1094
|
+
signal = self.build()
|
|
1095
|
+
return signal.to_trace(channel)
|
|
1096
|
+
|
|
1097
|
+
def save_npz(self, path: Path | str) -> GeneratedSignal:
|
|
1098
|
+
"""Build and save signal to NPZ format.
|
|
1099
|
+
|
|
1100
|
+
Args:
|
|
1101
|
+
path: Output file path.
|
|
1102
|
+
|
|
1103
|
+
Returns:
|
|
1104
|
+
GeneratedSignal that was saved.
|
|
1105
|
+
"""
|
|
1106
|
+
signal = self.build()
|
|
1107
|
+
signal.save_npz(path)
|
|
1108
|
+
return signal
|
|
1109
|
+
|
|
1110
|
+
# ========== Internal Methods ==========
|
|
1111
|
+
|
|
1112
|
+
def _get_time(self) -> np.ndarray[Any, np.dtype[np.float64]]:
|
|
1113
|
+
"""Get time array based on current settings."""
|
|
1114
|
+
n_samples = self._get_num_samples()
|
|
1115
|
+
return np.arange(n_samples) / self._sample_rate
|
|
1116
|
+
|
|
1117
|
+
def _get_num_samples(self) -> int:
|
|
1118
|
+
"""Get number of samples based on current settings."""
|
|
1119
|
+
return int(self._sample_rate * self._duration)
|
|
1120
|
+
|
|
1121
|
+
def _add_to_channel(self, channel: str, signal: np.ndarray[Any, np.dtype[np.float64]]) -> None:
|
|
1122
|
+
"""Add signal to channel, summing if channel already exists."""
|
|
1123
|
+
if channel in self._channels:
|
|
1124
|
+
current = self._channels[channel]
|
|
1125
|
+
if len(signal) > len(current):
|
|
1126
|
+
current = np.pad(current, (0, len(signal) - len(current)))
|
|
1127
|
+
elif len(signal) < len(current):
|
|
1128
|
+
signal = np.pad(signal, (0, len(current) - len(signal)))
|
|
1129
|
+
self._channels[channel] = current + signal
|
|
1130
|
+
else:
|
|
1131
|
+
self._channels[channel] = signal
|