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

add RoundFullyContainedSampleSpans #38

Open
wants to merge 10 commits into
base: eph/improve-docs
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions docs/src/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ AlignedSpan
AlignedSpans.SpanRoundingMode
AlignedSpans.RoundInward
AlignedSpans.RoundSpanDown
AlignedSpans.RoundFullyContainedSampleSpans
AlignedSpan(sample_rate, span, mode::SpanRoundingMode)
AlignedSpans.ConstantSamplesRoundingMode
AlignedSpan(sample_rate, span, mode::ConstantSamplesRoundingMode)
Expand Down
2 changes: 2 additions & 0 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Rounding options:
* The alias `RoundInward = SpanRoundingMode(RoundUp, RoundDown)`, for example, constructs the largest span such that all samples are entirely contained within `span`.
* The alias `RoundSpanDown = SpanRoundingMode(RoundDown, RoundDown)` matches the rounding semantics of `TimeSpans.index_from_time(sample_rate, span)`.
* `ConstantSamplesRoundingMode` consists of a `RoundingMode` for the `start` alone. The `stop` is determined from the `start` plus a number of samples which is a function only of the sampling rate and the `duration` of the span.
* `RoundFullyContainedSampleSpans` This is a special rounding mode which differs from the other rounding modes by associating each sample with a _span_ (from the instant the sample occurs until just before the next sample occurs), and rounding
inwards to the "sample spans" that are fully contained in the input span.

Also provides a helper `consecutive_subspans` to partition an `AlignedSpan` into smaller consecutive `AlignedSpans` of equal size (except possibly the last one).

Expand Down
107 changes: 103 additions & 4 deletions src/AlignedSpans.jl
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ using Dates, Intervals, Onda
using TimeSpans: TimeSpans, start, stop, format_duration
using StructTypes, ArrowTypes

export SpanRoundingMode, RoundInward, RoundSpanDown, ConstantSamplesRoundingMode
export SpanRoundingMode, RoundInward, RoundSpanDown, ConstantSamplesRoundingMode,
RoundFullyContainedSampleSpans
export AlignedSpan, consecutive_subspans, n_samples, consecutive_overlapping_subspans

# Make our own method so we can add methods for Intervals without piracy
Expand Down Expand Up @@ -130,6 +131,68 @@ gives an `AlignedSpan` with indices `2:3`.
"""
const RoundSpanDown = SpanRoundingMode(RoundDown, RoundDown)

struct RoundingModeFullyContainedSampleSpans end

"""
RoundFullyContainedSampleSpans

This is a special rounding mode which differs from the other rounding modes by associating each sample
with a _span_ (from the instant the sample occurs until just before the next sample occurs), and rounding
inwards to the "sample spans" that are fully contained in the input span.

This is in some sense a stricter form of inward rounding than provided by [`RoundInward`](@ref).

## Example

Consider a signal with sample rate 1 Hz.

```
Index 1 2 3 4 5
Time (s) 0 1 2 3 4
```

Normally, we consider each sample to occur at an instant of time. For `RoundFullyContainedSampleSpans`,
we consider the span between a sample occurring and the next sample. (This usually does not make sense for digital sensors,
but can make sense for things like hypnograms in which a sample summarizes some region of time).

Thus, to each index (each sample), we associate a 1s closed-open span of time:
```
Index 1 2 3 4 5
Span (s) [0 )[1 )[2 )[3 )[4 )
```
The span durations are `1/sample_rate`, which is 1s in this example.

Now, consider the input time span 1.5s (inclusive) to 3.5s (exclusive).
Using brackets to highlight this span:

```
Index 1 2 3 4 5
Span (s) [0 )[1 )[2 )[3 )[4 )
Time (s) 0 1 [ 2 3 ) 4
```

This input span contains only one sample-span, namely `[2s, 3s)`. The span from `[1s, 2)` is not fully contained,
nor is the span from `[3s, 4s)`.

Thus, in this scenario, `RoundFullyContainedSampleSpans` would give a single sample, that at index 3, associated to `[2s, 3s)`.

In code, this span is described by

```jldoctest RoundFullyContainedSampleSpans
julia> using AlignedSpans, Dates, TimeSpans

julia> ts = TimeSpan(Millisecond(1500), Millisecond(3500))
TimeSpan(00:00:01.500000000, 00:00:03.500000000)

julia> aligned = AlignedSpan(1, ts, RoundFullyContainedSampleSpans)
AlignedSpan(1.0, 3, 3)

julia> AlignedSpans.indices(aligned)
3:3
```
"""
const RoundFullyContainedSampleSpans = RoundingModeFullyContainedSampleSpans()

"""
AlignedSpan(sample_rate::Number, first_index::Int, last_index::Int)

Expand Down Expand Up @@ -199,7 +262,7 @@ Note: `span` may be of any type which which provides methods for `AlignedSpans.s
If the input `span` is not sample-aligned, meaning the `start` and `stop` of the input span are not exact multiples of the sample rate, the results can be non-intuitive at first. Each underlying sample is considered to occur at some instant in time (not, e.g. over a span of duration of `1/sample_rate`), and the rounding is relative to the samples themselves.

So for example:

```jldoctest
julia> using TimeSpans, AlignedSpans, Dates

Expand All @@ -222,14 +285,15 @@ Note: `span` may be of any type which which provides methods for `AlignedSpans.s
How can this be? Clearly `Second(60) > Second(30) + Nanosecond(1)`, so what is going on?

To understand this, note that at 1/30Hz, the samples occur at 00:00, 00:30, 00:60, and so forth. The original input span
includes _two_ samples, the one at 00:00 and the one at 00:30. Rounding inward to include only samples that occur within the span means including both samples.
includes _two_ samples, the one at 00:00 and the one at 00:30. Rounding inward to include only samples that occur within the span means including both samples (see [`RoundFullyContainedSampleSpans`](@ref) for alternate behavior).

When converting back to TimeSpans, AlignedSpans canonicalizes `AlignedSpan(1/30, 1, 2)` as `TimeSpan(0, Second(60))`,
representing these two samples as the time from the first sample until just before the not-included sample-3 (which occurs at `Second(60)`), using that `TimeSpan`'s exclude their right endpoint.

AlignedSpans could make a different choice to e.g. canonicalize samples to only add an additional nanosecond,
but that has its own issue (e.g. `TimeSpan(AlignedSpan(sample_rate, 1, 2))` and `TimeSpan(AlignedSpan(sample_rate, 3, 4))` would not be consecutive).

See also [`AlignedSpan(sample_rate, span, mode::RoundingModeFullyContainedSampleSpans)`](@ref).
"""
function AlignedSpan(sample_rate, span, mode::SpanRoundingMode)
first_index = start_index_from_time(sample_rate, span, mode.start)
Expand All @@ -255,7 +319,7 @@ This is designed so that if `AlignedSpan(sample_rate, span, mode::ConstantSample

For this reason, we ask for `TimeSpans.duration(span)` to be defined, rather than a `n_samples(span)` function: the idea is that we want to only using the duration and the starting time, rather than the *actual* number of samples in this particular `span`.

In contrast, `AlignedSpan(sample_rate, span, RoundInward)` provides an `AlignedSpan` which includes only (and exactly) the samples which occur within `span`.
In contrast, [`RoundInward`](@ref) provides an `AlignedSpan` which includes only (and exactly) the samples which occur within `span`, while [`AlignedSpan(sample_rate, span, ::RoundingModeFullyContainedSampleSpans)`](@ref) provides an `AlignedSpan` consisting of the full spans from each sample until the next sample occurs that are contained in the input span.

If one wants to create a collection of consecutive, non-overlapping, `AlignedSpans` each with the same number of samples, then use [`consecutive_subspans`](@ref) instead.
"""
Expand All @@ -266,6 +330,41 @@ function AlignedSpan(sample_rate, span, mode::ConstantSamplesRoundingMode)
return AlignedSpan(sample_rate, first_index, last_index)
end

"""
AlignedSpan(sample_rate, span, mode::RoundingModeFullyContainedSampleSpans)

Constructs an `AlignedSpan` consisting of the full spans from each sample until the next sample occurs that are contained in the input span.

For example:
```jldoctest
julia> using TimeSpans, AlignedSpans, Dates

julia> sample_rate = 1/30
0.03333333333333333

julia> input = TimeSpan(0, Second(30) + Nanosecond(1))
TimeSpan(00:00:00.000000000, 00:00:30.000000001)

julia> aligned = AlignedSpan(sample_rate, input, RoundFullyContainedSampleSpans)
AlignedSpan(0.03333333333333333, 1, 1)

julia> TimeSpan(aligned)
TimeSpan(00:00:00.000000000, 00:00:30.000000000)
```

Here, in contrast to the behavior for [`RoundInward`](@ref) or [`RoundSpanDown`](@ref), only one sample is included,
since only one 30s-long "sample span" is fully included in the input span.

"""
function AlignedSpan(sample_rate, span, mode::RoundingModeFullyContainedSampleSpans)
first_index = start_index_from_time(sample_rate, span, RoundUp)
last_index = stop_index_from_time(sample_rate, span, mode)
if last_index < first_index
throw(ArgumentError("No samples lie within `span`"))
end
return AlignedSpan(sample_rate, first_index, last_index)
end

include("time_index_conversions.jl")
include("interop.jl")
include("utilities.jl")
Expand Down
22 changes: 22 additions & 0 deletions src/interop.jl
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,28 @@ function stop_index_from_time(sample_rate, interval::Interval,
return last_index
end

function stop_index_from_time(sample_rate, interval::Interval,
::RoundingModeFullyContainedSampleSpans)
# here we are in `RoundingModeFullyContainedSampleSpans` which means we treat each sample
# as a closed-open span starting from each sample to just before the next sample,
# and we are trying to round down to the last fully-enclosed sample span
last_index, _ = index_and_error_from_time(sample_rate, last(interval), RoundDown)

# `time_from_index(sample_rate, last_index + 1)` gives us the _start_ of the next sample
# we subtract 1 ns to get the (inclusive) _end_ of the span associated to this sample
end_of_span_time = time_from_index(sample_rate, last_index + 1) - Nanosecond(1)
# if this end isn't fully included in the interval, then we need to go back one
if !(end_of_span_time in interval)
@debug "Decrementing last index to fully fit within span"
Copy link
Member Author

Choose a reason for hiding this comment

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

just to check we are hitting this

julia> include("test/runtests.jl")
Precompiling AlignedSpans...
  1 dependency successfully precompiled in 2 seconds. 59 already precompiled.
┌ Debug: Decrementing last index to fully fit within span
└ @ AlignedSpans ~/AlignedSpans.jl/src/interop.jl:56
┌ Debug: Decrementing last index to fully fit within span
└ @ AlignedSpans ~/AlignedSpans.jl/src/interop.jl:56
┌ Debug: Decrementing last index to fully fit within span
└ @ AlignedSpans ~/AlignedSpans.jl/src/interop.jl:56
┌ Debug: Decrementing last index to fully fit within span
└ @ AlignedSpans ~/AlignedSpans.jl/src/interop.jl:56
┌ Debug: Decrementing last index to fully fit within span
└ @ AlignedSpans ~/AlignedSpans.jl/src/interop.jl:56
┌ Debug: Decrementing last index to fully fit within span
└ @ AlignedSpans ~/AlignedSpans.jl/src/interop.jl:56
┌ Debug: Decrementing last index to fully fit within span
└ @ AlignedSpans ~/AlignedSpans.jl/src/interop.jl:56
┌ Debug: Decrementing last index to fully fit within span
└ @ AlignedSpans ~/AlignedSpans.jl/src/interop.jl:56
(aligned, sample_rate, dur) = (AlignedSpan(1.0, 1, 100), 1, Second(1))
(aligned, sample_rate, dur) = (AlignedSpan(1.0, 1, 100), 1, Second(9))
(aligned, sample_rate, dur) = (AlignedSpan(1.0, 1, 100), 1, Millisecond(2500))
(aligned, sample_rate, dur) = (AlignedSpan(1.0, 1, 100), 1, Millisecond(1111))
Test Summary:   | Pass  Total  Time
AlignedSpans.jl | 5858   5858  7.3s

last_index -= 1
end

# We should never need to decrement twice, but we will assert this
end_of_span_time = time_from_index(sample_rate, last_index + 1) - Nanosecond(1)
@assert end_of_span_time in interval
return last_index
end

#####
##### Onda
#####
Expand Down
34 changes: 34 additions & 0 deletions test/time_index_conversions.jl
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,40 @@ end
end
end

@testset "RoundFullyContainedSampleSpans" begin
input = TimeSpan(0, Second(30))
aligned = AlignedSpan(1 / 30, input, RoundFullyContainedSampleSpans)
@test aligned == AlignedSpan(1 / 30, 1, 1)

input = TimeSpan(0, Second(30) + Nanosecond(1))
aligned = AlignedSpan(1 / 30, input, RoundFullyContainedSampleSpans)
@test aligned == AlignedSpan(1 / 30, 1, 1)

input = TimeSpan(0, Second(59))
aligned = AlignedSpan(1 / 30, input, RoundFullyContainedSampleSpans)
@test aligned == AlignedSpan(1 / 30, 1, 1)

input = TimeSpan(0, Second(60))
aligned = AlignedSpan(1 / 30, input, RoundFullyContainedSampleSpans)
@test aligned == AlignedSpan(1 / 30, 1, 2)

input = TimeSpan(Nanosecond(1), Second(60) + Nanosecond(1))
aligned = AlignedSpan(1 / 30, input, RoundFullyContainedSampleSpans)
@test aligned == AlignedSpan(1 / 30, 2, 2)

input = TimeSpan(0, Second(60) + Nanosecond(1))
aligned = AlignedSpan(1 / 30, input, RoundFullyContainedSampleSpans)
@test aligned == AlignedSpan(1 / 30, 1, 2)

input = TimeSpan(0, Second(60))
aligned = AlignedSpan(1 / 30, input, RoundFullyContainedSampleSpans)
@test aligned == AlignedSpan(1 / 30, 1, 2)

# No spans contained
input = TimeSpan(Nanosecond(1), Second(30) + Nanosecond(1))
@test_throws ArgumentError AlignedSpan(1 / 30, input, RoundFullyContainedSampleSpans)
Comment on lines +69 to +71
Copy link
Member

Choose a reason for hiding this comment

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

do we really want this to be an error? or should it be like an empty span somehow? do we throw on any other failures to construct an appropriate span?

end

# https://github.com/beacon-biosignals/TimeSpans.jl/blob/e3c999021336e51a08d118e6defb792e38ac1cc7/test/runtests.jl#L93-L96
@testset "time_from_index" begin
@test AlignedSpans.time_from_index(200, 0) == -Millisecond(5)
Expand Down
Loading