oscura 0.6.0__py3-none-any.whl → 0.8.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 (38) hide show
  1. oscura/__init__.py +1 -1
  2. oscura/analyzers/eye/__init__.py +5 -1
  3. oscura/analyzers/eye/generation.py +501 -0
  4. oscura/analyzers/jitter/__init__.py +6 -6
  5. oscura/analyzers/jitter/timing.py +419 -0
  6. oscura/analyzers/patterns/__init__.py +28 -0
  7. oscura/analyzers/patterns/reverse_engineering.py +991 -0
  8. oscura/analyzers/power/__init__.py +35 -12
  9. oscura/analyzers/statistics/__init__.py +4 -0
  10. oscura/analyzers/statistics/basic.py +149 -0
  11. oscura/analyzers/statistics/correlation.py +47 -6
  12. oscura/analyzers/waveform/__init__.py +2 -0
  13. oscura/analyzers/waveform/measurements.py +145 -23
  14. oscura/analyzers/waveform/spectral.py +361 -8
  15. oscura/automotive/__init__.py +1 -1
  16. oscura/core/config/loader.py +0 -1
  17. oscura/core/types.py +108 -0
  18. oscura/loaders/__init__.py +12 -4
  19. oscura/loaders/tss.py +456 -0
  20. oscura/reporting/__init__.py +88 -1
  21. oscura/reporting/automation.py +348 -0
  22. oscura/reporting/citations.py +374 -0
  23. oscura/reporting/core.py +54 -0
  24. oscura/reporting/formatting/__init__.py +11 -0
  25. oscura/reporting/formatting/measurements.py +279 -0
  26. oscura/reporting/html.py +57 -0
  27. oscura/reporting/interpretation.py +431 -0
  28. oscura/reporting/summary.py +329 -0
  29. oscura/reporting/visualization.py +542 -0
  30. oscura/visualization/__init__.py +2 -1
  31. oscura/visualization/batch.py +521 -0
  32. oscura/workflows/__init__.py +2 -0
  33. oscura/workflows/waveform.py +783 -0
  34. {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/METADATA +37 -19
  35. {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/RECORD +38 -26
  36. {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/WHEEL +0 -0
  37. {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/entry_points.txt +0 -0
  38. {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -646,9 +646,12 @@ def thd(
646
646
  nfft: int | None = None,
647
647
  return_db: bool = True,
648
648
  ) -> float:
649
- """Compute Total Harmonic Distortion.
649
+ """Compute Total Harmonic Distortion per IEEE 1241-2010.
650
650
 
651
- THD is the ratio of harmonic power to fundamental power.
651
+ THD is defined as the ratio of RMS harmonic power to fundamental amplitude:
652
+ THD = sqrt(sum(A_harmonics^2)) / A_fundamental
653
+
654
+ where harmonics are the 2nd, 3rd, ..., nth harmonic frequencies.
652
655
 
653
656
  Args:
654
657
  trace: Input waveform trace.
@@ -659,12 +662,14 @@ def thd(
659
662
  return_db: If True, return in dB. If False, return percentage.
660
663
 
661
664
  Returns:
662
- THD in dB or percentage.
665
+ THD in dB (if return_db=True) or percentage (if return_db=False).
666
+ Always non-negative in percentage form.
663
667
 
664
668
  Example:
665
669
  >>> thd_db = thd(trace)
666
670
  >>> thd_pct = thd(trace, return_db=False)
667
671
  >>> print(f"THD: {thd_db:.1f} dB ({thd_pct:.2f}%)")
672
+ >>> assert thd_pct >= 0, "THD percentage must be non-negative"
668
673
 
669
674
  References:
670
675
  IEEE 1241-2010 Section 4.1.4.2
@@ -676,32 +681,41 @@ def thd(
676
681
  result = fft(trace, window=window, nfft=nfft, detrend="mean")
677
682
  freq, mag_db = result[0], result[1]
678
683
 
679
- # Convert to linear
684
+ # Convert to linear magnitude
680
685
  magnitude = 10 ** (mag_db / 20)
681
686
 
682
- # Find fundamental
687
+ # Find fundamental (strongest peak above DC)
683
688
  _fund_idx, fund_freq, fund_mag = _find_fundamental(freq, magnitude)
684
689
 
685
690
  if fund_mag == 0 or fund_freq == 0:
686
691
  return np.nan
687
692
 
688
- # Find harmonics
693
+ # Find harmonic frequencies (2*f0, 3*f0, ..., n*f0)
689
694
  harmonic_indices = _find_harmonic_indices(freq, fund_freq, n_harmonics)
690
695
 
691
696
  if len(harmonic_indices) == 0:
692
697
  return 0.0 if not return_db else -np.inf
693
698
 
694
- # Sum harmonic power
699
+ # Compute total harmonic power: sum of squared magnitudes
695
700
  harmonic_power = sum(magnitude[i] ** 2 for i in harmonic_indices)
696
701
 
697
- # THD ratio
702
+ # THD = sqrt(sum(harmonic_power)) / fundamental_amplitude
703
+ # This is the IEEE 1241-2010 definition
698
704
  thd_ratio = np.sqrt(harmonic_power) / fund_mag
699
705
 
706
+ # Validate: THD must always be non-negative
707
+ if thd_ratio < 0:
708
+ raise ValueError(
709
+ f"THD ratio is negative ({thd_ratio:.6f}), indicating a calculation error. "
710
+ f"Fundamental: {fund_mag:.6f}, Harmonic power: {harmonic_power:.6f}"
711
+ )
712
+
700
713
  if return_db:
701
714
  if thd_ratio <= 0:
702
715
  return -np.inf
703
716
  return float(20 * np.log10(thd_ratio))
704
717
  else:
718
+ # Return as percentage
705
719
  return float(thd_ratio * 100)
706
720
 
707
721
 
@@ -1936,6 +1950,340 @@ def _extract_fft_segment(
1936
1950
  return segment
1937
1951
 
1938
1952
 
1953
+ def find_peaks(
1954
+ trace: WaveformTrace,
1955
+ *,
1956
+ window: str = "hann",
1957
+ nfft: int | None = None,
1958
+ threshold_db: float = -60.0,
1959
+ min_distance: int = 5,
1960
+ n_peaks: int | None = None,
1961
+ ) -> dict[str, NDArray[np.float64]]:
1962
+ """Find spectral peaks in FFT magnitude spectrum.
1963
+
1964
+ Identifies prominent frequency components above a threshold with
1965
+ minimum spacing between peaks.
1966
+
1967
+ Args:
1968
+ trace: Input waveform trace.
1969
+ window: Window function for FFT.
1970
+ nfft: FFT length.
1971
+ threshold_db: Minimum peak magnitude in dB (relative to max).
1972
+ min_distance: Minimum bin spacing between peaks.
1973
+ n_peaks: Maximum number of peaks to return (None = all).
1974
+
1975
+ Returns:
1976
+ Dictionary with keys:
1977
+ - "frequencies": Peak frequencies in Hz
1978
+ - "magnitudes_db": Peak magnitudes in dB
1979
+ - "indices": FFT bin indices of peaks
1980
+
1981
+ Example:
1982
+ >>> peaks = find_peaks(trace, threshold_db=-40, n_peaks=10)
1983
+ >>> print(f"Found {len(peaks['frequencies'])} peaks")
1984
+ >>> for freq, mag in zip(peaks['frequencies'], peaks['magnitudes_db']):
1985
+ ... print(f" {freq:.1f} Hz: {mag:.1f} dB")
1986
+
1987
+ References:
1988
+ IEEE 1241-2010 Section 4.1.5 - Spectral Analysis
1989
+ """
1990
+ from scipy.signal import find_peaks as sp_find_peaks
1991
+
1992
+ result = fft(trace, window=window, nfft=nfft)
1993
+ freq, mag_db = result[0], result[1]
1994
+
1995
+ # Find peaks using scipy
1996
+ # Convert threshold from dB relative to max
1997
+ max_mag_db = np.max(mag_db)
1998
+ abs_threshold = max_mag_db + threshold_db # threshold_db is negative
1999
+
2000
+ peak_indices, _ = sp_find_peaks(mag_db, height=abs_threshold, distance=min_distance)
2001
+
2002
+ # Sort by magnitude (strongest first)
2003
+ sorted_idx = np.argsort(mag_db[peak_indices])[::-1]
2004
+ peak_indices = peak_indices[sorted_idx]
2005
+
2006
+ # Limit number of peaks
2007
+ if n_peaks is not None:
2008
+ peak_indices = peak_indices[:n_peaks]
2009
+
2010
+ return {
2011
+ "frequencies": freq[peak_indices],
2012
+ "magnitudes_db": mag_db[peak_indices],
2013
+ "indices": peak_indices.astype(np.float64),
2014
+ }
2015
+
2016
+
2017
+ def extract_harmonics(
2018
+ trace: WaveformTrace,
2019
+ *,
2020
+ fundamental_freq: float | None = None,
2021
+ n_harmonics: int = 10,
2022
+ window: str = "hann",
2023
+ nfft: int | None = None,
2024
+ search_width_hz: float = 50.0,
2025
+ ) -> dict[str, NDArray[np.float64]]:
2026
+ """Extract harmonic frequencies and amplitudes from spectrum.
2027
+
2028
+ Identifies fundamental frequency (if not provided) and extracts
2029
+ harmonic series with frequencies and amplitudes.
2030
+
2031
+ Args:
2032
+ trace: Input waveform trace.
2033
+ fundamental_freq: Fundamental frequency in Hz. If None, auto-detected.
2034
+ n_harmonics: Number of harmonics to extract (excluding fundamental).
2035
+ window: Window function for FFT.
2036
+ nfft: FFT length.
2037
+ search_width_hz: Search range around expected harmonic frequencies.
2038
+
2039
+ Returns:
2040
+ Dictionary with keys:
2041
+ - "frequencies": Harmonic frequencies [f0, 2f0, 3f0, ...]
2042
+ - "amplitudes": Harmonic amplitudes (linear scale)
2043
+ - "amplitudes_db": Harmonic amplitudes in dB
2044
+ - "fundamental_freq": Detected or provided fundamental frequency
2045
+
2046
+ Example:
2047
+ >>> harmonics = extract_harmonics(trace, n_harmonics=5)
2048
+ >>> f0 = harmonics["fundamental_freq"]
2049
+ >>> print(f"Fundamental: {f0:.1f} Hz")
2050
+ >>> for i, (freq, amp_db) in enumerate(
2051
+ ... zip(harmonics["frequencies"], harmonics["amplitudes_db"]), 1
2052
+ ... ):
2053
+ ... print(f" H{i}: {freq:.1f} Hz at {amp_db:.1f} dB")
2054
+
2055
+ References:
2056
+ IEEE 1241-2010 Section 4.1.4.2 - Harmonic Analysis
2057
+ """
2058
+ result = fft(trace, window=window, nfft=nfft)
2059
+ freq, mag_db = result[0], result[1]
2060
+ magnitude = 10 ** (mag_db / 20)
2061
+
2062
+ # Auto-detect fundamental if not provided
2063
+ if fundamental_freq is None:
2064
+ _fund_idx, fund_freq, _fund_mag = _find_fundamental(freq, magnitude)
2065
+ fundamental_freq = fund_freq
2066
+
2067
+ if fundamental_freq == 0:
2068
+ # Return empty result
2069
+ return {
2070
+ "frequencies": np.array([]),
2071
+ "amplitudes": np.array([]),
2072
+ "amplitudes_db": np.array([]),
2073
+ "fundamental_freq": np.array([0.0]),
2074
+ }
2075
+
2076
+ # Extract harmonics
2077
+ harmonic_freqs = []
2078
+ harmonic_amps = []
2079
+
2080
+ for h in range(1, n_harmonics + 2): # Include fundamental (h=1)
2081
+ target_freq = h * fundamental_freq
2082
+ if target_freq > freq[-1]:
2083
+ break
2084
+
2085
+ # Search around expected frequency
2086
+ search_mask = np.abs(freq - target_freq) <= search_width_hz
2087
+ if not np.any(search_mask):
2088
+ continue
2089
+
2090
+ # Find peak in search region
2091
+ search_region_mag = magnitude.copy()
2092
+ search_region_mag[~search_mask] = 0
2093
+ peak_idx = np.argmax(search_region_mag)
2094
+
2095
+ harmonic_freqs.append(float(freq[peak_idx]))
2096
+ harmonic_amps.append(float(magnitude[peak_idx]))
2097
+
2098
+ harmonic_freqs_arr = np.array(harmonic_freqs)
2099
+ harmonic_amps_arr = np.array(harmonic_amps)
2100
+ harmonic_amps_db = 20 * np.log10(np.maximum(harmonic_amps_arr, 1e-20))
2101
+
2102
+ return {
2103
+ "frequencies": harmonic_freqs_arr,
2104
+ "amplitudes": harmonic_amps_arr,
2105
+ "amplitudes_db": harmonic_amps_db,
2106
+ "fundamental_freq": np.array([fundamental_freq]),
2107
+ }
2108
+
2109
+
2110
+ def phase_spectrum(
2111
+ trace: WaveformTrace,
2112
+ *,
2113
+ window: str = "hann",
2114
+ nfft: int | None = None,
2115
+ unwrap: bool = True,
2116
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
2117
+ """Compute phase spectrum from FFT.
2118
+
2119
+ Extracts phase information from frequency domain representation.
2120
+
2121
+ Args:
2122
+ trace: Input waveform trace.
2123
+ window: Window function for FFT.
2124
+ nfft: FFT length.
2125
+ unwrap: If True, unwrap phase to remove 2π discontinuities.
2126
+
2127
+ Returns:
2128
+ (frequencies, phase) where phase is in radians.
2129
+
2130
+ Example:
2131
+ >>> freq, phase = phase_spectrum(trace)
2132
+ >>> plt.plot(freq, phase)
2133
+ >>> plt.xlabel("Frequency (Hz)")
2134
+ >>> plt.ylabel("Phase (radians)")
2135
+
2136
+ References:
2137
+ Oppenheim & Schafer (2009) - Discrete-Time Signal Processing
2138
+ """
2139
+ result = fft(trace, window=window, nfft=nfft, return_phase=True)
2140
+ assert len(result) == 3, "Expected 3-tuple from fft with return_phase=True"
2141
+ freq, _mag_db, phase = result[0], result[1], result[2]
2142
+
2143
+ if unwrap:
2144
+ phase = np.unwrap(phase)
2145
+
2146
+ return freq, phase
2147
+
2148
+
2149
+ def group_delay(
2150
+ trace: WaveformTrace,
2151
+ *,
2152
+ window: str = "hann",
2153
+ nfft: int | None = None,
2154
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
2155
+ """Compute group delay from phase spectrum.
2156
+
2157
+ Group delay is the negative derivative of phase with respect to
2158
+ frequency, representing signal delay at each frequency.
2159
+
2160
+ Args:
2161
+ trace: Input waveform trace.
2162
+ window: Window function for FFT.
2163
+ nfft: FFT length.
2164
+
2165
+ Returns:
2166
+ (frequencies, group_delay_samples) - Delay in samples at each frequency.
2167
+
2168
+ Example:
2169
+ >>> freq, gd = group_delay(trace)
2170
+ >>> plt.plot(freq, gd)
2171
+ >>> plt.xlabel("Frequency (Hz)")
2172
+ >>> plt.ylabel("Group Delay (samples)")
2173
+
2174
+ References:
2175
+ Oppenheim & Schafer (2009) Section 5.1.1
2176
+ """
2177
+ freq, phase = phase_spectrum(trace, window=window, nfft=nfft, unwrap=True)
2178
+
2179
+ # Group delay = -dφ/dω
2180
+ # In discrete frequency: gd[i] ≈ -(φ[i+1] - φ[i]) / (ω[i+1] - ω[i])
2181
+ # Convert frequency to angular frequency: ω = 2π * f
2182
+ omega = 2 * np.pi * freq
2183
+
2184
+ # Compute derivative using central differences
2185
+ gd = np.zeros_like(phase)
2186
+
2187
+ # Central difference for interior points
2188
+ gd[1:-1] = -(phase[2:] - phase[:-2]) / (omega[2:] - omega[:-2])
2189
+
2190
+ # Forward/backward difference for boundaries
2191
+ if len(phase) > 1:
2192
+ gd[0] = -(phase[1] - phase[0]) / (omega[1] - omega[0])
2193
+ gd[-1] = -(phase[-1] - phase[-2]) / (omega[-1] - omega[-2])
2194
+
2195
+ return freq, gd
2196
+
2197
+
2198
+ def measure(
2199
+ trace: WaveformTrace,
2200
+ *,
2201
+ parameters: list[str] | None = None,
2202
+ include_units: bool = True,
2203
+ ) -> dict[str, Any]:
2204
+ """Compute multiple spectral measurements with consistent format.
2205
+
2206
+ Unified function for computing spectral quality metrics following IEEE 1241-2010.
2207
+ Matches the API pattern of oscura.analyzers.waveform.measurements.measure().
2208
+
2209
+ Args:
2210
+ trace: Input waveform trace.
2211
+ parameters: List of measurement names to compute. If None, compute all.
2212
+ Valid names: thd, snr, sinad, enob, sfdr, dominant_freq
2213
+ include_units: If True, return {value, unit} dicts. If False, return flat values.
2214
+
2215
+ Returns:
2216
+ Dictionary mapping measurement names to values (with units if requested).
2217
+
2218
+ Raises:
2219
+ InsufficientDataError: If trace is too short for analysis.
2220
+ AnalysisError: If computation fails.
2221
+
2222
+ Example:
2223
+ >>> from oscura.analyzers.waveform.spectral import measure
2224
+ >>> results = measure(trace)
2225
+ >>> print(f"THD: {results['thd']['value']}{results['thd']['unit']}")
2226
+ >>> print(f"SNR: {results['snr']['value']} {results['snr']['unit']}")
2227
+
2228
+ >>> # Get specific measurements only
2229
+ >>> results = measure(trace, parameters=["thd", "snr"])
2230
+
2231
+ >>> # Get flat values without units
2232
+ >>> results = measure(trace, include_units=False)
2233
+ >>> thd_value = results["thd"] # Just the float
2234
+
2235
+ References:
2236
+ IEEE 1241-2010: ADC Terminology and Test Methods
2237
+ """
2238
+ # Define all available spectral measurements with units
2239
+ all_measurements = {
2240
+ "thd": (thd, "%"),
2241
+ "snr": (snr, "dB"),
2242
+ "sinad": (sinad, "dB"),
2243
+ "enob": (enob, "bits"),
2244
+ "sfdr": (sfdr, "dB"),
2245
+ }
2246
+
2247
+ # Select requested measurements or all
2248
+ if parameters is None:
2249
+ selected = all_measurements
2250
+ else:
2251
+ selected = {k: v for k, v in all_measurements.items() if k in parameters}
2252
+
2253
+ results: dict[str, Any] = {}
2254
+
2255
+ for name, (func, unit) in selected.items():
2256
+ try:
2257
+ value = func(trace) # type: ignore[operator]
2258
+ except Exception:
2259
+ value = np.nan
2260
+
2261
+ if include_units:
2262
+ results[name] = {"value": value, "unit": unit}
2263
+ else:
2264
+ results[name] = value
2265
+
2266
+ # Add dominant frequency if requested or if computing all
2267
+ if parameters is None or "dominant_freq" in parameters:
2268
+ try:
2269
+ fft_result = fft(trace, return_phase=False)
2270
+ freq, magnitude = fft_result[0], fft_result[1]
2271
+ dominant_idx = int(np.argmax(np.abs(magnitude[1:]))) + 1 # Skip DC
2272
+ dominant_freq_value = float(freq[dominant_idx])
2273
+
2274
+ if include_units:
2275
+ results["dominant_freq"] = {"value": dominant_freq_value, "unit": "Hz"}
2276
+ else:
2277
+ results["dominant_freq"] = dominant_freq_value
2278
+ except Exception:
2279
+ if include_units:
2280
+ results["dominant_freq"] = {"value": np.nan, "unit": "Hz"}
2281
+ else:
2282
+ results["dominant_freq"] = np.nan
2283
+
2284
+ return results
2285
+
2286
+
1939
2287
  __all__ = [
1940
2288
  "bartlett_psd",
1941
2289
  "clear_fft_cache",
@@ -1943,13 +2291,18 @@ __all__ = [
1943
2291
  "cwt",
1944
2292
  "dwt",
1945
2293
  "enob",
2294
+ "extract_harmonics",
1946
2295
  "fft",
1947
2296
  "fft_chunked",
2297
+ "find_peaks",
1948
2298
  "get_fft_cache_stats",
2299
+ "group_delay",
1949
2300
  "hilbert_transform",
1950
2301
  "idwt",
2302
+ "measure",
1951
2303
  "mfcc",
1952
2304
  "periodogram",
2305
+ "phase_spectrum",
1953
2306
  "psd",
1954
2307
  "psd_chunked",
1955
2308
  "sfdr",
@@ -49,7 +49,7 @@ try:
49
49
  __version__ = version("oscura")
50
50
  except Exception:
51
51
  # Fallback for development/testing when package not installed
52
- __version__ = "0.6.0"
52
+ __version__ = "0.8.0"
53
53
 
54
54
  __all__ = [
55
55
  "CANMessage",
@@ -225,7 +225,6 @@ def load_config(
225
225
  config = copy.deepcopy(DEFAULT_CONFIG)
226
226
 
227
227
  # Search for config files if no explicit path provided
228
- # use_defaults flag only controls whether to merge with DEFAULT_CONFIG
229
228
  if config_path is None:
230
229
  # Search standard locations
231
230
  search_paths = [
oscura/core/types.py CHANGED
@@ -240,6 +240,42 @@ class WaveformTrace:
240
240
  return 0.0
241
241
  return (len(self.data) - 1) * self.metadata.time_base
242
242
 
243
+ @property
244
+ def is_analog(self) -> bool:
245
+ """Check if this is an analog signal trace.
246
+
247
+ Returns:
248
+ True for WaveformTrace (always analog).
249
+ """
250
+ return True
251
+
252
+ @property
253
+ def is_digital(self) -> bool:
254
+ """Check if this is a digital signal trace.
255
+
256
+ Returns:
257
+ False for WaveformTrace (always analog).
258
+ """
259
+ return False
260
+
261
+ @property
262
+ def is_iq(self) -> bool:
263
+ """Check if this is an I/Q signal trace.
264
+
265
+ Returns:
266
+ False for WaveformTrace.
267
+ """
268
+ return False
269
+
270
+ @property
271
+ def signal_type(self) -> str:
272
+ """Get the signal type identifier.
273
+
274
+ Returns:
275
+ "analog" for WaveformTrace.
276
+ """
277
+ return "analog"
278
+
243
279
  def __len__(self) -> int:
244
280
  """Return number of samples in the trace."""
245
281
  return len(self.data)
@@ -323,6 +359,42 @@ class DigitalTrace:
323
359
  return []
324
360
  return [ts for ts, is_rising in self.edges if not is_rising]
325
361
 
362
+ @property
363
+ def is_analog(self) -> bool:
364
+ """Check if this is an analog signal trace.
365
+
366
+ Returns:
367
+ False for DigitalTrace (always digital).
368
+ """
369
+ return False
370
+
371
+ @property
372
+ def is_digital(self) -> bool:
373
+ """Check if this is a digital signal trace.
374
+
375
+ Returns:
376
+ True for DigitalTrace (always digital).
377
+ """
378
+ return True
379
+
380
+ @property
381
+ def is_iq(self) -> bool:
382
+ """Check if this is an I/Q signal trace.
383
+
384
+ Returns:
385
+ False for DigitalTrace.
386
+ """
387
+ return False
388
+
389
+ @property
390
+ def signal_type(self) -> str:
391
+ """Get the signal type identifier.
392
+
393
+ Returns:
394
+ "digital" for DigitalTrace.
395
+ """
396
+ return "digital"
397
+
326
398
  def __len__(self) -> int:
327
399
  """Return number of samples in the trace."""
328
400
  return len(self.data)
@@ -421,6 +493,42 @@ class IQTrace:
421
493
  return 0.0
422
494
  return (len(self.i_data) - 1) * self.metadata.time_base
423
495
 
496
+ @property
497
+ def is_analog(self) -> bool:
498
+ """Check if this is an analog signal trace.
499
+
500
+ Returns:
501
+ False for IQTrace (complex I/Q data).
502
+ """
503
+ return False
504
+
505
+ @property
506
+ def is_digital(self) -> bool:
507
+ """Check if this is a digital signal trace.
508
+
509
+ Returns:
510
+ False for IQTrace (complex I/Q data).
511
+ """
512
+ return False
513
+
514
+ @property
515
+ def is_iq(self) -> bool:
516
+ """Check if this is an I/Q signal trace.
517
+
518
+ Returns:
519
+ True for IQTrace (always I/Q).
520
+ """
521
+ return True
522
+
523
+ @property
524
+ def signal_type(self) -> str:
525
+ """Get the signal type identifier.
526
+
527
+ Returns:
528
+ "iq" for IQTrace.
529
+ """
530
+ return "iq"
531
+
424
532
  def __len__(self) -> int:
425
533
  """Return number of samples in the trace."""
426
534
  return len(self.i_data)
@@ -29,6 +29,7 @@ from oscura.core.types import DigitalTrace, IQTrace, WaveformTrace
29
29
  _LOADER_REGISTRY: dict[str, tuple[str, str]] = {
30
30
  "tektronix": ("oscura.loaders.tektronix", "load_tektronix_wfm"),
31
31
  "tek": ("oscura.loaders.tektronix", "load_tektronix_wfm"),
32
+ "tss": ("oscura.loaders.tss", "load_tss"),
32
33
  "rigol": ("oscura.loaders.rigol", "load_rigol_wfm"),
33
34
  "numpy": ("oscura.loaders.numpy_loader", "load_npz"),
34
35
  "csv": ("oscura.loaders.csv_loader", "load_csv"),
@@ -180,6 +181,7 @@ logger = logging.getLogger(__name__)
180
181
  # Supported format extensions mapped to loader names
181
182
  SUPPORTED_FORMATS: dict[str, str] = {
182
183
  ".wfm": "auto_wfm", # Auto-detect Tektronix vs Rigol
184
+ ".tss": "tss", # Tektronix session files
183
185
  ".npz": "numpy",
184
186
  ".csv": "csv",
185
187
  ".h5": "hdf5",
@@ -392,8 +394,8 @@ def load_all_channels(
392
394
  )
393
395
  loader_name = SUPPORTED_FORMATS[ext]
394
396
 
395
- # Currently only supports Tektronix WFM for multi-channel loading
396
- if loader_name in ("auto_wfm", "tektronix", "tek"):
397
+ # Currently only supports Tektronix WFM and TSS for multi-channel loading
398
+ if loader_name in ("auto_wfm", "tektronix", "tek", "tss"):
397
399
  return _load_all_channels_tektronix(path)
398
400
  else:
399
401
  # For other formats, try loading as single channel
@@ -405,10 +407,10 @@ def load_all_channels(
405
407
  def _load_all_channels_tektronix(
406
408
  path: Path,
407
409
  ) -> dict[str, WaveformTrace | DigitalTrace | IQTrace]:
408
- """Load all channels from a Tektronix WFM file.
410
+ """Load all channels from a Tektronix WFM or TSS file.
409
411
 
410
412
  Args:
411
- path: Path to the Tektronix .wfm file.
413
+ path: Path to the Tektronix .wfm or .tss file.
412
414
 
413
415
  Returns:
414
416
  Dictionary mapping channel names to traces.
@@ -416,6 +418,12 @@ def _load_all_channels_tektronix(
416
418
  Raises:
417
419
  LoaderError: If the file cannot be read or parsed.
418
420
  """
421
+ # Check if this is a .tss session file
422
+ if path.suffix.lower() == ".tss":
423
+ from oscura.loaders.tss import load_all_channels_tss
424
+
425
+ return load_all_channels_tss(path)
426
+
419
427
  wfm = _read_tektronix_file(path)
420
428
  channels: dict[str, WaveformTrace | DigitalTrace | IQTrace] = {}
421
429