Skip to content

Commit c09954f

Browse files
authored
Add keyword indexing + constructor (#177)
1 parent ec68c29 commit c09954f

File tree

8 files changed

+85
-31
lines changed

8 files changed

+85
-31
lines changed

Project.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ IterTools = "c8e1da08-722c-5040-9ed9-7db0dc04731e"
99
RangeArrays = "b3c3ace0-ae52-54e7-9d0b-2c1406fd6b9d"
1010

1111
[compat]
12-
IntervalSets = "0.1, 0.2, 0.3"
12+
IntervalSets = "0.1, 0.2, 0.3, 0.4"
1313
IterTools = "1"
1414
RangeArrays = "0.3"
1515
julia = "1"

README.md

+41-28
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,43 @@
1-
# AxisArrays
1+
# AxisArrays.jl
22

33
[![Build Status](https://travis-ci.org/JuliaArrays/AxisArrays.jl.svg?branch=master)](https://travis-ci.org/JuliaArrays/AxisArrays.jl) [![Coverage Status](https://coveralls.io/repos/github/JuliaArrays/AxisArrays.jl/badge.svg?branch=master)](https://coveralls.io/github/JuliaArrays/AxisArrays.jl?branch=master)
44

55
This package for the Julia language provides an array type (the `AxisArray`) that knows about its dimension names and axis values.
6-
This allows for indexing with the axis name without incurring any runtime overhead.
7-
AxisArrays can also be indexed by the values of their axes, allowing column names or interval selections.
6+
This allows for indexing by name without incurring any runtime overhead.
87
This permits one to implement algorithms that are oblivious to the storage order of the underlying arrays.
8+
AxisArrays can also be indexed by the values along their axes, allowing column names or interval selections.
9+
910
In contrast to similar approaches in [Images.jl](https://github.com/timholy/Images.jl) and [NamedArrays.jl](https://github.com/davidavdav/NamedArrays), this allows for type-stable selection of dimensions and compile-time axis lookup. It is also better suited for regularly sampled axes, like samples over time.
1011

1112
Collaboration is welcome! This is still a work-in-progress. See [the roadmap](https://github.com/JuliaArrays/AxisArrays.jl/issues/7) for the project's current direction.
1213

13-
### Notice regarding `axes`
14+
### Note about `Axis{}` and keywords
15+
16+
An `AxisArray` stores an object of type `Axis{:name}` for each dimension,
17+
containing both the name (a `Symbol`) and the "axis values" (an `AbstractVector`).
18+
These types are what made compile-time lookup possible.
19+
Instead of providing them explicitly, it is now possible to use keyword arguments
20+
for both construction and indexing:
21+
22+
```julia
23+
V = AxisArray(rand(10); row='a':'j') # AxisArray(rand(10), Axis{:row}('a':'j'))
24+
V[row='c'] == V[Axis{:row}('c')] == V[row=3] == V[3]
25+
```
26+
27+
### Note about `axes()` and `indices()`
1428

15-
Since Julia version 0.7, the name `axes` is exported by default from `Base`
16-
with a meaning (and behavior) that is distinct from how AxisArrays has been
17-
using it. Since you cannot simultaneously be `using` the same name from the two
18-
different modules, Julia will issue a warning, and it'll error if you try to
19-
use `axes` without qualification:
29+
The function `AxisArrays.axes` returns the tuple of such `Axis` objects.
30+
Since Julia version 0.7, `Base.axes(V) == (1:10,)` gives instead the range of possible
31+
ordinary integer indices. (This was called `Base.indices`.) Since both names are exported,
32+
this collision results in a warning if you try to use `axes` without qualification:
2033

2134
```julia
22-
julia> axes([])
35+
julia> axes([1,2])
2336
WARNING: both AxisArrays and Base export "axes"; uses of it in module Main must be qualified
2437
ERROR: UndefVarError: axes not defined
2538
```
2639

27-
Packages that are upgrading to support 0.7+ and use AxisArrays should follow
28-
this upgrade path:
40+
Packages that are upgrading to support Julia 0.7+ should:
2941

3042
* Replace all uses of the `axes` function with the fully-qualified `AxisArrays.axes`
3143
* Replace all uses of the deprecated `indices` function with the un-qualified `axes`
@@ -38,14 +50,13 @@ path to whatever the new name will be.
3850
## Example of currently-implemented behavior:
3951

4052
```julia
41-
julia> using Pkg; Pkg.add("AxisArrays")
42-
julia> using AxisArrays, Unitful
43-
julia> import Unitful: s, ms, µs
44-
julia> using Random: MersenneTwister
53+
julia> using Pkg; pkg"add AxisArrays Unitful"
54+
julia> using AxisArrays, Unitful, Random
4555

46-
julia> rng = MersenneTwister(123) # Seed a random number generator for repeatable examples
47-
julia> fs = 40000 # Generate a 40kHz noisy signal, with spike-like stuff added for testing
48-
julia> y = randn(rng, 60*fs+1)*3
56+
julia> fs = 40000; # Generate a 40kHz noisy signal, with spike-like stuff added for testing
57+
julia> import Unitful: s, ms, µs
58+
julia> rng = Random.MersenneTwister(123); # Seed a random number generator for repeatable examples
59+
julia> y = randn(rng, 60*fs+1)*3;
4960
julia> for spk = (sin.(0.8:0.2:8.6) .* [0:0.01:.1; .15:.1:.95; 1:-.05:.05] .* 50,
5061
sin.(0.8:0.4:8.6) .* [0:0.02:.1; .15:.1:1; 1:-.2:.1] .* 50)
5162
i = rand(rng, round(Int,.001fs):1fs)
@@ -55,7 +66,7 @@ julia> for spk = (sin.(0.8:0.2:8.6) .* [0:0.01:.1; .15:.1:.95; 1:-.05:.05] .* 50
5566
end
5667
end
5768

58-
julia> A = AxisArray([y 2y], Axis{:time}(0s:1s/fs:60s), Axis{:chan}([:c1, :c2]))
69+
julia> A = AxisArray(hcat(y, 2 .* y); time = (0s:1s/fs:60s), chan = ([:c1, :c2]))
5970
2-dimensional AxisArray{Float64,2,...} with axes:
6071
:time, 0.0 s:2.5e-5 s:60.0 s
6172
:chan, Symbol[:c1, :c2]
@@ -87,14 +98,14 @@ information to enable all sorts of fancy behaviors. For example, we can specify
8798
indices in *any* order, just so long as we annotate them with the axis name:
8899

89100
```julia
90-
julia> A[Axis{:time}(4)]
101+
julia> A[time=4] # or A[Axis{:time}(4)]
91102
1-dimensional AxisArray{Float64,1,...} with axes:
92103
:chan, Symbol[:c1, :c2]
93104
And data, a 2-element Array{Float64,1}:
94105
1.37825
95106
2.75649
96107

97-
julia> A[Axis{:chan}(:c2), Axis{:time}(1:5)]
108+
julia> A[chan = :c2, time = 1:5] # or A[Axis{:chan}(:c2), Axis{:time}(1:5)]
98109
1-dimensional AxisArray{Float64,1,...} with axes:
99110
:time, 0.0 s:2.5e-5 s:0.0001 s
100111
And data, a 5-element Array{Float64,1}:
@@ -127,9 +138,13 @@ julia> AxisArrays.axes(ans, 1)
127138
AxisArrays.Axis{:time,StepRangeLen{Quantity{Float64, Dimensions:{𝐓}, Units:{s}},Base.TwicePrecision{Quantity{Float64, Dimensions:{𝐓}, Units:{s}}},Base.TwicePrecision{Quantity{Float64, Dimensions:{𝐓}, Units:{s}}}}}(5.0e-5 s:2.5e-5 s:0.0002 s)
128139
```
129140

130-
You can also index by a single value on an axis using `atvalue`. This will drop
131-
a dimension. Indexing with an `Interval` type retains dimensions, even
132-
when the ends of the interval are equal:
141+
You can also index by a single value using `atvalue(t)`.
142+
This function is not needed for categorical axes like `:chan` here,
143+
as `:c1` is a `Symbol` which can't be confused with an integer index.
144+
145+
Using `atvalue()` will drop a dimension (like using a single integer).
146+
Indexing with an `Interval(lo, hi)` type retains dimensions, even
147+
when the ends of the interval are equal (like using a range `1:1`):
133148

134149
```julia
135150
julia> A[atvalue(2.5e-5s), :c1]
@@ -220,8 +235,6 @@ across the columns.
220235

221236
## Indexing
222237

223-
### Indexing axes
224-
225238
Two main types of Axes supported by default include:
226239

227240
* Categorical axis -- These are vectors of labels, normally symbols or
@@ -238,7 +251,7 @@ headers.
238251

239252
```julia
240253
B = AxisArray(reshape(1:15, 5, 3), .1:.1:0.5, [:a, :b, :c])
241-
B[Axis{:row}(0.2..0.4)] # restrict the AxisArray along the time axis
254+
B[row = (0.2..0.4)] # restrict the AxisArray along the time axis
242255
B[0.0..0.3, [:a, :c]] # select an interval and two of the columns
243256
```
244257

src/core.jl

+5
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,11 @@ function AxisArray(A::AbstractArray{T,N}, names::NTuple{N,Symbol}, steps::NTuple
239239
AxisArray(A, axs...)
240240
end
241241

242+
# Alternative constructor, takes names as keywords:
243+
AxisArray(A; kw...) = AxisArray(A, nt_to_axes(kw.data)...)
244+
@generated nt_to_axes(nt::NamedTuple) =
245+
Expr(:tuple, (:(Axis{$(QuoteNode(n))}(getfield(nt, $(QuoteNode(n))))) for n in nt.names)...)
246+
242247
AxisArray(A::AxisArray) = A
243248
AxisArray(A::AxisArray, ax::Vararg{Axis, N}) where N =
244249
AxisArray(A.data, ax..., last(Base.IteratorsMD.split(axes(A), Val(N)))...)

src/indexing.jl

+17-1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ Base.IndexStyle(::Type{AxisArray{T,N,D,Ax}}) where {T,N,D,Ax} = IndexStyle(D)
4242
# Cartesian iteration
4343
Base.eachindex(A::AxisArray) = eachindex(A.data)
4444

45+
# Avoid an ambiguity -- [email protected] takes .. from EllipsisNotation,
46+
# which defines A[..] for any AbstractArray, like this:
47+
Base.getindex(A::AxisArray, ::typeof(..)) = A
48+
4549
"""
4650
reaxis(A::AxisArray, I...)
4751
@@ -129,7 +133,6 @@ end
129133
@propagate_inbounds getindex_converted(A, idxs...) = A.data[idxs...]
130134
@propagate_inbounds setindex!_converted(A, v, idxs...) = (A.data[idxs...] = v)
131135

132-
133136
# First is indexing by named axis. We simply sort the axes and re-dispatch.
134137
# When indexing by named axis the shapes of omitted dimensions are preserved
135138
# TODO: should we handle multidimensional Axis indexes? It could be interpreted
@@ -155,6 +158,19 @@ function Base.reshape(A::AxisArray, ::Val{N}) where N
155158
AxisArray(reshape(A.data, Val(N)), Base.front(axN))
156159
end
157160

161+
# Keyword indexing, reconstructs the Axis{}() objects
162+
@propagate_inbounds Base.view(A::AxisArray; kw...) =
163+
view(A, kw_to_axes(parent(A), kw.data)...)
164+
@propagate_inbounds Base.getindex(A::AxisArray; kw...) =
165+
getindex(A, kw_to_axes(parent(A), kw.data)...)
166+
@propagate_inbounds Base.setindex!(A::AxisArray, val; kw...) =
167+
setindex!(A, val, kw_to_axes(parent(A), kw.data)...)
168+
169+
function kw_to_axes(A::AbstractArray, nt::NamedTuple)
170+
length(nt) == 0 && throw(BoundsError(A, ())) # Trivial case A[] lands here
171+
nt_to_axes(nt)
172+
end
173+
158174
### Indexing along values of the axes ###
159175

160176
# Default axes indexing throws an error

test/core.jl

+5
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ B = AxisArray([1 4; 2 5; 3 6], (:x, :y), (0.2, 100))
132132
B = AxisArray([1 4; 2 5; 3 6], (:x, :y), (0.2, 100), (-3,14))
133133
@test axisnames(B) == (:x, :y)
134134
@test axisvalues(B) == (-3:0.2:-2.6, 14:100:114)
135+
# Keyword constructor
136+
C = AxisArray([1 4; 2 5; 3 6], x=10:10:30, y=[:a, :b])
137+
@test axisnames(C) == (:x, :y)
138+
@test axisvalues(C) == (10:10:30, [:a, :b])
139+
@test @inferred(AxisArray(parent(C), x=1:3, y=1:2)) isa AxisArray
135140

136141
@test AxisArrays.HasAxes(A) == AxisArrays.HasAxes{true}()
137142
@test AxisArrays.HasAxes([1]) == AxisArrays.HasAxes{false}()

test/indexing.jl

+9
Original file line numberDiff line numberDiff line change
@@ -302,3 +302,12 @@ a = [2, 3, 7]
302302
@test a[idx] 6.2
303303
aa = AxisArray(a, :x)
304304
@test aa[idx] 6.2
305+
306+
# Keyword indexing
307+
A = AxisArray([1 2; 3 4], Axis{:x}(10:10:20), Axis{:y}(["c", "d"]))
308+
@test @inferred(A[x=1, y=1]) == 1
309+
@test @inferred(A[x=1]) == [1, 2]
310+
@test axisnames(A[x=1]) == (:y,)
311+
@test @inferred(view(A, x=1)) == [1,2]
312+
@test parent(view(A, x=1)) isa SubArray
313+
@test @inferred(A[x=atvalue(20), y=atvalue("d")]) == 4

test/readme.jl

+6
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,14 @@ for spk = (sin.(0.8:0.2:8.6) .* [0:0.01:.1; .15:.1:.95; 1:-.05:.05] .* 50,
1515
end
1616

1717
A = AxisArray([y 2y], Axis{:time}(0s:1s/fs:60s), Axis{:chan}([:c1, :c2]))
18+
A = AxisArray(hcat(y, 2 .* y); time = (0s:1s/fs:60s), chan = ([:c1, :c2]))
19+
1820
A[Axis{:time}(4)]
21+
A[time=4]
22+
1923
A[Axis{:chan}(:c2), Axis{:time}(1:5)]
24+
A[chan = :c2, time = 1:5]
25+
2026
ax = A[40µs .. 220µs, :c1]
2127
AxisArrays.axes(ax, 1)
2228
A[atindex(-90µs .. 90µs, 5), :c2]

test/runtests.jl

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ using Random
55
import IterTools
66

77
@testset "AxisArrays" begin
8-
VERSION >= v"1.0.0-" && @test isempty(detect_ambiguities(AxisArrays, Base, Core))
8+
VERSION >= v"1.1" && @test isempty(detect_ambiguities(AxisArrays, Base, Core))
99

1010
@testset "Core" begin
1111
include("core.jl")

0 commit comments

Comments
 (0)