oscura 0.1.2__py3-none-any.whl → 0.4.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 +1 -7
- oscura/acquisition/__init__.py +147 -0
- oscura/acquisition/file.py +255 -0
- oscura/acquisition/hardware.py +186 -0
- oscura/acquisition/saleae.py +340 -0
- oscura/acquisition/socketcan.py +315 -0
- oscura/acquisition/streaming.py +38 -0
- oscura/acquisition/synthetic.py +229 -0
- oscura/acquisition/visa.py +376 -0
- oscura/analyzers/__init__.py +3 -0
- oscura/analyzers/digital/clock.py +9 -1
- oscura/analyzers/digital/edges.py +1 -1
- oscura/analyzers/digital/timing.py +41 -11
- oscura/analyzers/packet/payload_extraction.py +2 -4
- oscura/analyzers/packet/stream.py +5 -5
- oscura/analyzers/patterns/__init__.py +4 -3
- oscura/analyzers/patterns/clustering.py +3 -1
- oscura/analyzers/power/ac_power.py +0 -2
- oscura/analyzers/power/basic.py +0 -2
- oscura/analyzers/power/ripple.py +0 -2
- oscura/analyzers/side_channel/__init__.py +52 -0
- oscura/analyzers/side_channel/power.py +690 -0
- oscura/analyzers/side_channel/timing.py +369 -0
- oscura/analyzers/signal_integrity/embedding.py +0 -2
- oscura/analyzers/signal_integrity/sparams.py +28 -206
- oscura/analyzers/spectral/fft.py +0 -2
- oscura/analyzers/statistical/__init__.py +3 -3
- oscura/analyzers/statistical/checksum.py +2 -0
- oscura/analyzers/statistical/classification.py +2 -0
- oscura/analyzers/statistical/entropy.py +11 -9
- oscura/analyzers/statistical/ngrams.py +4 -2
- oscura/api/fluent.py +2 -2
- oscura/automotive/__init__.py +4 -4
- oscura/automotive/can/__init__.py +0 -2
- oscura/automotive/can/patterns.py +3 -1
- oscura/automotive/can/session.py +277 -78
- oscura/automotive/can/state_machine.py +5 -2
- oscura/automotive/dbc/__init__.py +0 -2
- oscura/automotive/dtc/__init__.py +0 -2
- oscura/automotive/dtc/data.json +2763 -0
- oscura/automotive/dtc/database.py +37 -2769
- oscura/automotive/j1939/__init__.py +0 -2
- oscura/automotive/loaders/__init__.py +0 -2
- oscura/automotive/loaders/asc.py +0 -2
- oscura/automotive/loaders/blf.py +0 -2
- oscura/automotive/loaders/csv_can.py +0 -2
- oscura/automotive/obd/__init__.py +0 -2
- oscura/automotive/uds/__init__.py +0 -2
- oscura/automotive/uds/models.py +0 -2
- oscura/builders/__init__.py +9 -11
- oscura/builders/signal_builder.py +99 -191
- oscura/cli/main.py +0 -2
- oscura/cli/shell.py +0 -2
- oscura/config/loader.py +0 -2
- oscura/core/backend_selector.py +1 -1
- oscura/core/correlation.py +0 -2
- oscura/core/exceptions.py +61 -3
- oscura/core/lazy.py +5 -3
- oscura/core/memory_limits.py +0 -2
- oscura/core/numba_backend.py +5 -7
- oscura/core/uncertainty.py +3 -3
- oscura/dsl/interpreter.py +2 -0
- oscura/dsl/parser.py +8 -6
- oscura/exploratory/error_recovery.py +3 -3
- oscura/exploratory/parse.py +2 -0
- oscura/exploratory/recovery.py +2 -0
- oscura/exploratory/sync.py +2 -0
- oscura/export/wireshark/generator.py +1 -1
- oscura/export/wireshark/type_mapping.py +2 -0
- oscura/exporters/hdf5.py +1 -3
- oscura/extensibility/templates.py +0 -8
- oscura/inference/active_learning/lstar.py +2 -4
- oscura/inference/active_learning/observation_table.py +0 -2
- oscura/inference/active_learning/oracle.py +3 -1
- oscura/inference/active_learning/teachers/simulator.py +1 -3
- oscura/inference/alignment.py +2 -0
- oscura/inference/message_format.py +2 -0
- oscura/inference/protocol_dsl.py +7 -5
- oscura/inference/sequences.py +12 -14
- oscura/inference/state_machine.py +2 -0
- oscura/integrations/llm.py +3 -1
- oscura/jupyter/display.py +0 -2
- oscura/loaders/__init__.py +68 -51
- oscura/loaders/chipwhisperer.py +393 -0
- oscura/loaders/pcap.py +1 -1
- oscura/loaders/touchstone.py +221 -0
- oscura/math/arithmetic.py +0 -2
- oscura/optimization/parallel.py +9 -6
- oscura/pipeline/composition.py +0 -2
- oscura/plugins/cli.py +0 -2
- oscura/reporting/comparison.py +0 -2
- oscura/reporting/config.py +1 -1
- oscura/reporting/formatting/emphasis.py +2 -0
- oscura/reporting/formatting/numbers.py +0 -2
- oscura/reporting/output.py +1 -3
- oscura/reporting/sections.py +0 -2
- oscura/search/anomaly.py +2 -0
- oscura/session/session.py +91 -16
- oscura/sessions/__init__.py +70 -0
- oscura/sessions/base.py +323 -0
- oscura/sessions/blackbox.py +640 -0
- oscura/sessions/generic.py +189 -0
- oscura/testing/synthetic.py +2 -0
- oscura/ui/formatters.py +4 -2
- oscura/utils/buffer.py +2 -2
- oscura/utils/lazy.py +5 -5
- oscura/utils/memory_advanced.py +2 -2
- oscura/utils/memory_extensions.py +2 -2
- oscura/visualization/colors.py +0 -2
- oscura/visualization/power.py +2 -0
- oscura/workflows/multi_trace.py +2 -0
- {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/METADATA +122 -20
- {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/RECORD +116 -98
- {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/WHEEL +0 -0
- {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/entry_points.txt +0 -0
- {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/licenses/LICENSE +0 -0
oscura/automotive/loaders/asc.py
CHANGED
oscura/automotive/loaders/blf.py
CHANGED
oscura/automotive/uds/models.py
CHANGED
oscura/builders/__init__.py
CHANGED
|
@@ -7,7 +7,7 @@ generation without manual numpy operations.
|
|
|
7
7
|
Example:
|
|
8
8
|
>>> import oscura as osc
|
|
9
9
|
>>> # Simple sine wave with noise
|
|
10
|
-
>>>
|
|
10
|
+
>>> trace = (osc.SignalBuilder(sample_rate=1e6, duration=0.01)
|
|
11
11
|
... .add_sine(frequency=1000, amplitude=1.0)
|
|
12
12
|
... .add_noise(snr_db=40)
|
|
13
13
|
... .build())
|
|
@@ -19,23 +19,21 @@ Example:
|
|
|
19
19
|
... .build())
|
|
20
20
|
>>>
|
|
21
21
|
>>> # Multi-channel SPI transaction
|
|
22
|
-
>>>
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
>>> builder = osc.SignalBuilder(sample_rate=10e6)
|
|
23
|
+
>>> builder.add_spi(clock_freq=1e6, data_mosi=b"\\x9F\\x00\\x00")
|
|
24
|
+
>>> channels = builder.build_channels() # Returns dict[str, WaveformTrace]
|
|
25
|
+
|
|
26
|
+
API:
|
|
27
|
+
- SignalBuilder.build() returns WaveformTrace for single-channel signals
|
|
28
|
+
- SignalBuilder.build_channels() returns dict[str, WaveformTrace] for multi-channel
|
|
25
29
|
|
|
26
30
|
References:
|
|
27
31
|
- Oscura Signal Generation Guide
|
|
28
32
|
- Protocol Test Signal Specifications
|
|
29
33
|
"""
|
|
30
34
|
|
|
31
|
-
from oscura.builders.signal_builder import
|
|
32
|
-
GeneratedSignal,
|
|
33
|
-
SignalBuilder,
|
|
34
|
-
SignalMetadata,
|
|
35
|
-
)
|
|
35
|
+
from oscura.builders.signal_builder import SignalBuilder
|
|
36
36
|
|
|
37
37
|
__all__ = [
|
|
38
|
-
"GeneratedSignal",
|
|
39
38
|
"SignalBuilder",
|
|
40
|
-
"SignalMetadata",
|
|
41
39
|
]
|
|
@@ -5,7 +5,7 @@ composition of signals for test data generation, demos, and protocol testing.
|
|
|
5
5
|
|
|
6
6
|
Example:
|
|
7
7
|
>>> from oscura import SignalBuilder
|
|
8
|
-
>>>
|
|
8
|
+
>>> trace = (SignalBuilder()
|
|
9
9
|
... .sample_rate(10e6)
|
|
10
10
|
... .duration(0.01)
|
|
11
11
|
... .add_sine(frequency=1000, amplitude=1.0)
|
|
@@ -16,12 +16,11 @@ The builder supports:
|
|
|
16
16
|
- Analog waveforms (sine, square, triangle, chirp, multitone)
|
|
17
17
|
- Protocol signals (UART, SPI, I2C, CAN)
|
|
18
18
|
- Noise and impairments (gaussian, pink, jitter, quantization)
|
|
19
|
-
- Multi-channel signals
|
|
19
|
+
- Multi-channel signals (use build_channels() to get all channels)
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
|
-
from dataclasses import dataclass, field
|
|
25
24
|
from pathlib import Path
|
|
26
25
|
from typing import Any, Literal
|
|
27
26
|
|
|
@@ -31,167 +30,6 @@ from scipy import signal as scipy_signal
|
|
|
31
30
|
from oscura.core.types import TraceMetadata, WaveformTrace
|
|
32
31
|
|
|
33
32
|
|
|
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 Oscura 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
33
|
class SignalBuilder:
|
|
196
34
|
"""Fluent builder for composable signal generation.
|
|
197
35
|
|
|
@@ -200,7 +38,7 @@ class SignalBuilder:
|
|
|
200
38
|
|
|
201
39
|
Example:
|
|
202
40
|
>>> # Simple sine wave with noise
|
|
203
|
-
>>>
|
|
41
|
+
>>> trace = (SignalBuilder()
|
|
204
42
|
... .sample_rate(1e6)
|
|
205
43
|
... .duration(0.01)
|
|
206
44
|
... .add_sine(frequency=1000, amplitude=1.0)
|
|
@@ -208,7 +46,7 @@ class SignalBuilder:
|
|
|
208
46
|
... .build())
|
|
209
47
|
>>>
|
|
210
48
|
>>> # UART signal with realistic characteristics
|
|
211
|
-
>>>
|
|
49
|
+
>>> uart_trace = (SignalBuilder()
|
|
212
50
|
... .sample_rate(10e6)
|
|
213
51
|
... .add_uart(baud_rate=115200, data=b"Hello Oscura!", config="8N1")
|
|
214
52
|
... .add_noise(snr_db=30)
|
|
@@ -1050,14 +888,34 @@ class SignalBuilder:
|
|
|
1050
888
|
|
|
1051
889
|
# ========== Build Methods ==========
|
|
1052
890
|
|
|
1053
|
-
def build(self) ->
|
|
1054
|
-
"""Build and return signal.
|
|
891
|
+
def build(self, channel: str | None = None) -> WaveformTrace:
|
|
892
|
+
"""Build and return signal as WaveformTrace.
|
|
893
|
+
|
|
894
|
+
This is the primary build method that returns a WaveformTrace ready
|
|
895
|
+
for analysis with Oscura. For multi-channel signals, specify which
|
|
896
|
+
channel to return, or use build_channels() to get all channels.
|
|
897
|
+
|
|
898
|
+
Args:
|
|
899
|
+
channel: Channel name to build. If None, uses first channel.
|
|
900
|
+
For single-channel signals, this parameter is optional.
|
|
1055
901
|
|
|
1056
902
|
Returns:
|
|
1057
|
-
|
|
903
|
+
WaveformTrace ready for Oscura analysis.
|
|
1058
904
|
|
|
1059
905
|
Raises:
|
|
1060
|
-
ValueError: If no signals have been added.
|
|
906
|
+
ValueError: If no signals have been added or channel doesn't exist.
|
|
907
|
+
|
|
908
|
+
Example:
|
|
909
|
+
>>> builder = SignalBuilder(sample_rate=1e6).add_sine(1000)
|
|
910
|
+
>>> trace = builder.build()
|
|
911
|
+
>>> print(f"Generated {len(trace.data)} samples")
|
|
912
|
+
|
|
913
|
+
>>> # Multi-channel
|
|
914
|
+
>>> builder = SignalBuilder(sample_rate=1e6)
|
|
915
|
+
>>> builder.add_sine(1000, channel="sig")
|
|
916
|
+
>>> builder.add_square(500, channel="clk")
|
|
917
|
+
>>> sig_trace = builder.build(channel="sig")
|
|
918
|
+
>>> clk_trace = builder.build(channel="clk")
|
|
1061
919
|
"""
|
|
1062
920
|
if not self._channels:
|
|
1063
921
|
raise ValueError("No signals added. Call add_* methods before build().")
|
|
@@ -1068,44 +926,94 @@ class SignalBuilder:
|
|
|
1068
926
|
if len(signal) < max_len:
|
|
1069
927
|
self._channels[name] = np.pad(signal, (0, max_len - len(signal)), mode="edge")
|
|
1070
928
|
|
|
1071
|
-
#
|
|
1072
|
-
|
|
929
|
+
# Determine which channel to return
|
|
930
|
+
if channel is None:
|
|
931
|
+
# Use first channel
|
|
932
|
+
channel = next(iter(self._channels.keys()))
|
|
933
|
+
elif channel not in self._channels:
|
|
934
|
+
available = list(self._channels.keys())
|
|
935
|
+
raise ValueError(f"Channel '{channel}' not found. Available: {available}")
|
|
936
|
+
|
|
937
|
+
# Get channel data
|
|
938
|
+
data = self._channels[channel]
|
|
1073
939
|
|
|
1074
|
-
|
|
940
|
+
# Build TraceMetadata
|
|
941
|
+
trace_metadata = TraceMetadata(
|
|
1075
942
|
sample_rate=self._sample_rate,
|
|
1076
|
-
|
|
1077
|
-
channel_names=list(self._channels.keys()),
|
|
1078
|
-
description=self._description,
|
|
1079
|
-
generator="SignalBuilder",
|
|
1080
|
-
parameters=self._parameters,
|
|
943
|
+
channel_name=channel,
|
|
1081
944
|
)
|
|
1082
945
|
|
|
1083
|
-
return
|
|
946
|
+
return WaveformTrace(data=data, metadata=trace_metadata)
|
|
1084
947
|
|
|
1085
|
-
def
|
|
1086
|
-
"""Build and return
|
|
948
|
+
def build_channels(self) -> dict[str, WaveformTrace]:
|
|
949
|
+
"""Build and return all channels as dictionary of WaveformTraces.
|
|
1087
950
|
|
|
1088
|
-
|
|
1089
|
-
|
|
951
|
+
Use this method when you need all channels from a multi-channel
|
|
952
|
+
signal generation.
|
|
1090
953
|
|
|
1091
954
|
Returns:
|
|
1092
|
-
|
|
955
|
+
Dictionary mapping channel names to WaveformTrace objects.
|
|
956
|
+
|
|
957
|
+
Raises:
|
|
958
|
+
ValueError: If no signals have been added.
|
|
959
|
+
|
|
960
|
+
Example:
|
|
961
|
+
>>> builder = SignalBuilder(sample_rate=1e6)
|
|
962
|
+
>>> builder.add_sine(1000, channel="sig")
|
|
963
|
+
>>> builder.add_square(500, channel="clk")
|
|
964
|
+
>>> channels = builder.build_channels()
|
|
965
|
+
>>> print(f"Generated {len(channels)} channels")
|
|
966
|
+
>>> sig_trace = channels["sig"]
|
|
967
|
+
>>> clk_trace = channels["clk"]
|
|
1093
968
|
"""
|
|
1094
|
-
|
|
1095
|
-
|
|
969
|
+
if not self._channels:
|
|
970
|
+
raise ValueError("No signals added. Call add_* methods before build().")
|
|
971
|
+
|
|
972
|
+
# Ensure all channels have same length (pad if necessary)
|
|
973
|
+
max_len = max(len(s) for s in self._channels.values())
|
|
974
|
+
for name, signal in self._channels.items():
|
|
975
|
+
if len(signal) < max_len:
|
|
976
|
+
self._channels[name] = np.pad(signal, (0, max_len - len(signal)), mode="edge")
|
|
977
|
+
|
|
978
|
+
# Build WaveformTrace for each channel
|
|
979
|
+
traces: dict[str, WaveformTrace] = {}
|
|
980
|
+
for channel_name, data in self._channels.items():
|
|
981
|
+
trace_metadata = TraceMetadata(
|
|
982
|
+
sample_rate=self._sample_rate,
|
|
983
|
+
channel_name=channel_name,
|
|
984
|
+
)
|
|
985
|
+
traces[channel_name] = WaveformTrace(data=data, metadata=trace_metadata)
|
|
986
|
+
|
|
987
|
+
return traces
|
|
1096
988
|
|
|
1097
|
-
def save_npz(self, path: Path | str) ->
|
|
989
|
+
def save_npz(self, path: Path | str, channel: str | None = None) -> WaveformTrace:
|
|
1098
990
|
"""Build and save signal to NPZ format.
|
|
1099
991
|
|
|
1100
992
|
Args:
|
|
1101
993
|
path: Output file path.
|
|
994
|
+
channel: Channel to save (for multi-channel signals).
|
|
1102
995
|
|
|
1103
996
|
Returns:
|
|
1104
|
-
|
|
997
|
+
WaveformTrace that was saved.
|
|
998
|
+
|
|
999
|
+
Example:
|
|
1000
|
+
>>> builder = SignalBuilder().sample_rate(1e6).add_sine(1000)
|
|
1001
|
+
>>> trace = builder.save_npz("signal.npz")
|
|
1105
1002
|
"""
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1003
|
+
trace = self.build(channel=channel)
|
|
1004
|
+
|
|
1005
|
+
# Save as NPZ using numpy
|
|
1006
|
+
path = Path(path)
|
|
1007
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1008
|
+
|
|
1009
|
+
np.savez_compressed(
|
|
1010
|
+
path,
|
|
1011
|
+
data=trace.data,
|
|
1012
|
+
sample_rate=trace.metadata.sample_rate,
|
|
1013
|
+
channel_name=trace.metadata.channel_name or "ch1",
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
return trace
|
|
1109
1017
|
|
|
1110
1018
|
# ========== Internal Methods ==========
|
|
1111
1019
|
|
oscura/cli/main.py
CHANGED
oscura/cli/shell.py
CHANGED
oscura/config/loader.py
CHANGED
oscura/core/backend_selector.py
CHANGED
oscura/core/correlation.py
CHANGED
oscura/core/exceptions.py
CHANGED
|
@@ -13,8 +13,6 @@ Example:
|
|
|
13
13
|
...
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
-
from __future__ import annotations
|
|
17
|
-
|
|
18
16
|
from typing import Any
|
|
19
17
|
|
|
20
18
|
# Documentation base URL
|
|
@@ -285,6 +283,7 @@ class InsufficientDataError(AnalysisError):
|
|
|
285
283
|
required: int | None = None,
|
|
286
284
|
available: int | None = None,
|
|
287
285
|
analysis_type: str | None = None,
|
|
286
|
+
fix_hint: str | None = None,
|
|
288
287
|
) -> None:
|
|
289
288
|
"""Initialize InsufficientDataError.
|
|
290
289
|
|
|
@@ -293,6 +292,7 @@ class InsufficientDataError(AnalysisError):
|
|
|
293
292
|
required: Minimum number of samples or features required.
|
|
294
293
|
available: Actual number available.
|
|
295
294
|
analysis_type: Type of analysis that failed.
|
|
295
|
+
fix_hint: Optional custom fix suggestion (overrides default).
|
|
296
296
|
"""
|
|
297
297
|
self.required = required
|
|
298
298
|
self.available = available
|
|
@@ -303,7 +303,9 @@ class InsufficientDataError(AnalysisError):
|
|
|
303
303
|
elif required is not None:
|
|
304
304
|
details = f"Minimum required: {required}"
|
|
305
305
|
|
|
306
|
-
|
|
306
|
+
# Use default fix hint if not provided
|
|
307
|
+
if fix_hint is None:
|
|
308
|
+
fix_hint = "Acquire more data or reduce analysis window."
|
|
307
309
|
|
|
308
310
|
super().__init__(
|
|
309
311
|
message,
|
|
@@ -520,6 +522,61 @@ class ExportError(OscuraError):
|
|
|
520
522
|
)
|
|
521
523
|
|
|
522
524
|
|
|
525
|
+
class SecurityError(OscuraError):
|
|
526
|
+
"""Security validation failed.
|
|
527
|
+
|
|
528
|
+
Raised when security checks fail, such as signature verification
|
|
529
|
+
or file integrity validation.
|
|
530
|
+
|
|
531
|
+
Attributes:
|
|
532
|
+
file_path: Path to the file that failed security checks.
|
|
533
|
+
check_type: Type of security check that failed.
|
|
534
|
+
"""
|
|
535
|
+
|
|
536
|
+
docs_path: str = "errors#security"
|
|
537
|
+
|
|
538
|
+
def __init__(
|
|
539
|
+
self,
|
|
540
|
+
message: str,
|
|
541
|
+
*,
|
|
542
|
+
file_path: str | None = None,
|
|
543
|
+
check_type: str | None = None,
|
|
544
|
+
details: str | None = None,
|
|
545
|
+
fix_hint: str | None = None,
|
|
546
|
+
) -> None:
|
|
547
|
+
"""Initialize SecurityError.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
message: Brief description of the error.
|
|
551
|
+
file_path: Path to the file that failed security checks.
|
|
552
|
+
check_type: Type of security check that failed.
|
|
553
|
+
details: Additional context about the error.
|
|
554
|
+
fix_hint: Suggestion for how to fix the error.
|
|
555
|
+
"""
|
|
556
|
+
self.file_path = file_path
|
|
557
|
+
self.check_type = check_type
|
|
558
|
+
|
|
559
|
+
details_parts = []
|
|
560
|
+
if file_path:
|
|
561
|
+
details_parts.append(f"File: {file_path}")
|
|
562
|
+
if check_type:
|
|
563
|
+
details_parts.append(f"Check: {check_type}")
|
|
564
|
+
if details:
|
|
565
|
+
details_parts.append(details)
|
|
566
|
+
|
|
567
|
+
combined_details = ". ".join(details_parts) if details_parts else None
|
|
568
|
+
|
|
569
|
+
if fix_hint is None:
|
|
570
|
+
fix_hint = "Only load files from trusted sources. File may be corrupted or tampered."
|
|
571
|
+
|
|
572
|
+
super().__init__(
|
|
573
|
+
message,
|
|
574
|
+
details=combined_details,
|
|
575
|
+
fix_hint=fix_hint,
|
|
576
|
+
docs_path=self.docs_path,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
|
|
523
580
|
__all__ = [
|
|
524
581
|
"DOCS_BASE_URL",
|
|
525
582
|
"AnalysisError",
|
|
@@ -530,6 +587,7 @@ __all__ = [
|
|
|
530
587
|
"LoaderError",
|
|
531
588
|
"OscuraError",
|
|
532
589
|
"SampleRateError",
|
|
590
|
+
"SecurityError",
|
|
533
591
|
"UnsupportedFormatError",
|
|
534
592
|
"ValidationError",
|
|
535
593
|
]
|
oscura/core/lazy.py
CHANGED
|
@@ -49,7 +49,9 @@ from __future__ import annotations
|
|
|
49
49
|
import functools
|
|
50
50
|
import threading
|
|
51
51
|
from dataclasses import dataclass
|
|
52
|
-
from typing import TYPE_CHECKING, Any
|
|
52
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
53
|
+
|
|
54
|
+
T = TypeVar("T")
|
|
53
55
|
|
|
54
56
|
if TYPE_CHECKING:
|
|
55
57
|
from collections.abc import Callable
|
|
@@ -149,7 +151,7 @@ def reset_lazy_stats() -> None:
|
|
|
149
151
|
_global_stats = LazyComputeStats()
|
|
150
152
|
|
|
151
153
|
|
|
152
|
-
class LazyResult[T]:
|
|
154
|
+
class LazyResult(Generic[T]):
|
|
153
155
|
"""Deferred computation wrapper with thread-safe compute-once semantics.
|
|
154
156
|
|
|
155
157
|
Wraps a computation function that will be called only when the result is
|
|
@@ -557,7 +559,7 @@ class LazyDict(dict[str, Any]):
|
|
|
557
559
|
]
|
|
558
560
|
|
|
559
561
|
|
|
560
|
-
def lazy
|
|
562
|
+
def lazy(fn: Callable[..., T]) -> Callable[..., LazyResult[T]]:
|
|
561
563
|
"""Decorator to make a function return a LazyResult.
|
|
562
564
|
|
|
563
565
|
Wraps a function so it returns a LazyResult instead of computing
|