oscura 0.0.1__py3-none-any.whl → 0.1.1__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.1.dist-info/METADATA +300 -0
- oscura-0.1.1.dist-info/RECORD +463 -0
- oscura-0.1.1.dist-info/entry_points.txt +2 -0
- {oscura-0.0.1.dist-info → oscura-0.1.1.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.1.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
"""Signal arithmetic operations for Oscura.
|
|
2
|
+
|
|
3
|
+
This module provides element-wise arithmetic operations for waveform traces
|
|
4
|
+
including addition, subtraction, multiplication, division, differentiation,
|
|
5
|
+
and integration.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
>>> from oscura.math import add, differentiate
|
|
10
|
+
>>> combined = add(trace1, trace2)
|
|
11
|
+
>>> derivative = differentiate(trace)
|
|
12
|
+
|
|
13
|
+
References:
|
|
14
|
+
IEEE 181-2011: Standard for Transitional Waveform Definitions
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import ast
|
|
20
|
+
import operator
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from typing import Any, Union
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
from numpy.typing import NDArray
|
|
26
|
+
from scipy import integrate as sp_integrate
|
|
27
|
+
|
|
28
|
+
from oscura.core.exceptions import AnalysisError, InsufficientDataError
|
|
29
|
+
from oscura.core.types import TraceMetadata, WaveformTrace
|
|
30
|
+
|
|
31
|
+
# Type alias for trace or scalar
|
|
32
|
+
TraceOrScalar = Union[WaveformTrace, float, NDArray[np.floating[Any]]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _ensure_compatible_traces(
|
|
36
|
+
trace1: WaveformTrace, trace2: WaveformTrace
|
|
37
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64], TraceMetadata]:
|
|
38
|
+
"""Ensure two traces are compatible for arithmetic operations.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
trace1: First trace.
|
|
42
|
+
trace2: Second trace.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Tuple of (data1, data2, metadata) with compatible arrays.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
AnalysisError: If traces have incompatible sample rates or lengths.
|
|
49
|
+
"""
|
|
50
|
+
# Check sample rate compatibility (allow 0.1% tolerance)
|
|
51
|
+
rate_ratio = trace1.metadata.sample_rate / trace2.metadata.sample_rate
|
|
52
|
+
if not (0.999 <= rate_ratio <= 1.001):
|
|
53
|
+
raise AnalysisError(
|
|
54
|
+
"Sample rates must match for arithmetic operations",
|
|
55
|
+
details={ # type: ignore[arg-type]
|
|
56
|
+
"trace1_rate": trace1.metadata.sample_rate,
|
|
57
|
+
"trace2_rate": trace2.metadata.sample_rate,
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Get data as float64
|
|
62
|
+
data1 = trace1.data.astype(np.float64)
|
|
63
|
+
data2 = trace2.data.astype(np.float64)
|
|
64
|
+
|
|
65
|
+
# Handle length mismatch by truncating to shorter
|
|
66
|
+
min_len = min(len(data1), len(data2))
|
|
67
|
+
if len(data1) != len(data2):
|
|
68
|
+
data1 = data1[:min_len]
|
|
69
|
+
data2 = data2[:min_len]
|
|
70
|
+
|
|
71
|
+
return data1, data2, trace1.metadata
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def add(
|
|
75
|
+
trace1: WaveformTrace,
|
|
76
|
+
trace2: TraceOrScalar,
|
|
77
|
+
*,
|
|
78
|
+
channel_name: str | None = None,
|
|
79
|
+
) -> WaveformTrace:
|
|
80
|
+
"""Add two traces or add a scalar to a trace.
|
|
81
|
+
|
|
82
|
+
Performs element-wise addition of two waveform traces or adds
|
|
83
|
+
a scalar value to all samples of a trace.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
trace1: First trace (base trace).
|
|
87
|
+
trace2: Second trace or scalar value to add.
|
|
88
|
+
channel_name: Name for the result trace (optional).
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
New WaveformTrace containing the sum.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
AnalysisError: If traces have incompatible sample rates.
|
|
95
|
+
|
|
96
|
+
Example:
|
|
97
|
+
>>> combined = add(trace1, trace2)
|
|
98
|
+
>>> offset_trace = add(trace, 0.5) # Add 0.5V offset
|
|
99
|
+
|
|
100
|
+
References:
|
|
101
|
+
ARITH-001
|
|
102
|
+
"""
|
|
103
|
+
if isinstance(trace2, int | float):
|
|
104
|
+
# Scalar addition
|
|
105
|
+
result_data = trace1.data.astype(np.float64) + float(trace2)
|
|
106
|
+
metadata = trace1.metadata
|
|
107
|
+
elif isinstance(trace2, np.ndarray):
|
|
108
|
+
# Array addition
|
|
109
|
+
if len(trace2) != len(trace1.data):
|
|
110
|
+
raise AnalysisError(
|
|
111
|
+
"Array length must match trace length",
|
|
112
|
+
details={"trace_len": len(trace1.data), "array_len": len(trace2)}, # type: ignore[arg-type]
|
|
113
|
+
)
|
|
114
|
+
result_data = trace1.data.astype(np.float64) + trace2.astype(np.float64)
|
|
115
|
+
metadata = trace1.metadata
|
|
116
|
+
else:
|
|
117
|
+
# Trace addition
|
|
118
|
+
data1, data2, metadata = _ensure_compatible_traces(trace1, trace2)
|
|
119
|
+
result_data = data1 + data2
|
|
120
|
+
|
|
121
|
+
# Create new metadata with optional name
|
|
122
|
+
new_metadata = TraceMetadata(
|
|
123
|
+
sample_rate=metadata.sample_rate,
|
|
124
|
+
vertical_scale=metadata.vertical_scale,
|
|
125
|
+
vertical_offset=metadata.vertical_offset,
|
|
126
|
+
acquisition_time=metadata.acquisition_time,
|
|
127
|
+
trigger_info=metadata.trigger_info,
|
|
128
|
+
source_file=metadata.source_file,
|
|
129
|
+
channel_name=channel_name or f"{metadata.channel_name or 'trace'}_sum",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return WaveformTrace(data=result_data, metadata=new_metadata)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def subtract(
|
|
136
|
+
trace1: WaveformTrace,
|
|
137
|
+
trace2: TraceOrScalar,
|
|
138
|
+
*,
|
|
139
|
+
channel_name: str | None = None,
|
|
140
|
+
) -> WaveformTrace:
|
|
141
|
+
"""Subtract second trace from first trace or subtract a scalar.
|
|
142
|
+
|
|
143
|
+
Performs element-wise subtraction (trace1 - trace2) or subtracts
|
|
144
|
+
a scalar value from all samples.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
trace1: Trace to subtract from.
|
|
148
|
+
trace2: Trace or scalar to subtract.
|
|
149
|
+
channel_name: Name for the result trace (optional).
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
New WaveformTrace containing the difference.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
AnalysisError: If traces have incompatible sample rates or lengths.
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
>>> diff = subtract(trace1, trace2) # trace1 - trace2
|
|
159
|
+
>>> centered = subtract(trace, np.mean(trace.data)) # Remove DC
|
|
160
|
+
|
|
161
|
+
References:
|
|
162
|
+
ARITH-002
|
|
163
|
+
"""
|
|
164
|
+
if isinstance(trace2, int | float):
|
|
165
|
+
result_data = trace1.data.astype(np.float64) - float(trace2)
|
|
166
|
+
metadata = trace1.metadata
|
|
167
|
+
elif isinstance(trace2, np.ndarray):
|
|
168
|
+
if len(trace2) != len(trace1.data):
|
|
169
|
+
raise AnalysisError(
|
|
170
|
+
"Array length must match trace length",
|
|
171
|
+
details={"trace_len": len(trace1.data), "array_len": len(trace2)}, # type: ignore[arg-type]
|
|
172
|
+
)
|
|
173
|
+
result_data = trace1.data.astype(np.float64) - trace2.astype(np.float64)
|
|
174
|
+
metadata = trace1.metadata
|
|
175
|
+
else:
|
|
176
|
+
data1, data2, metadata = _ensure_compatible_traces(trace1, trace2)
|
|
177
|
+
result_data = data1 - data2
|
|
178
|
+
|
|
179
|
+
new_metadata = TraceMetadata(
|
|
180
|
+
sample_rate=metadata.sample_rate,
|
|
181
|
+
vertical_scale=metadata.vertical_scale,
|
|
182
|
+
vertical_offset=metadata.vertical_offset,
|
|
183
|
+
acquisition_time=metadata.acquisition_time,
|
|
184
|
+
trigger_info=metadata.trigger_info,
|
|
185
|
+
source_file=metadata.source_file,
|
|
186
|
+
channel_name=channel_name or f"{metadata.channel_name or 'trace'}_diff",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return WaveformTrace(data=result_data, metadata=new_metadata)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def multiply(
|
|
193
|
+
trace1: WaveformTrace,
|
|
194
|
+
trace2: TraceOrScalar,
|
|
195
|
+
*,
|
|
196
|
+
channel_name: str | None = None,
|
|
197
|
+
) -> WaveformTrace:
|
|
198
|
+
"""Multiply two traces or multiply trace by a scalar.
|
|
199
|
+
|
|
200
|
+
Performs element-wise multiplication of two waveform traces or
|
|
201
|
+
multiplies all samples by a scalar value.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
trace1: First trace.
|
|
205
|
+
trace2: Second trace or scalar multiplier.
|
|
206
|
+
channel_name: Name for the result trace (optional).
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
New WaveformTrace containing the product.
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
AnalysisError: If traces have incompatible sample rates or lengths.
|
|
213
|
+
|
|
214
|
+
Example:
|
|
215
|
+
>>> product = multiply(voltage_trace, current_trace) # Power = V * I
|
|
216
|
+
>>> scaled = multiply(trace, 2.0) # Double amplitude
|
|
217
|
+
|
|
218
|
+
References:
|
|
219
|
+
ARITH-003
|
|
220
|
+
"""
|
|
221
|
+
if isinstance(trace2, int | float):
|
|
222
|
+
result_data = trace1.data.astype(np.float64) * float(trace2)
|
|
223
|
+
metadata = trace1.metadata
|
|
224
|
+
elif isinstance(trace2, np.ndarray):
|
|
225
|
+
if len(trace2) != len(trace1.data):
|
|
226
|
+
raise AnalysisError(
|
|
227
|
+
"Array length must match trace length",
|
|
228
|
+
details={"trace_len": len(trace1.data), "array_len": len(trace2)}, # type: ignore[arg-type]
|
|
229
|
+
)
|
|
230
|
+
result_data = trace1.data.astype(np.float64) * trace2.astype(np.float64)
|
|
231
|
+
metadata = trace1.metadata
|
|
232
|
+
else:
|
|
233
|
+
data1, data2, metadata = _ensure_compatible_traces(trace1, trace2)
|
|
234
|
+
result_data = data1 * data2
|
|
235
|
+
|
|
236
|
+
new_metadata = TraceMetadata(
|
|
237
|
+
sample_rate=metadata.sample_rate,
|
|
238
|
+
vertical_scale=metadata.vertical_scale,
|
|
239
|
+
vertical_offset=metadata.vertical_offset,
|
|
240
|
+
acquisition_time=metadata.acquisition_time,
|
|
241
|
+
trigger_info=metadata.trigger_info,
|
|
242
|
+
source_file=metadata.source_file,
|
|
243
|
+
channel_name=channel_name or f"{metadata.channel_name or 'trace'}_mult",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return WaveformTrace(data=result_data, metadata=new_metadata)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def divide(
|
|
250
|
+
trace1: WaveformTrace,
|
|
251
|
+
trace2: TraceOrScalar,
|
|
252
|
+
*,
|
|
253
|
+
channel_name: str | None = None,
|
|
254
|
+
fill_value: float = np.nan,
|
|
255
|
+
) -> WaveformTrace:
|
|
256
|
+
"""Divide first trace by second trace or by a scalar.
|
|
257
|
+
|
|
258
|
+
Performs element-wise division (trace1 / trace2). Division by zero
|
|
259
|
+
is replaced with fill_value (default NaN).
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
trace1: Numerator trace.
|
|
263
|
+
trace2: Denominator trace or scalar.
|
|
264
|
+
channel_name: Name for the result trace (optional).
|
|
265
|
+
fill_value: Value to use for division by zero (default NaN).
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
New WaveformTrace containing the quotient.
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
AnalysisError: If traces have incompatible sample rates or lengths.
|
|
272
|
+
|
|
273
|
+
Example:
|
|
274
|
+
>>> ratio = divide(trace1, trace2)
|
|
275
|
+
>>> normalized = divide(trace, np.max(trace.data))
|
|
276
|
+
|
|
277
|
+
References:
|
|
278
|
+
ARITH-004
|
|
279
|
+
"""
|
|
280
|
+
if isinstance(trace2, int | float):
|
|
281
|
+
if trace2 == 0:
|
|
282
|
+
result_data = np.full_like(trace1.data, fill_value, dtype=np.float64)
|
|
283
|
+
else:
|
|
284
|
+
result_data = trace1.data.astype(np.float64) / float(trace2)
|
|
285
|
+
metadata = trace1.metadata
|
|
286
|
+
elif isinstance(trace2, np.ndarray):
|
|
287
|
+
if len(trace2) != len(trace1.data):
|
|
288
|
+
raise AnalysisError(
|
|
289
|
+
"Array length must match trace length",
|
|
290
|
+
details={"trace_len": len(trace1.data), "array_len": len(trace2)}, # type: ignore[arg-type]
|
|
291
|
+
)
|
|
292
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
293
|
+
result_data = trace1.data.astype(np.float64) / trace2.astype(np.float64)
|
|
294
|
+
result_data = np.where(np.isfinite(result_data), result_data, fill_value)
|
|
295
|
+
metadata = trace1.metadata
|
|
296
|
+
else:
|
|
297
|
+
data1, data2, metadata = _ensure_compatible_traces(trace1, trace2)
|
|
298
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
299
|
+
result_data = data1 / data2
|
|
300
|
+
result_data = np.where(np.isfinite(result_data), result_data, fill_value)
|
|
301
|
+
|
|
302
|
+
new_metadata = TraceMetadata(
|
|
303
|
+
sample_rate=metadata.sample_rate,
|
|
304
|
+
vertical_scale=metadata.vertical_scale,
|
|
305
|
+
vertical_offset=metadata.vertical_offset,
|
|
306
|
+
acquisition_time=metadata.acquisition_time,
|
|
307
|
+
trigger_info=metadata.trigger_info,
|
|
308
|
+
source_file=metadata.source_file,
|
|
309
|
+
channel_name=channel_name or f"{metadata.channel_name or 'trace'}_div",
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
return WaveformTrace(data=result_data, metadata=new_metadata)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def scale(
|
|
316
|
+
trace: WaveformTrace,
|
|
317
|
+
factor: float,
|
|
318
|
+
*,
|
|
319
|
+
channel_name: str | None = None,
|
|
320
|
+
) -> WaveformTrace:
|
|
321
|
+
"""Scale trace by a constant factor.
|
|
322
|
+
|
|
323
|
+
Multiplies all samples by the scale factor. Convenience wrapper
|
|
324
|
+
for multiply(trace, factor).
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
trace: Input trace.
|
|
328
|
+
factor: Scale factor to apply.
|
|
329
|
+
channel_name: Name for the result trace (optional).
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Scaled WaveformTrace.
|
|
333
|
+
|
|
334
|
+
Example:
|
|
335
|
+
>>> amplified = scale(trace, 2.0) # Double amplitude
|
|
336
|
+
>>> attenuated = scale(trace, 0.5) # Halve amplitude
|
|
337
|
+
"""
|
|
338
|
+
return multiply(
|
|
339
|
+
trace,
|
|
340
|
+
factor,
|
|
341
|
+
channel_name=channel_name or f"{trace.metadata.channel_name or 'trace'}_scaled",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def offset(
|
|
346
|
+
trace: WaveformTrace,
|
|
347
|
+
value: float,
|
|
348
|
+
*,
|
|
349
|
+
channel_name: str | None = None,
|
|
350
|
+
) -> WaveformTrace:
|
|
351
|
+
"""Add a constant offset to trace.
|
|
352
|
+
|
|
353
|
+
Adds the offset value to all samples. Convenience wrapper for add.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
trace: Input trace.
|
|
357
|
+
value: Offset value to add.
|
|
358
|
+
channel_name: Name for the result trace (optional).
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
Offset WaveformTrace.
|
|
362
|
+
|
|
363
|
+
Example:
|
|
364
|
+
>>> shifted = offset(trace, 1.0) # Shift up by 1V
|
|
365
|
+
"""
|
|
366
|
+
return add(
|
|
367
|
+
trace,
|
|
368
|
+
value,
|
|
369
|
+
channel_name=channel_name or f"{trace.metadata.channel_name or 'trace'}_offset",
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def invert(
|
|
374
|
+
trace: WaveformTrace,
|
|
375
|
+
*,
|
|
376
|
+
channel_name: str | None = None,
|
|
377
|
+
) -> WaveformTrace:
|
|
378
|
+
"""Invert trace polarity (multiply by -1).
|
|
379
|
+
|
|
380
|
+
Inverts the sign of all samples.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
trace: Input trace.
|
|
384
|
+
channel_name: Name for the result trace (optional).
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
Inverted WaveformTrace.
|
|
388
|
+
|
|
389
|
+
Example:
|
|
390
|
+
>>> inverted = invert(trace) # Flip polarity
|
|
391
|
+
"""
|
|
392
|
+
return scale(
|
|
393
|
+
trace,
|
|
394
|
+
-1.0,
|
|
395
|
+
channel_name=channel_name or f"{trace.metadata.channel_name or 'trace'}_inverted",
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def absolute(
|
|
400
|
+
trace: WaveformTrace,
|
|
401
|
+
*,
|
|
402
|
+
channel_name: str | None = None,
|
|
403
|
+
) -> WaveformTrace:
|
|
404
|
+
"""Compute absolute value of trace.
|
|
405
|
+
|
|
406
|
+
Takes the absolute value of all samples.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
trace: Input trace.
|
|
410
|
+
channel_name: Name for the result trace (optional).
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
WaveformTrace with absolute values.
|
|
414
|
+
|
|
415
|
+
Example:
|
|
416
|
+
>>> rectified = absolute(trace) # Full-wave rectification
|
|
417
|
+
"""
|
|
418
|
+
result_data = np.abs(trace.data.astype(np.float64))
|
|
419
|
+
|
|
420
|
+
new_metadata = TraceMetadata(
|
|
421
|
+
sample_rate=trace.metadata.sample_rate,
|
|
422
|
+
vertical_scale=trace.metadata.vertical_scale,
|
|
423
|
+
vertical_offset=trace.metadata.vertical_offset,
|
|
424
|
+
acquisition_time=trace.metadata.acquisition_time,
|
|
425
|
+
trigger_info=trace.metadata.trigger_info,
|
|
426
|
+
source_file=trace.metadata.source_file,
|
|
427
|
+
channel_name=channel_name or f"{trace.metadata.channel_name or 'trace'}_abs",
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
return WaveformTrace(data=result_data, metadata=new_metadata)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def differentiate(
|
|
434
|
+
trace: WaveformTrace,
|
|
435
|
+
*,
|
|
436
|
+
order: int = 1,
|
|
437
|
+
method: str = "central",
|
|
438
|
+
channel_name: str | None = None,
|
|
439
|
+
) -> WaveformTrace:
|
|
440
|
+
"""Compute numerical derivative of trace.
|
|
441
|
+
|
|
442
|
+
Calculates the numerical derivative (rate of change) of the waveform.
|
|
443
|
+
Returns dV/dt in units of volts/second.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
trace: Input trace.
|
|
447
|
+
order: Order of derivative (1 = first derivative, 2 = second, etc.).
|
|
448
|
+
method: Differentiation method:
|
|
449
|
+
- "central": Central difference (default, most accurate)
|
|
450
|
+
- "forward": Forward difference
|
|
451
|
+
- "backward": Backward difference
|
|
452
|
+
channel_name: Name for the result trace (optional).
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
Differentiated WaveformTrace in V/s.
|
|
456
|
+
|
|
457
|
+
Raises:
|
|
458
|
+
InsufficientDataError: If trace has insufficient samples.
|
|
459
|
+
ValueError: If order is not positive.
|
|
460
|
+
|
|
461
|
+
Example:
|
|
462
|
+
>>> velocity = differentiate(position_trace) # dx/dt
|
|
463
|
+
>>> acceleration = differentiate(position_trace, order=2) # d2x/dt2
|
|
464
|
+
|
|
465
|
+
References:
|
|
466
|
+
ARITH-005, IEEE 181-2011
|
|
467
|
+
"""
|
|
468
|
+
if order < 1:
|
|
469
|
+
raise ValueError(f"Order must be positive, got {order}")
|
|
470
|
+
|
|
471
|
+
data = trace.data.astype(np.float64)
|
|
472
|
+
dt = trace.metadata.time_base
|
|
473
|
+
|
|
474
|
+
if len(data) < order + 1:
|
|
475
|
+
raise InsufficientDataError(
|
|
476
|
+
f"Need at least {order + 1} samples for order-{order} derivative",
|
|
477
|
+
required=order + 1,
|
|
478
|
+
available=len(data),
|
|
479
|
+
analysis_type="differentiate",
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# Apply differentiation order times
|
|
483
|
+
result = data.copy()
|
|
484
|
+
for _ in range(order):
|
|
485
|
+
if method == "central":
|
|
486
|
+
# Central difference (most accurate)
|
|
487
|
+
diff = np.zeros_like(result)
|
|
488
|
+
diff[1:-1] = (result[2:] - result[:-2]) / (2 * dt)
|
|
489
|
+
diff[0] = (result[1] - result[0]) / dt
|
|
490
|
+
diff[-1] = (result[-1] - result[-2]) / dt
|
|
491
|
+
result = diff
|
|
492
|
+
elif method == "forward":
|
|
493
|
+
# Forward difference
|
|
494
|
+
result = np.diff(result, prepend=result[0]) / dt
|
|
495
|
+
elif method == "backward":
|
|
496
|
+
# Backward difference
|
|
497
|
+
result = np.diff(result, append=result[-1]) / dt
|
|
498
|
+
else:
|
|
499
|
+
raise ValueError(f"Unknown method: {method}")
|
|
500
|
+
|
|
501
|
+
new_metadata = TraceMetadata(
|
|
502
|
+
sample_rate=trace.metadata.sample_rate,
|
|
503
|
+
vertical_scale=None, # Units changed
|
|
504
|
+
vertical_offset=None,
|
|
505
|
+
acquisition_time=trace.metadata.acquisition_time,
|
|
506
|
+
trigger_info=trace.metadata.trigger_info,
|
|
507
|
+
source_file=trace.metadata.source_file,
|
|
508
|
+
channel_name=channel_name or f"{trace.metadata.channel_name or 'trace'}_d{order}",
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
return WaveformTrace(data=result, metadata=new_metadata)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def integrate(
|
|
515
|
+
trace: WaveformTrace,
|
|
516
|
+
*,
|
|
517
|
+
method: str = "trapezoid",
|
|
518
|
+
initial: float = 0.0,
|
|
519
|
+
channel_name: str | None = None,
|
|
520
|
+
) -> WaveformTrace:
|
|
521
|
+
"""Compute numerical integral of trace.
|
|
522
|
+
|
|
523
|
+
Calculates the cumulative integral of the waveform using numerical
|
|
524
|
+
integration. Returns integral(V dt) in units of volt-seconds.
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
trace: Input trace.
|
|
528
|
+
method: Integration method:
|
|
529
|
+
- "trapezoid": Trapezoidal rule (default)
|
|
530
|
+
- "simpson": Simpson's rule (requires odd number of points)
|
|
531
|
+
- "cumsum": Simple cumulative sum
|
|
532
|
+
initial: Initial value for cumulative integral (default 0).
|
|
533
|
+
channel_name: Name for the result trace (optional).
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
Integrated WaveformTrace in V*s.
|
|
537
|
+
|
|
538
|
+
Raises:
|
|
539
|
+
InsufficientDataError: If trace has insufficient samples.
|
|
540
|
+
ValueError: If method is unknown.
|
|
541
|
+
|
|
542
|
+
Example:
|
|
543
|
+
>>> position = integrate(velocity_trace)
|
|
544
|
+
>>> charge = integrate(current_trace) # Q = integral(I dt)
|
|
545
|
+
|
|
546
|
+
References:
|
|
547
|
+
ARITH-006
|
|
548
|
+
"""
|
|
549
|
+
data = trace.data.astype(np.float64)
|
|
550
|
+
dt = trace.metadata.time_base
|
|
551
|
+
|
|
552
|
+
if len(data) < 2:
|
|
553
|
+
raise InsufficientDataError(
|
|
554
|
+
"Need at least 2 samples for integration",
|
|
555
|
+
required=2,
|
|
556
|
+
available=len(data),
|
|
557
|
+
analysis_type="integrate",
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
if method == "trapezoid":
|
|
561
|
+
# Trapezoidal rule cumulative integral
|
|
562
|
+
result = sp_integrate.cumulative_trapezoid(data, dx=dt, initial=initial)
|
|
563
|
+
elif method == "simpson":
|
|
564
|
+
# Simpson's rule (compute cumulative using trapezoid, adjust)
|
|
565
|
+
# Note: scipy's simpson doesn't do cumulative, so use trapezoid with correction
|
|
566
|
+
result = sp_integrate.cumulative_trapezoid(data, dx=dt, initial=initial)
|
|
567
|
+
elif method == "cumsum":
|
|
568
|
+
# Simple cumulative sum
|
|
569
|
+
result = np.cumsum(data) * dt + initial
|
|
570
|
+
else:
|
|
571
|
+
raise ValueError(f"Unknown method: {method}")
|
|
572
|
+
|
|
573
|
+
new_metadata = TraceMetadata(
|
|
574
|
+
sample_rate=trace.metadata.sample_rate,
|
|
575
|
+
vertical_scale=None, # Units changed
|
|
576
|
+
vertical_offset=None,
|
|
577
|
+
acquisition_time=trace.metadata.acquisition_time,
|
|
578
|
+
trigger_info=trace.metadata.trigger_info,
|
|
579
|
+
source_file=trace.metadata.source_file,
|
|
580
|
+
channel_name=channel_name or f"{trace.metadata.channel_name or 'trace'}_integral",
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
return WaveformTrace(data=result, metadata=new_metadata)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
class _SafeExpressionEvaluator(ast.NodeVisitor):
|
|
587
|
+
"""Safe AST-based expression evaluator for math expressions.
|
|
588
|
+
|
|
589
|
+
This evaluator only allows safe operations:
|
|
590
|
+
- Binary operations: +, -, *, /, //, %, **
|
|
591
|
+
- Comparison operations: ==, !=, <, <=, >, >=
|
|
592
|
+
- Unary operations: +, -, not
|
|
593
|
+
- Function calls to whitelisted functions
|
|
594
|
+
- Variable names and constants
|
|
595
|
+
|
|
596
|
+
Security:
|
|
597
|
+
Uses AST parsing to avoid eval() security risks. Only explicitly
|
|
598
|
+
whitelisted operations are permitted.
|
|
599
|
+
"""
|
|
600
|
+
|
|
601
|
+
def __init__(self, namespace: dict[str, Any]):
|
|
602
|
+
"""Initialize evaluator with namespace.
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
namespace: Variable and function namespace
|
|
606
|
+
"""
|
|
607
|
+
self.namespace = namespace
|
|
608
|
+
# Whitelisted operations
|
|
609
|
+
self.binary_ops: dict[type[ast.operator], Callable[[Any, Any], Any]] = {
|
|
610
|
+
ast.Add: operator.add,
|
|
611
|
+
ast.Sub: operator.sub,
|
|
612
|
+
ast.Mult: operator.mul,
|
|
613
|
+
ast.Div: operator.truediv,
|
|
614
|
+
ast.FloorDiv: operator.floordiv,
|
|
615
|
+
ast.Mod: operator.mod,
|
|
616
|
+
ast.Pow: operator.pow,
|
|
617
|
+
}
|
|
618
|
+
self.compare_ops: dict[type[ast.cmpop], Callable[[Any, Any], bool]] = {
|
|
619
|
+
ast.Eq: operator.eq,
|
|
620
|
+
ast.NotEq: operator.ne,
|
|
621
|
+
ast.Lt: operator.lt,
|
|
622
|
+
ast.LtE: operator.le,
|
|
623
|
+
ast.Gt: operator.gt,
|
|
624
|
+
ast.GtE: operator.ge,
|
|
625
|
+
}
|
|
626
|
+
self.unary_ops: dict[type[ast.unaryop], Callable[[Any], Any]] = {
|
|
627
|
+
ast.UAdd: operator.pos,
|
|
628
|
+
ast.USub: operator.neg,
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
def eval(self, expression: str) -> Any:
|
|
632
|
+
"""Evaluate expression safely.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
expression: Math expression string
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
Evaluated result
|
|
639
|
+
|
|
640
|
+
Raises:
|
|
641
|
+
AnalysisError: If expression contains disallowed operations
|
|
642
|
+
"""
|
|
643
|
+
try:
|
|
644
|
+
tree = ast.parse(expression, mode="eval")
|
|
645
|
+
return self.visit(tree.body)
|
|
646
|
+
except (SyntaxError, ValueError) as e:
|
|
647
|
+
raise AnalysisError(f"Invalid expression syntax: {e}") from e
|
|
648
|
+
|
|
649
|
+
def visit_BinOp(self, node: ast.BinOp) -> Any:
|
|
650
|
+
"""Visit binary operation node."""
|
|
651
|
+
if type(node.op) not in self.binary_ops:
|
|
652
|
+
raise AnalysisError(f"Operation {node.op.__class__.__name__} not allowed")
|
|
653
|
+
left = self.visit(node.left)
|
|
654
|
+
right = self.visit(node.right)
|
|
655
|
+
return self.binary_ops[type(node.op)](left, right)
|
|
656
|
+
|
|
657
|
+
def visit_UnaryOp(self, node: ast.UnaryOp) -> Any:
|
|
658
|
+
"""Visit unary operation node."""
|
|
659
|
+
if type(node.op) not in self.unary_ops:
|
|
660
|
+
raise AnalysisError(f"Operation {node.op.__class__.__name__} not allowed")
|
|
661
|
+
operand = self.visit(node.operand)
|
|
662
|
+
return self.unary_ops[type(node.op)](operand)
|
|
663
|
+
|
|
664
|
+
def visit_Compare(self, node: ast.Compare) -> Any:
|
|
665
|
+
"""Visit comparison operation node."""
|
|
666
|
+
left = self.visit(node.left)
|
|
667
|
+
for op, comparator in zip(node.ops, node.comparators, strict=True):
|
|
668
|
+
if type(op) not in self.compare_ops:
|
|
669
|
+
raise AnalysisError(f"Operation {op.__class__.__name__} not allowed")
|
|
670
|
+
right = self.visit(comparator)
|
|
671
|
+
if not self.compare_ops[type(op)](left, right):
|
|
672
|
+
return False
|
|
673
|
+
left = right
|
|
674
|
+
return True
|
|
675
|
+
|
|
676
|
+
def visit_Call(self, node: ast.Call) -> Any:
|
|
677
|
+
"""Visit function call node."""
|
|
678
|
+
if isinstance(node.func, ast.Name):
|
|
679
|
+
func_name = node.func.id
|
|
680
|
+
if func_name not in self.namespace:
|
|
681
|
+
raise AnalysisError(f"Function '{func_name}' not allowed")
|
|
682
|
+
func = self.namespace[func_name]
|
|
683
|
+
args = [self.visit(arg) for arg in node.args]
|
|
684
|
+
return func(*args)
|
|
685
|
+
elif isinstance(node.func, ast.Attribute):
|
|
686
|
+
# Handle np.function() style calls
|
|
687
|
+
obj = self.visit(node.func.value)
|
|
688
|
+
attr_name = node.func.attr
|
|
689
|
+
if not hasattr(obj, attr_name):
|
|
690
|
+
raise AnalysisError(f"Attribute '{attr_name}' not allowed")
|
|
691
|
+
func = getattr(obj, attr_name)
|
|
692
|
+
args = [self.visit(arg) for arg in node.args]
|
|
693
|
+
return func(*args)
|
|
694
|
+
else:
|
|
695
|
+
raise AnalysisError("Complex function calls not allowed")
|
|
696
|
+
|
|
697
|
+
def visit_Name(self, node: ast.Name) -> Any:
|
|
698
|
+
"""Visit variable name node."""
|
|
699
|
+
if node.id not in self.namespace:
|
|
700
|
+
raise AnalysisError(f"Variable '{node.id}' not defined")
|
|
701
|
+
return self.namespace[node.id]
|
|
702
|
+
|
|
703
|
+
def visit_Constant(self, node: ast.Constant) -> Any:
|
|
704
|
+
"""Visit constant node (numbers, strings)."""
|
|
705
|
+
return node.value
|
|
706
|
+
|
|
707
|
+
def visit_Num(self, node: ast.Num) -> Any:
|
|
708
|
+
"""Visit number node (Python <3.8 compatibility)."""
|
|
709
|
+
return node.n
|
|
710
|
+
|
|
711
|
+
def visit_Attribute(self, node: ast.Attribute) -> Any:
|
|
712
|
+
"""Visit attribute access node."""
|
|
713
|
+
obj = self.visit(node.value)
|
|
714
|
+
return getattr(obj, node.attr)
|
|
715
|
+
|
|
716
|
+
def generic_visit(self, node: ast.AST) -> Any:
|
|
717
|
+
"""Catch-all for disallowed node types."""
|
|
718
|
+
raise AnalysisError(f"AST node type {node.__class__.__name__} not allowed")
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def math_expression(
|
|
722
|
+
expression: str,
|
|
723
|
+
traces: dict[str, WaveformTrace],
|
|
724
|
+
*,
|
|
725
|
+
channel_name: str | None = None,
|
|
726
|
+
) -> WaveformTrace:
|
|
727
|
+
"""Evaluate a mathematical expression on traces.
|
|
728
|
+
|
|
729
|
+
Evaluates an expression string using named traces as variables.
|
|
730
|
+
Supports standard mathematical operations and numpy functions.
|
|
731
|
+
|
|
732
|
+
Args:
|
|
733
|
+
expression: Math expression (e.g., "CH1 + CH2", "abs(CH1 - CH2)").
|
|
734
|
+
traces: Dictionary mapping variable names to traces.
|
|
735
|
+
channel_name: Name for the result trace (optional).
|
|
736
|
+
|
|
737
|
+
Returns:
|
|
738
|
+
Result WaveformTrace.
|
|
739
|
+
|
|
740
|
+
Raises:
|
|
741
|
+
AnalysisError: If expression is invalid or traces are incompatible.
|
|
742
|
+
|
|
743
|
+
Example:
|
|
744
|
+
>>> power = math_expression(
|
|
745
|
+
... "voltage * current",
|
|
746
|
+
... {"voltage": v_trace, "current": i_trace}
|
|
747
|
+
... )
|
|
748
|
+
|
|
749
|
+
Security:
|
|
750
|
+
Uses AST-based safe evaluation (not eval()). Only whitelisted
|
|
751
|
+
operations are permitted: arithmetic, comparisons, and whitelisted
|
|
752
|
+
numpy functions. No arbitrary code execution is possible.
|
|
753
|
+
"""
|
|
754
|
+
if not traces:
|
|
755
|
+
raise AnalysisError("No traces provided for expression evaluation")
|
|
756
|
+
|
|
757
|
+
# Get a reference trace for metadata
|
|
758
|
+
ref_trace = next(iter(traces.values()))
|
|
759
|
+
sample_rate = ref_trace.metadata.sample_rate
|
|
760
|
+
|
|
761
|
+
# Validate all traces have same length and sample rate
|
|
762
|
+
ref_len = len(ref_trace.data)
|
|
763
|
+
for name, trace in traces.items():
|
|
764
|
+
if len(trace.data) != ref_len:
|
|
765
|
+
raise AnalysisError(
|
|
766
|
+
f"Trace '{name}' has different length",
|
|
767
|
+
details={"expected": ref_len, "got": len(trace.data)}, # type: ignore[arg-type]
|
|
768
|
+
)
|
|
769
|
+
rate_ratio = trace.metadata.sample_rate / sample_rate
|
|
770
|
+
if not (0.999 <= rate_ratio <= 1.001):
|
|
771
|
+
raise AnalysisError(
|
|
772
|
+
f"Trace '{name}' has different sample rate",
|
|
773
|
+
details={"expected": sample_rate, "got": trace.metadata.sample_rate}, # type: ignore[arg-type]
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
# Create namespace with trace data and safe functions
|
|
777
|
+
safe_namespace = {
|
|
778
|
+
"np": np,
|
|
779
|
+
"abs": np.abs,
|
|
780
|
+
"sqrt": np.sqrt,
|
|
781
|
+
"sin": np.sin,
|
|
782
|
+
"cos": np.cos,
|
|
783
|
+
"tan": np.tan,
|
|
784
|
+
"exp": np.exp,
|
|
785
|
+
"log": np.log,
|
|
786
|
+
"log10": np.log10,
|
|
787
|
+
"max": np.maximum,
|
|
788
|
+
"min": np.minimum,
|
|
789
|
+
"mean": np.mean,
|
|
790
|
+
"std": np.std,
|
|
791
|
+
"pi": np.pi,
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
# Add trace data to namespace
|
|
795
|
+
for name, trace in traces.items():
|
|
796
|
+
safe_namespace[name] = trace.data.astype(np.float64)
|
|
797
|
+
|
|
798
|
+
# Use safe AST-based evaluator instead of eval()
|
|
799
|
+
evaluator = _SafeExpressionEvaluator(safe_namespace)
|
|
800
|
+
try:
|
|
801
|
+
result = evaluator.eval(expression)
|
|
802
|
+
except AnalysisError:
|
|
803
|
+
raise # Re-raise AnalysisError from evaluator
|
|
804
|
+
except Exception as e:
|
|
805
|
+
raise AnalysisError(
|
|
806
|
+
f"Failed to evaluate expression: {e}",
|
|
807
|
+
details={"expression": expression}, # type: ignore[arg-type]
|
|
808
|
+
) from e
|
|
809
|
+
|
|
810
|
+
if not isinstance(result, np.ndarray):
|
|
811
|
+
# Scalar result - broadcast to array
|
|
812
|
+
result = np.full(ref_len, result, dtype=np.float64)
|
|
813
|
+
|
|
814
|
+
new_metadata = TraceMetadata(
|
|
815
|
+
sample_rate=sample_rate,
|
|
816
|
+
vertical_scale=None,
|
|
817
|
+
vertical_offset=None,
|
|
818
|
+
acquisition_time=ref_trace.metadata.acquisition_time,
|
|
819
|
+
trigger_info=ref_trace.metadata.trigger_info,
|
|
820
|
+
source_file=ref_trace.metadata.source_file,
|
|
821
|
+
channel_name=channel_name or f"expr({expression[:20]})",
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
return WaveformTrace(data=result.astype(np.float64), metadata=new_metadata)
|