Coverage for src / tracekit / search / pattern.py: 100%
54 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Pattern search in digital traces.
4This module provides efficient bit pattern matching in digital signals
5with wildcard support via mask parameter.
6"""
8from typing import cast
10import numpy as np
11from numpy.typing import NDArray
14def find_pattern(
15 trace: NDArray[np.float64] | NDArray[np.uint8],
16 pattern: int | NDArray[np.uint8],
17 mask: int | NDArray[np.uint8] | None = None,
18 *,
19 threshold: float | None = None,
20 min_spacing: int = 1,
21) -> list[tuple[int, NDArray[np.uint8]]]:
22 """Find occurrences of bit patterns in digital traces.
24 : Pattern search with wildcard support via mask.
25 Works on both raw analog traces (with threshold) and decoded digital data.
27 Args:
28 trace: Input trace array. If analog (float), threshold is required.
29 If already digital (uint8), threshold is ignored.
30 pattern: Bit pattern to search for. Can be:
31 - Integer: e.g., 0b10101010 (8-bit pattern)
32 - Array: sequence of bytes to match
33 mask: Optional mask for wildcard matching. Bits set to 0 in mask
34 are "don't care" positions. Can be:
35 - Integer: e.g., 0xFF (all bits matter)
36 - Array: per-byte masks
37 If None, all bits must match (equivalent to all 1s).
38 threshold: Threshold for converting analog to digital (required if
39 trace is analog). Typically mid-level of logic family.
40 min_spacing: Minimum samples between detected patterns to avoid
41 overlapping matches (default: 1)
43 Returns:
44 List of (index, match) tuples where:
45 - index: Starting sample index of the pattern
46 - match: The actual matched bit sequence as uint8 array
48 Raises:
49 ValueError: If analog trace provided without threshold
50 ValueError: If pattern is empty
52 Examples:
53 >>> # Find 0xAA pattern in analog trace
54 >>> import numpy as np
55 >>> trace = np.array([0, 1, 0, 1, 0, 1, 0, 1, 0, 0])
56 >>> matches = find_pattern(trace, 0b10101010, threshold=0.5)
57 >>> print(f"Found {len(matches)} matches")
59 >>> # Wildcard search: find 0b1010xxxx (x = don't care)
60 >>> pattern = 0b10100000
61 >>> mask = 0b11110000 # Only upper 4 bits matter
62 >>> matches = find_pattern(trace, pattern, mask, threshold=0.5)
64 >>> # Search in already-decoded digital data
65 >>> digital = np.array([0xAA, 0x55, 0xAA, 0x00], dtype=np.uint8)
66 >>> matches = find_pattern(digital, 0xAA)
68 Notes:
69 - For analog traces, values >= threshold are interpreted as '1'
70 - Mask bits: 1 = must match, 0 = don't care
71 - Overlapping patterns can be filtered with min_spacing > 1
72 - Returns empty list if no matches found
74 References:
75 SRCH-001: Pattern Search
76 """
77 if trace.size == 0:
78 return []
80 # Convert pattern to array if integer
81 if isinstance(pattern, int):
82 if pattern < 0:
83 raise ValueError("Pattern must be non-negative")
84 # Convert to byte array (variable length based on value)
85 pattern_bytes = []
86 if pattern == 0:
87 pattern_bytes = [0]
88 else:
89 temp = pattern
90 while temp > 0:
91 pattern_bytes.insert(0, temp & 0xFF)
92 temp >>= 8
93 pattern_arr = np.array(pattern_bytes, dtype=np.uint8)
94 else:
95 pattern_arr = np.asarray(pattern, dtype=np.uint8)
97 if pattern_arr.size == 0:
98 raise ValueError("Pattern cannot be empty")
100 # Convert mask to array if integer
101 if mask is not None:
102 if isinstance(mask, int):
103 mask_bytes: list[int] = []
104 temp = mask
105 # Match pattern length
106 for _ in range(len(pattern_arr)):
107 mask_bytes.insert(0, temp & 0xFF)
108 temp >>= 8
109 mask_arr = np.array(mask_bytes, dtype=np.uint8)
110 else:
111 mask_arr = np.asarray(mask, dtype=np.uint8)
113 # Ensure mask and pattern have same length
114 if mask_arr.size != pattern_arr.size:
115 raise ValueError("Mask and pattern must have same length")
116 else:
117 # Default: all bits matter
118 mask_arr = np.full(pattern_arr.size, 0xFF, dtype=np.uint8)
120 # Convert analog trace to digital if needed
121 if trace.dtype != np.uint8:
122 if threshold is None:
123 raise ValueError(
124 "Threshold required for analog trace conversion. "
125 "Provide threshold parameter or pre-convert to digital."
126 )
127 # Simple threshold conversion: >= threshold is 1
128 digital = (trace >= threshold).astype(np.uint8)
129 # Pack bits into bytes (8 samples per byte)
130 # Pad to multiple of 8
131 n_pad = (8 - len(digital) % 8) % 8
132 if n_pad:
133 digital = np.pad(digital, (0, n_pad), constant_values=0)
134 # Pack bits
135 digital_packed: NDArray[np.uint8] = np.packbits(digital, bitorder="big")
136 else:
137 digital_packed = cast("NDArray[np.uint8]", trace)
139 if digital_packed.size < pattern_arr.size:
140 return []
142 # Sliding window pattern matching with mask
143 matches: list[tuple[int, NDArray[np.uint8]]] = []
144 i = 0
146 while i <= len(digital_packed) - len(pattern_arr):
147 window = digital_packed[i : i + len(pattern_arr)]
149 # Apply mask and compare
150 masked_window = window & mask_arr
151 masked_pattern = pattern_arr & mask_arr
153 if np.array_equal(masked_window, masked_pattern):
154 matches.append((i, window.copy()))
155 # Skip ahead by min_spacing to avoid overlapping matches
156 i += max(1, min_spacing)
157 else:
158 i += 1
160 return matches