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.
Files changed (116) hide show
  1. oscura/__init__.py +1 -7
  2. oscura/acquisition/__init__.py +147 -0
  3. oscura/acquisition/file.py +255 -0
  4. oscura/acquisition/hardware.py +186 -0
  5. oscura/acquisition/saleae.py +340 -0
  6. oscura/acquisition/socketcan.py +315 -0
  7. oscura/acquisition/streaming.py +38 -0
  8. oscura/acquisition/synthetic.py +229 -0
  9. oscura/acquisition/visa.py +376 -0
  10. oscura/analyzers/__init__.py +3 -0
  11. oscura/analyzers/digital/clock.py +9 -1
  12. oscura/analyzers/digital/edges.py +1 -1
  13. oscura/analyzers/digital/timing.py +41 -11
  14. oscura/analyzers/packet/payload_extraction.py +2 -4
  15. oscura/analyzers/packet/stream.py +5 -5
  16. oscura/analyzers/patterns/__init__.py +4 -3
  17. oscura/analyzers/patterns/clustering.py +3 -1
  18. oscura/analyzers/power/ac_power.py +0 -2
  19. oscura/analyzers/power/basic.py +0 -2
  20. oscura/analyzers/power/ripple.py +0 -2
  21. oscura/analyzers/side_channel/__init__.py +52 -0
  22. oscura/analyzers/side_channel/power.py +690 -0
  23. oscura/analyzers/side_channel/timing.py +369 -0
  24. oscura/analyzers/signal_integrity/embedding.py +0 -2
  25. oscura/analyzers/signal_integrity/sparams.py +28 -206
  26. oscura/analyzers/spectral/fft.py +0 -2
  27. oscura/analyzers/statistical/__init__.py +3 -3
  28. oscura/analyzers/statistical/checksum.py +2 -0
  29. oscura/analyzers/statistical/classification.py +2 -0
  30. oscura/analyzers/statistical/entropy.py +11 -9
  31. oscura/analyzers/statistical/ngrams.py +4 -2
  32. oscura/api/fluent.py +2 -2
  33. oscura/automotive/__init__.py +4 -4
  34. oscura/automotive/can/__init__.py +0 -2
  35. oscura/automotive/can/patterns.py +3 -1
  36. oscura/automotive/can/session.py +277 -78
  37. oscura/automotive/can/state_machine.py +5 -2
  38. oscura/automotive/dbc/__init__.py +0 -2
  39. oscura/automotive/dtc/__init__.py +0 -2
  40. oscura/automotive/dtc/data.json +2763 -0
  41. oscura/automotive/dtc/database.py +37 -2769
  42. oscura/automotive/j1939/__init__.py +0 -2
  43. oscura/automotive/loaders/__init__.py +0 -2
  44. oscura/automotive/loaders/asc.py +0 -2
  45. oscura/automotive/loaders/blf.py +0 -2
  46. oscura/automotive/loaders/csv_can.py +0 -2
  47. oscura/automotive/obd/__init__.py +0 -2
  48. oscura/automotive/uds/__init__.py +0 -2
  49. oscura/automotive/uds/models.py +0 -2
  50. oscura/builders/__init__.py +9 -11
  51. oscura/builders/signal_builder.py +99 -191
  52. oscura/cli/main.py +0 -2
  53. oscura/cli/shell.py +0 -2
  54. oscura/config/loader.py +0 -2
  55. oscura/core/backend_selector.py +1 -1
  56. oscura/core/correlation.py +0 -2
  57. oscura/core/exceptions.py +61 -3
  58. oscura/core/lazy.py +5 -3
  59. oscura/core/memory_limits.py +0 -2
  60. oscura/core/numba_backend.py +5 -7
  61. oscura/core/uncertainty.py +3 -3
  62. oscura/dsl/interpreter.py +2 -0
  63. oscura/dsl/parser.py +8 -6
  64. oscura/exploratory/error_recovery.py +3 -3
  65. oscura/exploratory/parse.py +2 -0
  66. oscura/exploratory/recovery.py +2 -0
  67. oscura/exploratory/sync.py +2 -0
  68. oscura/export/wireshark/generator.py +1 -1
  69. oscura/export/wireshark/type_mapping.py +2 -0
  70. oscura/exporters/hdf5.py +1 -3
  71. oscura/extensibility/templates.py +0 -8
  72. oscura/inference/active_learning/lstar.py +2 -4
  73. oscura/inference/active_learning/observation_table.py +0 -2
  74. oscura/inference/active_learning/oracle.py +3 -1
  75. oscura/inference/active_learning/teachers/simulator.py +1 -3
  76. oscura/inference/alignment.py +2 -0
  77. oscura/inference/message_format.py +2 -0
  78. oscura/inference/protocol_dsl.py +7 -5
  79. oscura/inference/sequences.py +12 -14
  80. oscura/inference/state_machine.py +2 -0
  81. oscura/integrations/llm.py +3 -1
  82. oscura/jupyter/display.py +0 -2
  83. oscura/loaders/__init__.py +68 -51
  84. oscura/loaders/chipwhisperer.py +393 -0
  85. oscura/loaders/pcap.py +1 -1
  86. oscura/loaders/touchstone.py +221 -0
  87. oscura/math/arithmetic.py +0 -2
  88. oscura/optimization/parallel.py +9 -6
  89. oscura/pipeline/composition.py +0 -2
  90. oscura/plugins/cli.py +0 -2
  91. oscura/reporting/comparison.py +0 -2
  92. oscura/reporting/config.py +1 -1
  93. oscura/reporting/formatting/emphasis.py +2 -0
  94. oscura/reporting/formatting/numbers.py +0 -2
  95. oscura/reporting/output.py +1 -3
  96. oscura/reporting/sections.py +0 -2
  97. oscura/search/anomaly.py +2 -0
  98. oscura/session/session.py +91 -16
  99. oscura/sessions/__init__.py +70 -0
  100. oscura/sessions/base.py +323 -0
  101. oscura/sessions/blackbox.py +640 -0
  102. oscura/sessions/generic.py +189 -0
  103. oscura/testing/synthetic.py +2 -0
  104. oscura/ui/formatters.py +4 -2
  105. oscura/utils/buffer.py +2 -2
  106. oscura/utils/lazy.py +5 -5
  107. oscura/utils/memory_advanced.py +2 -2
  108. oscura/utils/memory_extensions.py +2 -2
  109. oscura/visualization/colors.py +0 -2
  110. oscura/visualization/power.py +2 -0
  111. oscura/workflows/multi_trace.py +2 -0
  112. {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/METADATA +122 -20
  113. {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/RECORD +116 -98
  114. {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/WHEEL +0 -0
  115. {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/entry_points.txt +0 -0
  116. {oscura-0.1.2.dist-info → oscura-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -4,8 +4,6 @@ This module provides J1939 protocol decoding for heavy-duty vehicles
4
4
  (trucks, buses, agriculture, marine).
5
5
  """
6
6
 
7
- from __future__ import annotations
8
-
9
7
  __all__ = ["J1939Decoder", "J1939Message", "extract_pgn"]
10
8
 
11
9
  try:
@@ -8,8 +8,6 @@ This module provides loaders for common automotive logging file formats:
8
8
  - PCAP (Packet Capture - SocketCAN)
9
9
  """
10
10
 
11
- from __future__ import annotations
12
-
13
11
  __all__ = [
14
12
  "detect_format",
15
13
  "load_asc",
@@ -9,8 +9,6 @@ ASC format example:
9
9
  0.010000 1 280 Rx d 8 0A 0B 0C 0D 0E 0F 10 11
10
10
  """
11
11
 
12
- from __future__ import annotations
13
-
14
12
  import re
15
13
  from pathlib import Path
16
14
 
@@ -5,8 +5,6 @@ BLF is a proprietary binary format used by Vector tools (CANoe, CANalyzer, etc.)
5
5
  for logging CAN bus data.
6
6
  """
7
7
 
8
- from __future__ import annotations
9
-
10
8
  from pathlib import Path
11
9
 
12
10
  from oscura.automotive.can.models import CANMessage, CANMessageList
@@ -9,8 +9,6 @@ Common CSV format:
9
9
  0.010000,0x280,0A0B0C0D0E0F1011
10
10
  """
11
11
 
12
- from __future__ import annotations
13
-
14
12
  import csv
15
13
  from pathlib import Path
16
14
 
@@ -4,8 +4,6 @@ This module provides OBD-II (On-Board Diagnostics) protocol decoding
4
4
  for standard vehicle diagnostics.
5
5
  """
6
6
 
7
- from __future__ import annotations
8
-
9
7
  __all__ = ["PID", "OBD2Decoder", "OBD2Response"]
10
8
 
11
9
  try:
@@ -36,8 +36,6 @@ Example:
36
36
  UDSService(0x10 DiagnosticSessionControl [Request], sub=0x01)
37
37
  """
38
38
 
39
- from __future__ import annotations
40
-
41
39
  __all__ = [
42
40
  "UDSDecoder",
43
41
  "UDSNegativeResponse",
@@ -3,8 +3,6 @@
3
3
  This module defines the core data structures for UDS protocol analysis.
4
4
  """
5
5
 
6
- from __future__ import annotations
7
-
8
6
  from dataclasses import dataclass
9
7
 
10
8
  __all__ = [
@@ -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
- >>> signal = (osc.SignalBuilder(sample_rate=1e6, duration=0.01)
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
- >>> spi = (osc.SignalBuilder(sample_rate=10e6)
23
- ... .add_spi(clock_freq=1e6, data_mosi=b"\\x9F\\x00\\x00")
24
- ... .build())
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
- >>> signal = (SignalBuilder()
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
- >>> signal = (SignalBuilder()
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
- >>> uart_signal = (SignalBuilder()
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) -> GeneratedSignal:
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
- GeneratedSignal containing all channels and metadata.
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
- # Calculate actual duration from signal length
1072
- actual_duration = max_len / self._sample_rate
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
- metadata = SignalMetadata(
940
+ # Build TraceMetadata
941
+ trace_metadata = TraceMetadata(
1075
942
  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,
943
+ channel_name=channel,
1081
944
  )
1082
945
 
1083
- return GeneratedSignal(data=self._channels.copy(), metadata=metadata)
946
+ return WaveformTrace(data=data, metadata=trace_metadata)
1084
947
 
1085
- def build_trace(self, channel: str | None = None) -> WaveformTrace:
1086
- """Build and return as WaveformTrace for direct use with Oscura.
948
+ def build_channels(self) -> dict[str, WaveformTrace]:
949
+ """Build and return all channels as dictionary of WaveformTraces.
1087
950
 
1088
- Args:
1089
- channel: Channel to return as trace. If None, uses first channel.
951
+ Use this method when you need all channels from a multi-channel
952
+ signal generation.
1090
953
 
1091
954
  Returns:
1092
- WaveformTrace ready for Oscura analysis.
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
- signal = self.build()
1095
- return signal.to_trace(channel)
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) -> GeneratedSignal:
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
- GeneratedSignal that was saved.
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
- signal = self.build()
1107
- signal.save_npz(path)
1108
- return signal
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
@@ -11,8 +11,6 @@ Example:
11
11
  $ oscura shell # Interactive REPL
12
12
  """
13
13
 
14
- from __future__ import annotations
15
-
16
14
  import json
17
15
  import logging
18
16
  import sys
oscura/cli/shell.py CHANGED
@@ -23,8 +23,6 @@ References:
23
23
  - IPython-style interaction patterns
24
24
  """
25
25
 
26
- from __future__ import annotations
27
-
28
26
  import atexit
29
27
  import code
30
28
  import contextlib
oscura/config/loader.py CHANGED
@@ -9,8 +9,6 @@ Example:
9
9
  >>> config = load_config_file("pipeline.yaml", schema="pipeline")
10
10
  """
11
11
 
12
- from __future__ import annotations
13
-
14
12
  import json
15
13
  from pathlib import Path
16
14
  from typing import Any
@@ -43,7 +43,7 @@ except (ImportError, AttributeError):
43
43
  HAS_GPU = False
44
44
 
45
45
  try:
46
- import numba # type: ignore[import-untyped]
46
+ import numba # type: ignore[import-not-found]
47
47
 
48
48
  HAS_NUMBA = True
49
49
  del numba
@@ -16,8 +16,6 @@ References:
16
16
  - Thread-local and async-safe context management
17
17
  """
18
18
 
19
- from __future__ import annotations
20
-
21
19
  import asyncio
22
20
  import contextvars
23
21
  import functools
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
- fix_hint = "Acquire more data or reduce analysis window."
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[T](fn: Callable[..., T]) -> Callable[..., LazyResult[T]]:
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
@@ -13,8 +13,6 @@ References:
13
13
  See oscura.config.memory for global memory configuration.
14
14
  """
15
15
 
16
- from __future__ import annotations
17
-
18
16
  import warnings
19
17
  from typing import Any
20
18