Skip to content

Commit 8cef4b0

Browse files
authored
Added new feature: ClockFilter
Added ClockFilter, which removes single-frequency injected noise. Useful for eliminating reset noise in some capacitor-feedback amplifiers such as Elements.
1 parent 4c15366 commit 8cef4b0

1 file changed

Lines changed: 159 additions & 0 deletions

File tree

src/ionique/utils.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,166 @@ def __call__(self, current, sampling_frequency=None):
267267
else:
268268
current[:] = signal.sosfilt(self.sos, current, axis=0)
269269

270+
@dataclass
271+
class ClockFilter:
272+
"""
273+
This special filter removes a singular frequency from the signal.
274+
If the power spectral density of a signal contains a very narrow and sharp peak at one frequency, caused by EMF interference from a digital signal, this filter can eliminate its effect.
275+
This is not a notch filter, it effectively subtracts a phase-matching sine wave of an exact frequency from the signal.
276+
If multiple clock frequencies or harmonics exist, use once for each frequency.
277+
The clock filter can be used before, after, or without low-pass filtering.
278+
Constructor returns a callable, which would filter the signal inplace.
279+
280+
:param clock_frequency: clock frequency to be removed in Hz.
281+
:type clock_frequency: float
282+
283+
:param section_length: length of sections to use in noise estimation. Each section is filtered independently
284+
285+
286+
:param sampling_frequency: Sampling frequency of the signal in Hz.
287+
:type sampling_frequency: float, optional
288+
289+
"""
290+
clock_frequency: float
291+
section_length: float = field(default=0.5, metadata={"min":0.000001}) #in seconds
292+
sampling_frequency: float = None
293+
294+
295+
296+
def __call__(self, current, sampling_frequency=None):
297+
"""Run the filter inplace in a memory efficient way, without duplicating the full array in the process."""
298+
if self.sampling_frequency is None and sampling_frequency is None:
299+
raise ValueError("Sampling frequency must be provided.")
270300

301+
if sampling_frequency is not None:
302+
self.sampling_frequency = float(sampling_frequency)
303+
304+
fs = float(self.sampling_frequency)
305+
f0 = float(self.clock_frequency)
306+
307+
if current.ndim != 1:
308+
raise ValueError("current must be a 1D array.")
309+
if current.size == 0:
310+
return
311+
312+
from fractions import Fraction
313+
314+
def find_period_samples(fs_: float, f0_: float,
315+
rel_tol: float = 1e-12,
316+
max_den: int = 10_000_000,
317+
max_period: int = 1_000_000):
318+
"""If f0/fs is (effectively) rational, return reduced denominator q (period in samples). Else None."""
319+
r = f0_ / fs_
320+
if not np.isfinite(r) or r == 0.0:
321+
return None
322+
frac = Fraction(r).limit_denominator(max_den)
323+
p, q = frac.numerator, frac.denominator
324+
if q <= 0 or q > max_period:
325+
return None
326+
if abs(r - (p / q)) <= rel_tol * max(1.0, abs(r)):
327+
return q
328+
return None
329+
330+
def remove_tone_dot_inplace(x: np.ndarray, c_lut: np.ndarray, s_lut: np.ndarray):
331+
"""
332+
Fast removal assuming len(x) is an integer multiple of len(c_lut).
333+
Uses reshape views (no tiling) and subtracts in-place.
334+
"""
335+
N = x.size
336+
P = c_lut.size
337+
if N == 0:
338+
return
339+
if N % P != 0:
340+
return # caller guarantees; if violated, do nothing
341+
342+
X = x.reshape(-1, P) # view
343+
xc = float(np.sum(X * c_lut))
344+
xs = float(np.sum(X * s_lut))
345+
a = (2.0 / N) * xc
346+
b = (2.0 / N) * xs
347+
348+
tone_lut = a * c_lut + b * s_lut # only P samples allocated
349+
X -= tone_lut # broadcast subtract, in-place
350+
return a,b
351+
352+
def remove_tone_by_fitting_inplace(x: np.ndarray, fs_: float, f0_: float):
353+
"""
354+
2-parameter LS on the tail via 2x2 normal equations, no ridge regularization.
355+
"""
356+
N = x.size
357+
if N < 2:
358+
return
359+
360+
w0 = 2.0 * np.pi * f0_ / fs_
361+
n = np.arange(N, dtype=np.float64)
362+
c = np.cos(w0 * n)
363+
s = np.sin(w0 * n)
364+
365+
X = np.column_stack((c, s))
366+
theta, *_ = np.linalg.lstsq(X, np.asarray(x), rcond=None)
367+
a, b = theta
368+
369+
tone = a*c + b*s
370+
371+
x -= tone
372+
373+
# Section size in samples
374+
section_n_samples = int(round(fs * float(self.section_length)))
375+
section_n_samples = max(1, section_n_samples)
376+
377+
# Find discrete-time period (if rational enough)
378+
n_period = find_period_samples(fs, f0)
379+
380+
# Prepare LUT if usable
381+
use_fast = (n_period is not None) and (n_period > 0) and (n_period <= section_n_samples)
382+
if use_fast:
383+
w0 = 2.0 * np.pi * f0 / fs
384+
nL = np.arange(n_period, dtype=np.float64)
385+
c_lut = np.cos(w0 * nL)
386+
s_lut = np.sin(w0 * nL)
387+
else:
388+
n_period = None
389+
c_lut = s_lut = None
390+
391+
# Internal robustness knob: if remainder is tiny, move one (or more) full periods into the tail
392+
min_fit = n_period*10
393+
394+
# Process all sections, including final partial section
395+
for start in range(0, current.size, section_n_samples):
396+
stop = min(start + section_n_samples, current.size)
397+
seg_len = stop - start
398+
if seg_len <= 0:
399+
break
400+
401+
if not use_fast or n_period is None or n_period <= 1:
402+
remove_tone_by_fitting_inplace(current[start:stop], fs, f0)
403+
continue
404+
405+
rem = seg_len % n_period
406+
full_len = seg_len - rem
407+
408+
# If remainder is too short, steal one (or more) full periods from the LUT part
409+
while rem != 0 and rem < min_fit and full_len >= n_period:
410+
full_len -= n_period
411+
rem += n_period
412+
413+
mid = start + full_len
414+
dot_theta=None
415+
if full_len > 0:
416+
dot_theta=remove_tone_dot_inplace(current[start:mid], c_lut, s_lut)
417+
418+
# remove_tone_by_fitting_inplace(current[start:stop],fs,f0)
419+
420+
if mid < stop:
421+
if dot_theta is not None:
422+
n=stop-mid
423+
c = np.cos(w0*np.arange(n))
424+
s = np.sin(w0*np.arange(n))
425+
a,b=dot_theta
426+
current[mid:stop]-= a*c+b*s
427+
else:
428+
remove_tone_by_fitting_inplace(current[mid:stop], fs, f0)
429+
return
271430

272431

273432
@dataclass

0 commit comments

Comments
 (0)