oscura 0.8.0__py3-none-any.whl → 0.10.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 (151) hide show
  1. oscura/__init__.py +19 -19
  2. oscura/analyzers/__init__.py +2 -0
  3. oscura/analyzers/digital/extraction.py +2 -3
  4. oscura/analyzers/digital/quality.py +1 -1
  5. oscura/analyzers/digital/timing.py +1 -1
  6. oscura/analyzers/patterns/__init__.py +66 -0
  7. oscura/analyzers/power/basic.py +3 -3
  8. oscura/analyzers/power/soa.py +1 -1
  9. oscura/analyzers/power/switching.py +3 -3
  10. oscura/analyzers/signal_classification.py +529 -0
  11. oscura/analyzers/signal_integrity/sparams.py +3 -3
  12. oscura/analyzers/statistics/basic.py +10 -7
  13. oscura/analyzers/validation.py +1 -1
  14. oscura/analyzers/waveform/measurements.py +200 -156
  15. oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
  16. oscura/analyzers/waveform/spectral.py +164 -73
  17. oscura/api/dsl/commands.py +15 -6
  18. oscura/api/server/templates/base.html +137 -146
  19. oscura/api/server/templates/export.html +84 -110
  20. oscura/api/server/templates/home.html +248 -267
  21. oscura/api/server/templates/protocols.html +44 -48
  22. oscura/api/server/templates/reports.html +27 -35
  23. oscura/api/server/templates/session_detail.html +68 -78
  24. oscura/api/server/templates/sessions.html +62 -72
  25. oscura/api/server/templates/waveforms.html +54 -64
  26. oscura/automotive/__init__.py +1 -1
  27. oscura/automotive/can/session.py +1 -1
  28. oscura/automotive/dbc/generator.py +638 -23
  29. oscura/automotive/uds/decoder.py +99 -6
  30. oscura/cli/analyze.py +8 -2
  31. oscura/cli/batch.py +36 -5
  32. oscura/cli/characterize.py +18 -4
  33. oscura/cli/export.py +47 -5
  34. oscura/cli/main.py +2 -0
  35. oscura/cli/onboarding/wizard.py +10 -6
  36. oscura/cli/pipeline.py +585 -0
  37. oscura/cli/visualize.py +6 -4
  38. oscura/convenience.py +400 -32
  39. oscura/core/measurement_result.py +286 -0
  40. oscura/core/progress.py +1 -1
  41. oscura/core/types.py +232 -239
  42. oscura/correlation/multi_protocol.py +1 -1
  43. oscura/export/legacy/__init__.py +11 -0
  44. oscura/export/legacy/wav.py +75 -0
  45. oscura/exporters/__init__.py +19 -0
  46. oscura/exporters/wireshark.py +809 -0
  47. oscura/hardware/acquisition/file.py +5 -19
  48. oscura/hardware/acquisition/saleae.py +10 -10
  49. oscura/hardware/acquisition/socketcan.py +4 -6
  50. oscura/hardware/acquisition/synthetic.py +1 -5
  51. oscura/hardware/acquisition/visa.py +6 -6
  52. oscura/hardware/security/side_channel_detector.py +5 -508
  53. oscura/inference/message_format.py +686 -1
  54. oscura/jupyter/display.py +2 -2
  55. oscura/jupyter/magic.py +3 -3
  56. oscura/loaders/__init__.py +17 -12
  57. oscura/loaders/binary.py +1 -1
  58. oscura/loaders/chipwhisperer.py +1 -2
  59. oscura/loaders/configurable.py +1 -1
  60. oscura/loaders/csv_loader.py +2 -2
  61. oscura/loaders/hdf5_loader.py +1 -1
  62. oscura/loaders/lazy.py +6 -1
  63. oscura/loaders/mmap_loader.py +0 -1
  64. oscura/loaders/numpy_loader.py +8 -7
  65. oscura/loaders/preprocessing.py +3 -5
  66. oscura/loaders/rigol.py +21 -7
  67. oscura/loaders/sigrok.py +2 -5
  68. oscura/loaders/tdms.py +3 -2
  69. oscura/loaders/tektronix.py +38 -32
  70. oscura/loaders/tss.py +20 -27
  71. oscura/loaders/vcd.py +13 -8
  72. oscura/loaders/wav.py +1 -6
  73. oscura/pipeline/__init__.py +76 -0
  74. oscura/pipeline/handlers/__init__.py +165 -0
  75. oscura/pipeline/handlers/analyzers.py +1045 -0
  76. oscura/pipeline/handlers/decoders.py +899 -0
  77. oscura/pipeline/handlers/exporters.py +1103 -0
  78. oscura/pipeline/handlers/filters.py +891 -0
  79. oscura/pipeline/handlers/loaders.py +640 -0
  80. oscura/pipeline/handlers/transforms.py +768 -0
  81. oscura/reporting/formatting/measurements.py +55 -14
  82. oscura/reporting/templates/enhanced/protocol_re.html +504 -503
  83. oscura/side_channel/__init__.py +38 -57
  84. oscura/utils/builders/signal_builder.py +5 -5
  85. oscura/utils/comparison/compare.py +7 -9
  86. oscura/utils/comparison/golden.py +1 -1
  87. oscura/utils/filtering/convenience.py +2 -2
  88. oscura/utils/math/arithmetic.py +38 -62
  89. oscura/utils/math/interpolation.py +20 -20
  90. oscura/utils/pipeline/__init__.py +4 -17
  91. oscura/utils/progressive.py +1 -4
  92. oscura/utils/triggering/edge.py +1 -1
  93. oscura/utils/triggering/pattern.py +2 -2
  94. oscura/utils/triggering/pulse.py +2 -2
  95. oscura/utils/triggering/window.py +3 -3
  96. oscura/validation/hil_testing.py +11 -11
  97. oscura/visualization/__init__.py +46 -284
  98. oscura/visualization/batch.py +72 -433
  99. oscura/visualization/plot.py +542 -53
  100. oscura/visualization/styles.py +184 -318
  101. oscura/workflows/batch/advanced.py +1 -1
  102. oscura/workflows/batch/aggregate.py +7 -8
  103. oscura/workflows/complete_re.py +251 -23
  104. oscura/workflows/digital.py +27 -4
  105. oscura/workflows/multi_trace.py +136 -17
  106. oscura/workflows/waveform.py +11 -6
  107. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/METADATA +59 -79
  108. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/RECORD +111 -136
  109. oscura/side_channel/dpa.py +0 -1025
  110. oscura/utils/optimization/__init__.py +0 -19
  111. oscura/utils/optimization/parallel.py +0 -443
  112. oscura/utils/optimization/search.py +0 -532
  113. oscura/utils/pipeline/base.py +0 -338
  114. oscura/utils/pipeline/composition.py +0 -248
  115. oscura/utils/pipeline/parallel.py +0 -449
  116. oscura/utils/pipeline/pipeline.py +0 -375
  117. oscura/utils/search/__init__.py +0 -16
  118. oscura/utils/search/anomaly.py +0 -424
  119. oscura/utils/search/context.py +0 -294
  120. oscura/utils/search/pattern.py +0 -288
  121. oscura/utils/storage/__init__.py +0 -61
  122. oscura/utils/storage/database.py +0 -1166
  123. oscura/visualization/accessibility.py +0 -526
  124. oscura/visualization/annotations.py +0 -371
  125. oscura/visualization/axis_scaling.py +0 -305
  126. oscura/visualization/colors.py +0 -451
  127. oscura/visualization/digital.py +0 -436
  128. oscura/visualization/eye.py +0 -571
  129. oscura/visualization/histogram.py +0 -281
  130. oscura/visualization/interactive.py +0 -1035
  131. oscura/visualization/jitter.py +0 -1042
  132. oscura/visualization/keyboard.py +0 -394
  133. oscura/visualization/layout.py +0 -400
  134. oscura/visualization/optimization.py +0 -1079
  135. oscura/visualization/palettes.py +0 -446
  136. oscura/visualization/power.py +0 -508
  137. oscura/visualization/power_extended.py +0 -955
  138. oscura/visualization/presets.py +0 -469
  139. oscura/visualization/protocols.py +0 -1246
  140. oscura/visualization/render.py +0 -223
  141. oscura/visualization/rendering.py +0 -444
  142. oscura/visualization/reverse_engineering.py +0 -838
  143. oscura/visualization/signal_integrity.py +0 -989
  144. oscura/visualization/specialized.py +0 -643
  145. oscura/visualization/spectral.py +0 -1226
  146. oscura/visualization/thumbnails.py +0 -340
  147. oscura/visualization/time_axis.py +0 -351
  148. oscura/visualization/waveform.py +0 -454
  149. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/WHEEL +0 -0
  150. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/entry_points.txt +0 -0
  151. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -5,6 +5,7 @@ This module implements decoding for UDS diagnostic messages used in automotive E
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ from dataclasses import dataclass
8
9
  from typing import TYPE_CHECKING
9
10
 
10
11
  if TYPE_CHECKING:
@@ -12,7 +13,26 @@ if TYPE_CHECKING:
12
13
 
13
14
  from oscura.automotive.uds.models import UDSNegativeResponse, UDSService
14
15
 
15
- __all__ = ["UDSDecoder"]
16
+ __all__ = ["UDSDecoder", "UDSResponse"]
17
+
18
+
19
+ @dataclass
20
+ class UDSResponse:
21
+ """A decoded UDS response message.
22
+
23
+ Attributes:
24
+ service: Service ID.
25
+ data: Response data payload.
26
+ timestamp: Message timestamp.
27
+ is_negative: True if negative response (NRC).
28
+ nrc: Negative Response Code (if is_negative=True).
29
+ """
30
+
31
+ service: int
32
+ data: bytes
33
+ timestamp: float
34
+ is_negative: bool = False
35
+ nrc: int | None = None
16
36
 
17
37
 
18
38
  # Service ID mappings per ISO 14229-1
@@ -146,6 +166,58 @@ class UDSDecoder:
146
166
 
147
167
  return False
148
168
 
169
+ @staticmethod
170
+ def decode(message: CANMessage) -> UDSResponse | None:
171
+ """Decode UDS message from CAN message (returns UDSResponse).
172
+
173
+ Args:
174
+ message: CAN message to decode.
175
+
176
+ Returns:
177
+ UDSResponse with decoded information, or None if not a valid UDS message.
178
+
179
+ Example:
180
+ >>> msg = CANMessage(id=0x7E0, timestamp=1.0, data=bytes([0x02, 0x10, 0x01]))
181
+ >>> response = UDSDecoder.decode(msg)
182
+ >>> print(f"Service: 0x{response.service:02X}")
183
+ """
184
+ if len(message.data) < 2:
185
+ return None
186
+
187
+ # Extract UDS payload from ISO-TP frame if needed
188
+ data = UDSDecoder._extract_uds_payload(message.data)
189
+ if not data:
190
+ return None
191
+
192
+ # Check for negative response
193
+ if data[0] == 0x7F:
194
+ neg_resp = UDSDecoder._decode_negative_response(data)
195
+ if neg_resp is None:
196
+ return None
197
+ return UDSResponse(
198
+ service=0x7F,
199
+ data=data,
200
+ timestamp=message.timestamp,
201
+ is_negative=True,
202
+ nrc=neg_resp.nrc,
203
+ )
204
+
205
+ # Determine SID and request/response type
206
+ sid_info = UDSDecoder._parse_sid_byte(data[0])
207
+ if sid_info is None:
208
+ return None
209
+
210
+ sid, canonical_sid, is_request = sid_info
211
+
212
+ # Return UDSResponse
213
+ return UDSResponse(
214
+ service=sid,
215
+ data=data,
216
+ timestamp=message.timestamp,
217
+ is_negative=False,
218
+ nrc=None,
219
+ )
220
+
149
221
  @staticmethod
150
222
  def decode_service(message: CANMessage) -> UDSService | UDSNegativeResponse | None:
151
223
  """Decode UDS service from CAN message.
@@ -198,7 +270,11 @@ class UDSDecoder:
198
270
  def _extract_uds_payload(message_data: bytes) -> bytes:
199
271
  """Extract UDS payload from CAN message data.
200
272
 
201
- Handles ISO-TP single frame format (first byte ≤0x07 indicates length).
273
+ Handles ISO-TP frame formats:
274
+ - Single frame: 0x0X (X = length)
275
+ - First frame: 0x1X (X = upper nibble of length)
276
+ - Consecutive frame: 0x2X (X = sequence number)
277
+ - Flow control: 0x3X
202
278
 
203
279
  Args:
204
280
  message_data: Raw CAN message data.
@@ -206,14 +282,31 @@ class UDSDecoder:
206
282
  Returns:
207
283
  UDS payload bytes (empty if invalid).
208
284
  """
209
- if message_data[0] <= 0x07:
210
- # ISO-TP single frame: [length, ...UDS data...]
211
- uds_length = message_data[0]
285
+ pci = message_data[0]
286
+ pci_type = (pci >> 4) & 0x0F
287
+
288
+ if pci_type == 0:
289
+ # ISO-TP single frame: [0x0X, ...UDS data...]
290
+ uds_length = pci & 0x0F
291
+ if len(message_data) < 1 + uds_length:
292
+ return b""
293
+ return message_data[1 : 1 + uds_length]
294
+ elif pci_type == 1:
295
+ # ISO-TP first frame: [0x1X, length_low, ...UDS data...]
296
+ # Skip PCI bytes (2 bytes) and return remaining as UDS payload
297
+ return message_data[2:]
298
+ elif pci_type == 2:
299
+ # ISO-TP consecutive frame: [0x2X, ...data...]
300
+ # Skip PCI byte and return data (but this is continuation, not standalone UDS)
301
+ return message_data[1:]
302
+ elif pci <= 0x07:
303
+ # Legacy single frame format (for compatibility)
304
+ uds_length = pci
212
305
  if len(message_data) < 1 + uds_length:
213
306
  return b""
214
307
  return message_data[1 : 1 + uds_length]
215
308
  else:
216
- # Direct UDS: all bytes are UDS data
309
+ # Direct UDS or unknown format: treat all bytes as UDS data
217
310
  return message_data
218
311
 
219
312
  @staticmethod
oscura/cli/analyze.py CHANGED
@@ -222,14 +222,20 @@ def _characterize_signal(trace: Any) -> dict[str, Any]:
222
222
 
223
223
  rt = rise_time(trace)
224
224
  ft = fall_time(trace)
225
+ rt_val = rt["value"] if rt["applicable"] else None
226
+ ft_val = ft["value"] if ft["applicable"] else None
225
227
 
226
228
  return {
227
229
  "sample_rate": f"{sample_rate / 1e6:.1f} MHz",
228
230
  "samples": len(data),
229
231
  "duration": f"{len(data) / sample_rate * 1e3:.3f} ms",
230
232
  "amplitude": f"{float(data.max() - data.min()):.3f} V",
231
- "rise_time": f"{rt * 1e9:.2f} ns" if not np.isnan(rt) else "N/A",
232
- "fall_time": f"{ft * 1e9:.2f} ns" if not np.isnan(ft) else "N/A",
233
+ "rise_time": f"{rt_val * 1e9:.2f} ns"
234
+ if rt_val is not None and not np.isnan(rt_val)
235
+ else "N/A",
236
+ "fall_time": f"{ft_val * 1e9:.2f} ns"
237
+ if ft_val is not None and not np.isnan(ft_val)
238
+ else "N/A",
233
239
  }
234
240
 
235
241
 
oscura/cli/batch.py CHANGED
@@ -169,17 +169,37 @@ def _analyze_single_file(file_path: str, analysis_type: str) -> dict[str, Any]:
169
169
  "file": str(Path(file_path).name),
170
170
  "status": "success",
171
171
  "analysis_type": analysis_type,
172
- "samples": len(trace.data), # type: ignore[union-attr]
172
+ "samples": len(trace.data),
173
173
  "sample_rate": f"{sample_rate / 1e6:.1f} MHz",
174
174
  }
175
175
 
176
176
  if analysis_type == "characterize":
177
177
  rt = rise_time(trace) # type: ignore[arg-type]
178
178
  ft = fall_time(trace) # type: ignore[arg-type]
179
+
180
+ # Handle both MeasurementResult dict and raw float/nan values (for test compatibility)
181
+ if isinstance(rt, dict):
182
+ rt_val = rt["value"] if rt.get("applicable", True) else None
183
+ elif isinstance(rt, (int, float)): # type: ignore[unreachable]
184
+ rt_val = rt
185
+ else:
186
+ rt_val = None
187
+
188
+ if isinstance(ft, dict):
189
+ ft_val = ft["value"] if ft.get("applicable", True) else None
190
+ elif isinstance(ft, (int, float)): # type: ignore[unreachable]
191
+ ft_val = ft
192
+ else:
193
+ ft_val = None
194
+
179
195
  result.update(
180
196
  {
181
- "rise_time": f"{rt * 1e9:.2f} ns" if not np.isnan(rt) else "N/A",
182
- "fall_time": f"{ft * 1e9:.2f} ns" if not np.isnan(ft) else "N/A",
197
+ "rise_time": f"{rt_val * 1e9:.2f} ns"
198
+ if rt_val is not None and not np.isnan(rt_val)
199
+ else "N/A",
200
+ "fall_time": f"{ft_val * 1e9:.2f} ns"
201
+ if ft_val is not None and not np.isnan(ft_val)
202
+ else "N/A",
183
203
  }
184
204
  )
185
205
  elif analysis_type == "decode":
@@ -197,11 +217,22 @@ def _analyze_single_file(file_path: str, analysis_type: str) -> dict[str, Any]:
197
217
  peak_freq = freqs[peak_idx]
198
218
  else:
199
219
  peak_freq = 0.0
200
- thd_val = thd(trace) # type: ignore[arg-type]
220
+ thd_result = thd(trace) # type: ignore[arg-type]
221
+
222
+ # Handle both MeasurementResult dict and raw float/nan values (for test compatibility)
223
+ if isinstance(thd_result, dict):
224
+ thd_val = thd_result["value"] if thd_result.get("applicable", True) else None
225
+ elif isinstance(thd_result, (int, float)): # type: ignore[unreachable]
226
+ thd_val = thd_result
227
+ else:
228
+ thd_val = None
229
+
201
230
  result.update(
202
231
  {
203
232
  "peak_frequency": f"{peak_freq / 1e6:.3f} MHz",
204
- "thd": f"{thd_val:.1f} dB" if not np.isnan(thd_val) else "N/A",
233
+ "thd": f"{thd_val:.1f} dB"
234
+ if thd_val is not None and not np.isnan(thd_val)
235
+ else "N/A",
205
236
  }
206
237
  )
207
238
 
@@ -243,12 +243,26 @@ def _add_buffer_results(results: dict[str, Any], trace: Any, logic_family: str)
243
243
  os_pct = overshoot(trace)
244
244
  us_pct = undershoot(trace)
245
245
 
246
+ # Extract values from MeasurementResult dicts
247
+ rt_val = rt["value"] if isinstance(rt, dict) and rt.get("applicable") else None
248
+ ft_val = ft["value"] if isinstance(ft, dict) and ft.get("applicable") else None
249
+ os_val = os_pct["value"] if isinstance(os_pct, dict) and os_pct.get("applicable") else None
250
+ us_val = us_pct["value"] if isinstance(us_pct, dict) and us_pct.get("applicable") else None
251
+
246
252
  results.update(
247
253
  {
248
- "rise_time": f"{rt * 1e9:.2f} ns" if not np.isnan(rt) else "N/A",
249
- "fall_time": f"{ft * 1e9:.2f} ns" if not np.isnan(ft) else "N/A",
250
- "overshoot": f"{os_pct:.1f} %" if not np.isnan(os_pct) else "N/A",
251
- "undershoot": f"{us_pct:.1f} %" if not np.isnan(us_pct) else "N/A",
254
+ "rise_time": f"{rt_val * 1e9:.2f} ns"
255
+ if rt_val is not None and not np.isnan(rt_val)
256
+ else "N/A",
257
+ "fall_time": f"{ft_val * 1e9:.2f} ns"
258
+ if ft_val is not None and not np.isnan(ft_val)
259
+ else "N/A",
260
+ "overshoot": f"{os_val:.1f} %"
261
+ if os_val is not None and not np.isnan(os_val)
262
+ else "N/A",
263
+ "undershoot": f"{us_val:.1f} %"
264
+ if us_val is not None and not np.isnan(us_val)
265
+ else "N/A",
252
266
  "status": "PASS",
253
267
  }
254
268
  )
oscura/cli/export.py CHANGED
@@ -195,14 +195,56 @@ def _export_csv(session: Any, output_path: Path) -> None:
195
195
 
196
196
 
197
197
  def _export_matlab(session: Any, output_path: Path) -> None:
198
- """Export to MATLAB format.
198
+ """Export to MATLAB .mat format.
199
+
200
+ Exports session data and traces to MATLAB-compatible .mat file using scipy.
199
201
 
200
202
  Args:
201
- session: Session object.
202
- output_path: Output file path.
203
+ session: Session object with traces attribute.
204
+ output_path: Output file path (.mat extension).
205
+
206
+ Raises:
207
+ ImportError: If scipy is not installed.
203
208
  """
204
- # MATLAB export has been redesigned - use new AnalysisSession API
205
- raise NotImplementedError("MATLAB export needs reimplementation with new API")
209
+ try:
210
+ from scipy.io import savemat
211
+ except ImportError as e:
212
+ raise ImportError(
213
+ "scipy is required for MATLAB export. Install with: pip install scipy"
214
+ ) from e
215
+
216
+ import numpy as np
217
+
218
+ # Collect data to export
219
+ matlab_data: dict[str, Any] = {}
220
+
221
+ # Export traces if available
222
+ if hasattr(session, "traces") and session.traces:
223
+ for i, trace in enumerate(session.traces):
224
+ trace_name = f"trace_{i + 1}"
225
+ if hasattr(trace, "data"):
226
+ matlab_data[f"{trace_name}_data"] = np.asarray(trace.data)
227
+ if hasattr(trace, "metadata") and hasattr(trace.metadata, "sample_rate"):
228
+ matlab_data[f"{trace_name}_sample_rate"] = trace.metadata.sample_rate
229
+
230
+ # Export results if available
231
+ if hasattr(session, "results") and session.results:
232
+ if isinstance(session.results, dict):
233
+ for key, value in session.results.items():
234
+ # Convert to numpy-compatible format
235
+ if isinstance(value, (int, float, bool)):
236
+ matlab_data[key] = value
237
+ elif isinstance(value, (list, tuple)):
238
+ matlab_data[key] = np.array(value)
239
+ elif isinstance(value, np.ndarray):
240
+ matlab_data[key] = value
241
+
242
+ if not matlab_data:
243
+ matlab_data["message"] = "No data available for export"
244
+
245
+ # Save to .mat file
246
+ savemat(str(output_path), matlab_data)
247
+ click.echo(f"✓ Exported to MATLAB: {output_path}")
206
248
 
207
249
 
208
250
  def _export_wireshark(session: Any, output_path: Path) -> None:
oscura/cli/main.py CHANGED
@@ -248,6 +248,7 @@ from oscura.cli.compare import compare
248
248
  from oscura.cli.config_cmd import config as config_cmd
249
249
  from oscura.cli.decode import decode
250
250
  from oscura.cli.export import export
251
+ from oscura.cli.pipeline import pipeline
251
252
  from oscura.cli.validate_cmd import validate
252
253
  from oscura.cli.visualize import visualize
253
254
 
@@ -312,6 +313,7 @@ cli.add_command(config_cmd, name="config")
312
313
  cli.add_command(characterize) # type: ignore[has-type]
313
314
  cli.add_command(batch) # type: ignore[has-type]
314
315
  cli.add_command(compare) # type: ignore[has-type]
316
+ cli.add_command(pipeline) # type: ignore[has-type]
315
317
  cli.add_command(shell)
316
318
  cli.add_command(tutorial)
317
319
 
@@ -224,8 +224,8 @@ class AnalysisWizard:
224
224
  else:
225
225
  print(f" Sample rate: {rate / 1e3:.3f} kSa/s")
226
226
 
227
- if hasattr(meta, "channel_name") and meta.channel_name:
228
- print(f" Channel: {meta.channel_name}")
227
+ if hasattr(meta, "channel") and meta.channel:
228
+ print(f" Channel: {meta.channel}")
229
229
 
230
230
  if hasattr(trace, "data"):
231
231
  import numpy as np
@@ -375,13 +375,17 @@ class AnalysisWizard:
375
375
  print(f" SNR: {snr_val:.1f} dB")
376
376
 
377
377
  # Recommendations based on quality
378
- if thd_val > -40:
378
+ # Extract values from MeasurementResult dicts
379
+ thd_value = thd_val["value"] if isinstance(thd_val, dict) else thd_val
380
+ snr_value = snr_val["value"] if isinstance(snr_val, dict) else snr_val
381
+
382
+ if thd_value is not None and thd_value > -40:
379
383
  self.result.recommendations.append(
380
- f"THD is {thd_val:.1f} dB - consider filtering to reduce distortion"
384
+ f"THD is {thd_value:.1f} dB - consider filtering to reduce distortion"
381
385
  )
382
- if snr_val < 40:
386
+ if snr_value is not None and snr_value < 40:
383
387
  self.result.recommendations.append(
384
- f"SNR is {snr_val:.1f} dB - signal is noisy, try averaging or filtering"
388
+ f"SNR is {snr_value:.1f} dB - signal is noisy, try averaging or filtering"
385
389
  )
386
390
 
387
391
  if choice in (2, 3): # Anomalies