@@ -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