diff --git a/docs/make.jl b/docs/make.jl index c99ff63..946eccd 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -16,6 +16,7 @@ makedocs(modules = [FourierTools], "CZT" => "czt.md", "Fractional Fourier Transform" => "fractional.md", "Utility Functions" => "utils.md", + "Sliding Discrete Fourier Transforms" => "slidingdft.md", ] ) diff --git a/docs/src/slidingdft.md b/docs/src/slidingdft.md new file mode 100644 index 0000000..e231f18 --- /dev/null +++ b/docs/src/slidingdft.md @@ -0,0 +1,134 @@ +# Sliding Discrete Fourier Transforms + +Computation of [Sliding Discrete Fourer Transforms](https://en.wikipedia.org/wiki/Sliding_DFT) over one-dimensional series of values. + +## Usage + +The basic Sliding Discrete Fourier Transform (SDFT) of a one-dimensional series of values `x`, using a window of length `n`, is calculated as follows: + +**Step 1**: Setup the method for an SDFT of length `n`: + +```julia +method = SDFT(n) +``` + +**Step 2**: Create an iterator of the SDFT over `x`, with the function `sdft`. This is typically used in a loop: + +```julia +for spectrum in sdft(method, x) + # `spectrum` is a `Vector{Complex(eltype(x))}` of length `n` +end +``` + +### Considerations for stateful iterators + +By default, SDFTs are computed traversing sequentially the data series `x`, which can be any kind of iterator. In the case of stateful iterators (i.e. those that are modified upon each iteration, like `Base.Channel`s), `sdft(method, x)` will also be a stateful iterator that will "consume" as many items of `x` as the length of the computed DFT in the first iteration, and one additional item in every subsequent iteration. + +Apart from that consideration, it is safe to apply SDFTs to stateful iterators, since past samples of `x` already used in previous iterations, which are often required for the computations, are temporarily stored in an array — in internal variables that users do not need to deal with. + +## Methods for SDFTs + +```@autodocs +Modules = [SlidingDFTs] +Pages = ["sdft_implementations.jl"] +``` + +## Functions + +```@docs +sdft +``` + +## Developing new SDFTs + +### Theoretical basis + +An SDFT is generally implemented as a recursive equation, such that if $X_{i}[k]$ is the DFT of $x[j]$ for $j = i, \ldots, i+n$ at frequency $k$, the next iteration is: + +$X_{i+1}[k] = f(k, X_{1}[k], \ldots, X_{i}[k], x[i], \ldots x[i+n], x[i+n+1])$ + +Such an equation depends on: + +* The frequency $k$ +* The values of the DFT in one or more previous iterations, $X_{p}[k]$ for $p = 1, \ldots, i$. +* The values of the data series used in the most recent iteration, $x[j]$ for $j = i, \ldots, i+n$. +* The next value of the data series after the fragment used in the most recent iteration, $x[i+n+1]$. + +For instance, the [basic definition of the SDFT](https://www.researchgate.net/publication/3321463_The_sliding_DFT) uses the following formula: + +$X_{i+1}[k] = W[k] \cdot (X_{i}[k] + x[i+n] - x[i]),$ + +which depends only on the most recent DFT ($X_{i}[k]$), the first data point of the fragment used in that DFT ($x[i]$), and the next data point after that fragment ($x[i+n+1]$), plus a "twiddle" factor $W[k]$ that only depends on the frequency $k$, equal to $\exp(j2{\pi}k/n)$. +Other variations of the SDFT may use formulas that depend on previous iterations or other values of the data series in that fragment. + +### Implementation in Julia object types + +A method to compute an SDFT is defined by three kinds of object types: + +* One for the method, which contains the fixed parameters that are needed by the algorithm to compute the SDFT. +* Another for the iterator created by the function `sdft`, which binds a method with the target data series. +* And yet another for the state of the iterator, which holds the information needed by the algorithm that depends on the data series and changes at each iteration. + +The internals of this package, implemented in the module `FourierTools.SlidingDFTs`, take care of the design of the iterator and state types, and of the creation of their instances. The only thing that has to be defined to create a new kind of SDFT is the `struct` of the method with the fixed parameters, and a few function methods dispatching on that type. + +Before explaining how to define such a struct, it is convenient to know the functions that can be used to extract the information that is stored in the state of SDFT iterators. There is a function for each one of the three kinds of variables presented in the general recursive equation above. + +* [`SlidingDFTs.previousdft`](@ref) for the values of the DFT in previous iterations. +* [`SlidingDFTs.previousdata`](@ref) for the values of the data series used in the most recent iteration. +* [`SlidingDFTs.nextdata`](@ref) for the next value of the data series after the fragment used in the most recent iteration. + +For instance, the values used in the formula of the basic SDFT may be obtained from a `state` object as: +* `SlidingDFTs.previousdft(state, 0)` for $X_{i}$. +* `SlidingDFTs.previousdata(state, 0)` for $x[i]$. +* `SlidingDFTs.nextdata(state)` for $x[i+n+1]$. + +Notice that the second arguments of `previousdft` and `previousdata` might have been ommited in this case, since they are zero by default. + +For methods that need to know how many steps of the SDFT have been done, this can also be extracted with the function [`SlidingDFTs.iterationcount`](@ref). + +The design of the `struct` representing a new SDFT type is free, but it is required to implement the following methods dispatching on that type: + +* [`SlidingDFTs.windowlength`](@ref) to return the length of the DFT window. +* [`SlidingDFTs.updatepdf!`](@ref) with the implementation of the recursive equation, extracting the information stored in the state with the functions commented above (`previousdft`, etc.) . + +Depending on the functions that are used in the particular implementation of `updatepdf!` for a given type, the following methods should be defined too: + +* [`SlidingDFTs.dftback`](@ref) if `SlidingDFTs.previousdft` is used. +* [`SlidingDFTs.dataoffsets`](@ref) if `SlidingDFTs.previousdata` is used. + +### Example + +The formula of the basic SDFT formula could be implemented for a type `MyBasicSDFT` as follows: + +```julia +import FourierTools.SlidingDFTs: updatedft!, windowlength, nextdata, previousdata + +function udpatedft!(dft, x, method::MyBasicSDFT, state) + n = windowlength(method) + for k in eachindex(dft) + X_i = dft[k] + x_iplusn = nextdata(state) + x_i = previousdata(state) + Wk = exp(2π*im*k/n) + dft[k] = Wk * (X_i + x_iplusn - x_i) + end +end +``` + +(The type [`SDFT`](@ref) actually has as a similar, but not identical definition.) + +The implementation of `updatepdf!` given in the previous example does use `previousdata` - with the default offset value, equal to zero - so the following is required in this case: + +```julia +SlidingDFTs.dataoffsets(::MyBasicSDFT) = 0 +``` + +On the other hand there is no need to define `SlidingDFTs.dftback` in this case, since the interface of of `updatedft!` assumes that the most recent DFT is already contained in its first argument `dft`, so it is not necessary to use the function `previousdft` to get it. + +### SDFT development API + +```@autodocs +Modules = [FourierTools] +Pages = ["sdft_interface.jl"] +Filter = !isequal(SlidingDFTs.sdft) +``` diff --git a/src/FourierTools.jl b/src/FourierTools.jl index b57250a..de4e2f7 100644 --- a/src/FourierTools.jl +++ b/src/FourierTools.jl @@ -30,5 +30,7 @@ include("correlations.jl") include("damping.jl") include("czt.jl") include("fractional_fourier_transform.jl") +include("sdft_interface.jl") +include("sdft_implementations.jl") end # module diff --git a/src/sdft_implementations.jl b/src/sdft_implementations.jl new file mode 100644 index 0000000..3271ad6 --- /dev/null +++ b/src/sdft_implementations.jl @@ -0,0 +1,47 @@ +export sdft, SDFT + +import .SlidingDFTs +import .SlidingDFTs: sdft + +## Basic SDFT + +""" + SDFT(n) + +Basic method to compute a Sliding Discrete Fourier Transform of window length `n`, +through the recursive formula: + +\$X_{i+1}[k] = e^{j2{\\pi}k/n} \\cdot (X_{i}[k] + x[i+n] - x[i])\$ + +The transfer function for the `k`-th bin of this method is: + +\$H(z) = \\frac{1 - z^{-n}}{1 - e^{j2{\\pi}k/n} z^{-1}}\$ + +## References + +Jacobsen, E. & Lyons, R. (2003). "The sliding DFT," *IEEE Signal Processing Magazine*, 20(2), 74-80. +doi:[10.1109/MSP.2003.1184347](https://doi.org/10.1109/MSP.2003.1184347) +""" +struct SDFT{T,C} + n::T + factor::C +end + +function SDFT(n) + factor = exp(2π*im/n) + SDFT(n, factor) +end + +# Required functions + +SlidingDFTs.windowlength(method::SDFT) = method.n + +function SlidingDFTs.updatedft!(dft, x, method::SDFT{T,C}, state) where {T,C} + twiddle = one(C) + for k in eachindex(dft) + dft[k] = twiddle * (dft[k] + SlidingDFTs.nextdata(state) - SlidingDFTs.previousdata(state)) + twiddle *= method.factor + end +end + +SlidingDFTs.dataoffsets(::SDFT) = 0 \ No newline at end of file diff --git a/src/sdft_interface.jl b/src/sdft_interface.jl new file mode 100644 index 0000000..ea4ad58 --- /dev/null +++ b/src/sdft_interface.jl @@ -0,0 +1,289 @@ +module SlidingDFTs + +using FFTW # AbstractFFTs would suffice +import Base: iterate + + +# exports + +## Required functions + +""" + SlidingDFTs.windowlength(method) + +Return the length of the window used by `method`. +""" +function windowlength end + +""" + updatedft!(dft, x, method, state) + +Update the values of a sliding Discrete Fourier Transform (DFT) of a data series, +according to the algorithm of the provided method, for a given state of the sliding DFT. + +`dft` is a mutable collection of complex values with length equal to `windowlength(method)`, +containing the value returned by the last iteration of the sliding DFT. + +`x` is the data series for which the sliding DFT is computed, at least as long as +`windowlength(method)`. + +`method` is the object that defines the method to compute the sliding DFT. + +`state` is an object generated automatically at each iteration of an object created by +`sdft(method, x)`, containing the information that is needed +to compute the new values of the sliding DFT, together with `x`. +That information can be extracted from `state` with the following functions: + +* [`SlidingDFTs.previousdft`](@ref) to get the DFTs of previous iterations. +* [`SlidingDFTs.previousdata`](@ref) to get a previous value of the data series. +* [`SlidingDFTs.nextdata`](@ref) to get the next value of the data series. +""" +function updatedft! end + +## Conditionally required functions + +""" + dftback(method) + +Return an integer or a vector of positive integers with the indices of the previous iterations +that are needed by the given method to compute a sliding DFT. + +If the code of `SlidingDFTs.updatepdf!` for the type of `method` uses the function `SlidingDFTs.previousdft`, +this function must return the integers that are used as the third argument (`back`) of that function. + +If that function is not needed, this one may return `nothing` to reduce memory allocations. +""" +dftback(::Any) = nothing + +""" + dataoffsets(method) + +Return an integer or a vector of integers with the offsets of data samples +that are needed by the given method to compute a sliding DFT. + +If the code of `SlidingDFTs.updatepdf!` that dispatches on the type of `method` uses the function `SlidingDFTs.previousdata`, +this function must return the integers that are used as the third argument (`offset`) of that function. + +If that function is not needed (no past samples are used), this one may return `nothing` to reduce memory allocations. +""" +dataoffsets(::Any) = nothing + + +## States + +# State data used by `updatedft!` +struct StateData{H, F, N} + dfthistory::H # record of back dfts if needed, otherwise nothing + fragment::F # previous data fragment if needed, otherwise nothing + nextdatapoint::N # next data point after the fragment + windowlength::Int # window length + iteration::Int # iteration count +end + +hasdfthistory(::StateData{Nothing}) = false +hasdfthistory(::StateData) = true +haspreviousdata(::StateData{<:Any, Nothing}) = false +haspreviousdata(::StateData) = true + +""" + previousdft(state[, back=0]) + +Return the DFT computed in the most recent iteration +of the sliding DFT represented by `state`, or in a number of previous +iterations equal to `back`. + +If the DFT computed in the most recent iteration corresponds to the +fragment of the data series between its positions `i` and `i+n`, +then this function returns its DFT for the fragment between `i-back` and `i+n-back`. +""" +function previousdft(state::StateData, back=0) + !hasdfthistory(state) && throw(ErrorException( + "previous DFT results not available; the SDFT method has no valid definition of `SlidingDFTs.dftback`" + )) + dfthistory = state.dfthistory + n = state.windowlength + nh = length(dfthistory) ÷ n + offset = rem(state.iteration - back - 1, nh) + rg = (offset * n) .+ (1:n) + return view(dfthistory, rg) +end + +""" + previousdata(state[, offset=0]) + +Return the first value of the fragment of the data series that was used +in the most recent iteration of the sliding DFT represented by `state`, +or at `offset` positions after the beginning of that fragment. + +If the DFT computed in the most recent iteration corresponds to the +fragment of the data series between its positions `i` and `i+n`, +then this function returns the `i+offset`-th value. +""" +function previousdata(state::StateData, offset=0) + !haspreviousdata(state) && throw(ErrorException( + "previous data values not available; the SDFT method has no valid definition of `SlidingDFTs.dataoffsets`" + )) + fragment = state.fragment + adjustedoffset = rem(offset + state.iteration - 1, length(fragment)) + return fragment[firstindex(fragment) + adjustedoffset] +end + + +""" + nextdata(state) + +Return the next value after the fragment of the data series that was used +in the most recent iteration of the sliding DFT represented by `state`. + +If the DFT computed in the most recent iteration corresponds to the +fragment of the data series between its positions `i` and `i+n`, +then this function returns the `i+n+1`-th value. + +There is no defined behavior if such value does not exist +(i.e. if the end of a finite data series was reached). +""" +nextdata(state::StateData) = state.nextdatapoint + +""" + iterationcount(state) + +Return the number of iterations done for the sliding DFT represented by `state`. + +If the DFT computed in the most recent iteration corresponds to the +fragment of the data series between its positions `i` and `i+n`, +then this function returns the number `i`. +""" +iterationcount(state) = state.iteration + +# State of the iterator +struct SDFTState{T, H, F, S} + dft::Vector{Complex{T}} # dft returned + dfthistory::H # (see StateData) + fragment::F # (see StateData) + nextdatastate::S # state used to get the next data point + iteration::Int # iteration count +end + +# Return the updated state of the iterator, or `nothing` if the data series is consumed. +function updatestate(state::SDFTState, method, x) + nextiter = iterate(x, state.nextdatastate) + if nextiter === nothing + return nothing + end + nextdatapoint, nextdatastate = nextiter + dft = state.dft + dfthistory = state.dfthistory + fragment = state.fragment + n = windowlength(method) + statedata = StateData(dfthistory, fragment, nextdatapoint, n, state.iteration) + updatedft!(dft, x, method, statedata) + updatedfthistory!(dfthistory, dft, n, state.iteration) + updatefragment!(fragment, nextdatapoint, state.iteration) + return SDFTState(dft, dfthistory, fragment, nextdatastate, state.iteration + 1) +end + +function updatedfthistory!(dfthistory, dft, n, iteration) + offset = rem((iteration - 1) * n, length(dfthistory)) + dfthistory[(1:n) .+ offset] .= dft +end + +function updatedfthistory!(::Nothing, args...) end + +function updatefragment!(fragment, nextdatapoint, iteration) + n = length(fragment) + offset = rem(iteration - 1, n) + fragment[begin + offset] = nextdatapoint +end + +function updatefragment!(::Nothing, ::Any, ::Any) end + +## Iterator + +struct SDFTIterator{M, T} + method::M + data::T + safe::Bool +end + +getmethod(iterator::SDFTIterator) = iterator.method +getdata(iterator::SDFTIterator) = iterator.data +issafe(iterator::SDFTIterator) = iterator.safe + +Base.IteratorSize(::SDFTIterator{<:Any,T}) where T = IteratorSizeWrapper(Base.IteratorSize(T)) +IteratorSizeWrapper(::Base.HasShape) = Base.HasLength() # do not inherit shape +IteratorSizeWrapper(iteratorsizetrait) = iteratorsizetrait # inherit everything else + +function Base.length(iterator::SDFTIterator) + method = getmethod(iterator) + data = getdata(iterator) + return length(data) - windowlength(method) + 1 +end + +Base.eltype(::SDFTIterator{M,T}) where {M,T} = Vector{Complex{eltype(T)}} + +Base.isdone(iterator::SDFTIterator) = Base.isdone(getdata(iterator)) +Base.isdone(iterator::SDFTIterator, state::SDFTState) = Base.isdone(getdata(iterator), state.nextdatastate) + +""" + sdft(method, x[, safe=true]) + +Return an iterator to produce a sliding DFT of `x` using the given `method`. +If `safe == true` (the default behavior), this iterator produces a new vector at each iteration. + +Set the optional argument `safe=false` to improve performance by reducing allocations, +at the expense of unexpected behavior if the resulting vector is mutated between iterations. +""" +sdft(method, x, safe=true) = SDFTIterator(method, x, safe) + + +function iterate(itr::SDFTIterator) + windowed_data, datastate = initialize(itr) + dft = fft(windowed_data) + method = getmethod(itr) + backindices = dftback(method) + dfthistory = create_dfthistory(dft, backindices) + state = SDFTState(dft, dfthistory, windowed_data, datastate, 1) + returned_dft = itr.safe ? copy(dft) : dft + return returned_dft, state +end + +function iterate(itr::SDFTIterator, state) + method = getmethod(itr) + x = getdata(itr) + newstate = updatestate(state, method, x) + if newstate === nothing + return nothing + end + dft = newstate.dft + returned_dft = itr.safe ? copy(dft) : dft + return returned_dft, newstate +end + +# Get the window of the first chunk of data and the state of the data iterator at the end +function initialize(itr) + x = getdata(itr) + n = windowlength(getmethod(itr)) + firstiteration = iterate(x) + if firstiteration === nothing + throw(ErrorException("insufficient data to compute a sliding DFT of window length = $n")) + end + datapoint, datastate = firstiteration + windowed_data = fill(datapoint, n) + i = 1 + while i < n + iteration = iterate(x, datastate) + if iteration === nothing + throw(ErrorException("insufficient data to compute a sliding DFT of window length = $n")) + end + datapoint, datastate = iteration + i += 1 + windowed_data[i] = datapoint + end + return windowed_data, datastate +end + +create_dfthistory(::Any, ::Nothing) = nothing +create_dfthistory(dft, n::Integer) = repeat(dft, n+1) +create_dfthistory(dft, indices) = create_dfthistory(dft, maximum(indices)) + +end # module SlidingDFTs diff --git a/test/runtests.jl b/test/runtests.jl index 23d6a98..0c8ec32 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -25,5 +25,6 @@ include("czt.jl") include("nfft_tests.jl") include("fractional_fourier_transform.jl") include("fourier_filtering.jl") +include("sdft.jl") return diff --git a/test/sdft.jl b/test/sdft.jl new file mode 100644 index 0000000..321382b --- /dev/null +++ b/test/sdft.jl @@ -0,0 +1,32 @@ +@testset "Sliding DFT" begin + + # Piecewise sinusoidal signal + function signal(x) + if x < 1 + 5*cos(4π*x) + elseif x < 2 + (-2x+7)*cos(2π*(x^2+1)) + else + 3*cos(10π*x) + end + end + + y = signal.(range(0, 3, length=61)) + n = 20 + sample_offsets = (0, 20, 40) + dfty_sample = [fft(view(y, (1:n) .+ offset)) for offset in sample_offsets] + + # Compare SDFT + @testset "SDFT" begin + method = SDFT(n) + dfty = collect(sdft(method, y)) + @testset "stateless" for i in eachindex(sample_offsets) + @test dfty[1 + sample_offsets[i]] ≈ dfty_sample[i] + end + dfty = collect(sdft(method, Iterators.Stateful(y))) + @testset "stateful" for i in eachindex(sample_offsets) + @test dfty[1 + sample_offsets[i]] ≈ dfty_sample[i] + end + end + +end \ No newline at end of file