Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: Add fs= parameter to filter design functions #8397

Merged
merged 23 commits into from
Jul 28, 2018
Merged

Conversation

endolith
Copy link
Member

@endolith endolith commented Feb 10, 2018

This is just a more convenient way of specifying normalized frequencies for digital filters. Instead of having to remember that butter uses fc/(fs/2), while freqz uses fc/(fs/(2*pi)), for instance, you can just specify the frequencies in Hz and the sampling rate in Hz.

Old way:

>>> fc = 1000
>>> fs = 3000
>>> b, a = butter(2, fc/(fs/2))
>>> abs(freqz(b, a, [fc/(fs/(2*pi))])[1])
array([ 0.70710678])

New way:

>>> fc = 1000
>>> fs = 3000
>>> b, a = butter(2, fc, fs=fs)
>>> abs(freqz(b, a, [fc], fs=fs)[1])
array([ 0.70710678])

I proposed this a while ago and no one seemed interested, but it's easy enough to implement so we'll see what people think.

Examples of confusion:

@WarrenWeckesser WarrenWeckesser added enhancement A new feature or improvement scipy.signal labels Feb 10, 2018
@WarrenWeckesser
Copy link
Member

Thanks for this, it is a much needed enhancement. I'll try to do a review sometime this weekend (nag me if I don't!).

One thing I'll note now is that unit tests are needed for the new option.

@larsoner
Copy link
Member

Agreed, even if it's redundant the new code is much more readable. +1 from me

@larsoner larsoner added this to the 1.1.0 milestone Feb 10, 2018
@larsoner
Copy link
Member

I've optimistically marked this for 1.1 (mid-March)

@endolith
Copy link
Member Author

@WarrenWeckesser

One thing I'll note now is that unit tests are needed for the new option.

Yeah I wanted to see if people liked it before doing the rest of the work. Sounds like people do, though.

@endolith
Copy link
Member Author

endolith commented Feb 10, 2018

Instead of default of fs=None, I could make it fs=2 or fs=2*pi or whatever is appropriate for each function? I don't really have an opinion on which is better.

Also can be added to

  • iirnotch, iirpeak
  • buttord, cheb1ord, etc.
  • group_delay
  • iirdesign
  • band_stop_obj ?

Already had fs:

  • firwin, firwin2, firls, remez (all of which use fs=None)

@larsoner
Copy link
Member

I prefer explicit fs=2. and similar but IIRC @WarrenWeckesser prefers fs=None as an alias because it can be easier to wrap.

Maybe we can both be happy by using fs=2. as the default but support fs=None as "use the default" (in the code this is the one-liner fs = 2. if fs is None else fs)? That way introspection of the call immediately shows the default (rather than having to look in the docstring; what I like) and functions that wrap these can still pass None to get default behavior (what I think @WarrenWeckesser wants)?

As for what needs to be changed, I'd recommend taking a brief scan of the test_* functions in scipy.signal and seeing where else fs= would make things simpler. But I also don't mind multiple PRs if we find other cases in the future, so I don't think this needs to be exhaustive.

@endolith
Copy link
Member Author

endolith commented Feb 11, 2018

Should freqz, ellipord, etc. return w in fs units if fs specified?

Like if you do

w, h = freqz(b, a, [100, 200, 300], fs=1000)

should w == [100, 200, 300]? What about

w, h = signal.freqz(b, a, 5, fs=8000)

Should w == [0, 800, 1600, 2400, 3200] in Hz? What about

w, h = signal.freqz(b, a, fs=8000)

?

I think it makes sense to be in Hz for all of them. Likewise for buttord, etc. if your inputs are in Hz then it makes sense for output cutoff frequency to also be in Hz, which you can then feed to butter, also using fs parameter.

@endolith
Copy link
Member Author

Added examples that use fs and sos to filter a real signal:

https://5541-1460385-gh.circle-artifacts.com/0/home/ubuntu/scipy/doc/build/html-scipyorg/generated/scipy.signal.butter.html

Also added test for some functions but not all yet

@endolith
Copy link
Member Author

So doctest failed because of

Failed example:
    ax1.axis([0, 1, -2, 2])
Expected nothing
Got:
    [0, 1, -2, 2]

But there are other examples with

>>> ax.axis((10, 1000, -100, 10))

that don't cause failures?

@larsoner
Copy link
Member

Looks like ax.axis is explicitly ignored:

https://github.com/scipy/scipy/blob/master/tools/refguide_check.py#L518

This could be improved/extended with e.g. regex that permits names like ax1 and ax2, or just use # doctest:+SKIP or similar with the offending lines.

@larsoner
Copy link
Member

Maybe the thing to do is to change it to .axis(from ax.axis. It should be general enough. Same with plt.plot(, it seems like this should be .plot(.

This should probably be a separate PR, though, so that it gets proper discussion as it modifies the test suite.

@ev-br
Copy link
Member

ev-br commented Feb 12, 2018

Please no doctest:+SKIP, just fix the refguide-checker.

@WarrenWeckesser
Copy link
Member

WarrenWeckesser commented Feb 16, 2018

I can live with fs=2.0 (or whatever the appropriate constant is for the function being changed) for the default keyword argument.

@WarrenWeckesser
Copy link
Member

@endolith asked

Should freqz, ellipord, etc. return w in fs units if fs specified?

My expectation was that fs would set the units for everything--input and output--so the frequencies returned would be in the same units as fs. I'm trying to think of a reason for it to be otherwise, but so far I'm not succeeding. Can anyone make a compelling argument for the returned frequencies to not match fs?

@larsoner
Copy link
Member

Can anyone make a compelling argument for the returned frequencies to not match fs?

As long as the new defaults give the same old results, we should be good to go on this. I have the same expectation as you, that all outputs would be modified appropriately.

@WarrenWeckesser
Copy link
Member

WarrenWeckesser commented Feb 16, 2018

Another observation: the functions periodogram, welch, csd, coherence, spectrogram and stft all accept the argument fs already, and the frequencies that they return are in the same units as fs. Given that existing behavior, for API consistency the functions updated in this pull request should also return frequencies in the same units as fs.

@endolith
Copy link
Member Author

I'm not sure what to do about the failures.

operator.index(array([123])) is raising DeprecationWarning: converting an array with ndim > 0 to an index will result in an error in the future

but it's supposed to produce an error, and does in Python 3.6.3, but not in Python 3.4.6 the test is running in?

@larsoner
Copy link
Member

but it's supposed to produce an error, and does in Python 3.6.3, but not in Python 3.4.6 the test is running in?

Different Travis runs use different NumPy versions, this is probably to blame. The 3.4.6 appears to use NumPy 1.8.2. The code for freqz probably needs to be improved to deal with this case.

@endolith
Copy link
Member Author

So this goes back to #7351 and how to verify int parameters.

I made a chart of how different types are handled here: https://stackoverflow.com/a/48940855/125507

I did this using .index() since that seems to be what people want.

Copy link
Member

@larsoner larsoner left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

operator.index is indeed what we settled on for int checks.

I made some small comments on the code, let's see what @WarrenWeckesser says about the fs default, and once that's settled I'll take a look at the examples and tests.

except TypeError:
pass
else:
w = findfreqs(b, a, N)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could save on branching/lines a little bit with else instead of elif here:

else:
    try:
        N = operator.index(worN)
    except TypeError:
        w = worN
    else:
        w = findfreqs(b, a, N)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It needs to do the ndim check to interpret array([8]) as "measure at point 8", rather than "measure at 8 points" since apparently older numpy versions returned .index() on 1-element N-D arrays.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using, for example, worN=3.0, results in UnboundLocalError: local variable 'w' referenced before assignment

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I modified it to fix the unbound error. Now it calls index twice, but that's not a big deal? Otherwise I'd have to duplicate other things and it would be harder to read. Still need to write tests for this condition

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling index twice seems fine if it leads to the simplest code.

@@ -299,12 +304,15 @@ def freqz(b, a=1, worN=None, whole=False, plot=None):
A callable that takes two arguments. If given, the return parameters
`w` and `h` are passed to plot. Useful for plotting the frequency
response inside `freqz`.
fs : float
The sampling frequency of the digital system.
.. versionadded:: 1.1.0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need an extra newline before the .. directive otherwise it doesn't render properly (not seen as separate paragraph)

https://5706-1460385-gh.circle-artifacts.com/0/home/ubuntu/scipy/doc/build/html-scipyorg/generated/scipy.signal.freqz.html#scipy.signal.freqz

The normalized frequencies at which `h` was computed, in
radians/sample.
The frequencies at which `h` was computed. If `fs` is specified,
these are in the same units. Otherwise they are in radians/sample.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a case where setting fs=2*pi makes things simpler. Here you can just say they are in units of the sample rate, and you can get rid of the conditionals below. We can make fs=None behave like 2 * pi if this would make achieving "use-the-default" behavior easier for people. @WarrenWeckesser WDYT?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The worN=None arguments follow the same pattern, right? They actually default to numbers, too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I am arguing against that design choice as it hurts argument introspection. I think we should ideally change this everywhere, so at least avoid propagating the problem here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just remembered this was discussed in #7788. I'll try to move that forward so we can proceed here.

Copy link
Member Author

@endolith endolith Mar 10, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe that should be done in a separate PR then? (And changing worN=512 as well?) Changing these is 100% backwards compatible, right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for disappearing from this conversation. If no one else objects to fs=2*np.pi, then go ahead. (Of course, introspection of the signature will then show fs=6.283185307179586.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@WarrenWeckesser We could make an object that has repr of 2*pi but otherwise behaves as a float.

class Foo(float):
    def __new__(self, value):
        return float.__new__(self, value)

    def __init__(self, value):
        float.__init__(value)

    def __repr__(self):
        return '2*pi'


foo = Foo(1)

def freqz(fs=foo):
    print(fs)

2018-03-23 10_23_20-spyder python 3 6

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a neat idea, but I don't think the extra code is worth it just to fix a cosmetic issue.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Back to this line of code -- we should just say that the frequencies are in units of fs (and if you want, add that the default fs=2*pi implies the units are radians/sample)

This was tested in the functions a few different ways.

Some don't recognize np.int8(8) as an integer that means "measure at 8
frequencies".

Some accept np.array([8]) as an integer (with a warning) and measure at
8 frequencies, even though it should just mean "measure at frequency 8".
"512 frequencies equally spaced around the unit circle." is not correct
if whole=False.
iirfilter() Examples now include both analog and digital

freqz_zpk() Examples:

Get rid of MatplotlibDeprecationWarning: "Adding an axes using the same
arguments as a previous axes currently reuses the earlier instance."

subplot(1, 1, 1) is more intuitive than magic behavior of subplot(111).

Move grid() command so vertical lines are shown, too.
scipy#8397 (comment)

    worN=3.0, results in
    UnboundLocalError: local variable 'w' referenced before assignment

Harmonize all functions that detect w vs N using type, and use N or w
variable names in branches for clarity
Test that it handles type-checking correctly:

  worN=8 means "check 8 equally-spaced points around the unit circle"
  worN=8.0 means "check at frequency 8 Hz"

Test freqz and sosfreqz in both fft and non-fft conditions.
Reminder to not remove these; they're for backwards compatibility
after the changes in scipy#8584
It was checking for exact equality of input w to output w, but we are
normalizing and un-normalizing now, so there will be slight differences.
(Also can't just use a temporary normalized w internally, because we
still need to un-normalize it when N is int and fs is specified.)
@endolith
Copy link
Member Author

Ok updated the version number

@ilayn ilayn added this to the 1.2.0 milestone Jul 28, 2018
@ilayn
Copy link
Member

ilayn commented Jul 28, 2018

I guess everybody's happy now. In it goes then. Thanks a lot @endolith !!

@ilayn ilayn merged commit 72ced6e into scipy:master Jul 28, 2018
@endolith endolith deleted the fs_param branch July 28, 2018 16:42
@rgommers
Copy link
Member

Nice:) @endolith would you mind adding a few sentences to the release notes at https://github.com/scipy/scipy/wiki/Release-note-entries-for-SciPy-1.2.0?

@endolith endolith changed the title Add fs= parameter to filter design functions ENH: Add fs= parameter to filter design functions May 28, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement A new feature or improvement scipy.signal
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants