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.
- oscura/__init__.py +1 -1
- oscura/analyzers/eye/__init__.py +5 -1
- oscura/analyzers/eye/generation.py +501 -0
- oscura/analyzers/jitter/__init__.py +6 -6
- oscura/analyzers/jitter/timing.py +419 -0
- oscura/analyzers/patterns/__init__.py +28 -0
- oscura/analyzers/patterns/reverse_engineering.py +991 -0
- oscura/analyzers/power/__init__.py +35 -12
- oscura/analyzers/statistics/__init__.py +4 -0
- oscura/analyzers/statistics/basic.py +149 -0
- oscura/analyzers/statistics/correlation.py +47 -6
- oscura/analyzers/waveform/__init__.py +2 -0
- oscura/analyzers/waveform/measurements.py +145 -23
- oscura/analyzers/waveform/spectral.py +361 -8
- oscura/automotive/__init__.py +1 -1
- oscura/core/config/loader.py +0 -1
- oscura/core/types.py +108 -0
- oscura/loaders/__init__.py +12 -4
- oscura/loaders/tss.py +456 -0
- oscura/reporting/__init__.py +88 -1
- oscura/reporting/automation.py +348 -0
- oscura/reporting/citations.py +374 -0
- oscura/reporting/core.py +54 -0
- oscura/reporting/formatting/__init__.py +11 -0
- oscura/reporting/formatting/measurements.py +279 -0
- oscura/reporting/html.py +57 -0
- oscura/reporting/interpretation.py +431 -0
- oscura/reporting/summary.py +329 -0
- oscura/reporting/visualization.py +542 -0
- oscura/visualization/__init__.py +2 -1
- oscura/visualization/batch.py +521 -0
- oscura/workflows/__init__.py +2 -0
- oscura/workflows/waveform.py +783 -0
- {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/METADATA +37 -19
- {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/RECORD +38 -26
- {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/WHEEL +0 -0
- {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/entry_points.txt +0 -0
- {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
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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",
|
oscura/automotive/__init__.py
CHANGED
oscura/core/config/loader.py
CHANGED
|
@@ -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)
|
oscura/loaders/__init__.py
CHANGED
|
@@ -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
|
|