-
Notifications
You must be signed in to change notification settings - Fork 7
/
eeg_series.jl
296 lines (256 loc) · 10.2 KB
/
eeg_series.jl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
# Note: This is copied from https://github.com/MakieOrg/TopoPlots.jl/pull/3 because they apparently cannot do a review in ~9month...
"""
eeg_matrix_to_dataframe(data::Matrix, label)
Helper function converting a matrix (channel x times) to a tidy `DataFrame` with columns `:estimate`, `:time` and `:label`.
**Return Value:** `DataFrame`.
"""
function eeg_matrix_to_dataframe(data, label)
df = DataFrame(data', label)
df[!, :time] .= 1:nrow(df)
df = stack(df, Not([:time]); variable_name = :label, value_name = "estimate")
return df
end
"""
eeg_topoplot_series(data::DataFrame,
fig,
data::DataFrame,
Δbin;
y = :erp,
label = :label,
col = :time,
row = nothing,
col_labels = false,
row_labels = false,
rasterize_heatmaps = true,
combinefun = mean,
xlim_topo,
ylim_topo,
topoplot_attributes...,
)
eeg_topoplot_series!(fig, data::DataFrame, Δbin; kwargs..)
Plot a series of topoplots.
The function automatically takes the `combinefun = mean` over the `:time` column of `data` in `Δbin` steps.
- `fig` \\
Figure object. \\
- `data::DataFrame`\\
Needs the columns `:time` and `y(=:erp)`, and `label(=:label)`. \\
If `data` is a matrix, it is automatically cast to a dataframe, time bins are in samples, labels are `string.(1:size(data,1))`.
- `Δbin = :time` \\
In `:time` units, specifying the time steps. All other keyword arguments are passed to the `EEG_TopoPlot` recipe. \\
In most cases, the user should specify the electrode positions with `positions = pos`.
- `col`, `row = :time` \\
Specify the field to be divided into columns and rows. The default is `col=:time` to split by the time field and `row = nothing`. \\
Useful to split by a condition, e.g. `...(..., col=:time, row=:condition)` would result in multiple (as many as different values in `df.condition`) rows of topoplot series.
- `row_labels`, `col_labels = false` \\
Indicate whether there should be labels in the plots in the first column to indicate the row value and in the last row to indicate the time (typically timerange).
# Example
```julia-repl
df = DataFrame(:erp => repeat(1:63, 100), :time => repeat(1:20, 5 * 63), :label => repeat(1:63, 100)) # simulated data
pos = [(1:63) ./ 63 .* (sin.(range(-2 * pi, 2 * pi, 63))) (1:63) ./ 63 .* cos.(range(-2 * pi, 2 * pi, 63))] .* 0.5 .+ 0.5 # simulated electrode positions
pos = [Point2.(pos[k, 1], pos[k, 2]) for k in 1:size(pos, 1)]
eeg_topoplot_series(df, 5; positions = pos)
```
**Return Value:** `Tuple{Figure, Vector{Any}}`.
"""
function eeg_topoplot_series(
data::Union{<:Observable,<:DataFrame,<:AbstractMatrix},
Δbin;
figure = NamedTuple(),
kwargs...,
)
return eeg_topoplot_series!(Figure(; figure...), data, Δbin; kwargs...)
end
# allow to specify Δbin as an keyword for nicer readability
eeg_topoplot_series(
data::Union{<:Observable,<:DataFrame,<:AbstractMatrix};
Δbin,
kwargs...,
) = eeg_topoplot_series(data, Δbin; kwargs...)
# AbstractMatrix
function eeg_topoplot_series!(fig, data::AbstractMatrix, Δbin; kwargs...)
return eeg_topoplot_series!(fig, data, string.(1:size(data, 1)), Δbin; kwargs...)
end
# convert a 2D Matrix to the dataframe
function eeg_topoplot_series(data::AbstractMatrix, labels, Δbin; kwargs...)
return eeg_topoplot_series(eeg_matrix_to_dataframe(data, labels), Δbin; kwargs...)
end
function eeg_topoplot_series!(fig, data::AbstractMatrix, labels, Δbin; kwargs...)
return eeg_topoplot_series!(fig, eeg_matrix_to_dataframe(data, labels), Δbin; kwargs...)
end
function eeg_topoplot_series!(
fig,
data::Union{<:Observable{<:DataFrame},<:DataFrame},
Δbin;
y = :erp,
label = :label,
col = :time,
row = nothing,
col_labels = false,
row_labels = false,
rasterize_heatmaps = true,
combinefun = mean,
xlim_topo = (-0.25, 1.25),
ylim_topo = (-0.25, 1.25),
interactive_scatter = nothing,
highlight_scatter = false,#Observable([0]),
topoplot_attributes...,
)
# cannot be made easier right now, but Simon promised a simpler solution "soonish"
axisOptions = (
aspect = 1,
xgridvisible = false,
xminorgridvisible = false,
xminorticksvisible = false,
xticksvisible = false,
xticklabelsvisible = false,
xlabelvisible = false,
ygridvisible = false,
yminorgridvisible = false,
yminorticksvisible = false,
yticksvisible = false,
yticklabelsvisible = false,
ylabelvisible = false,
leftspinevisible = false,
rightspinevisible = false,
topspinevisible = false,
bottomspinevisible = false,
xpanlock = true,
ypanlock = true,
xzoomlock = true,
yzoomlock = true,
xrectzoom = false,
yrectzoom = false,
limits = (xlim_topo, ylim_topo),
)
# aggregate the data over time bins
# using same colormap + contour levels for all plots
data = _as_observable(data)
if eltype(to_value(data)[!, col]) <: Number
data_mean = @lift(
df_timebin(
$data,
Δbin;
col_y = y,
fun = combinefun,
grouping = [label, col, row],
)
)
else
# categorical detected, no binning necessary
data_mean = data
end
(q_min, q_max) = extract_colorrange(to_value(data_mean), y)
topoplot_attributes = merge(
(
colorrange = (q_min, q_max),
interp_resolution = (128, 128),
contours = (levels = range(q_min, q_max; length = 7),),
),
topoplot_attributes,
)
# do the col/row plot
select_col = isnothing(col) ? 1 : unique(to_value(data_mean)[:, col])
select_row = isnothing(row) ? 1 : unique(to_value(data_mean)[:, row])
if interactive_scatter != nothing
@assert isa(interactive_scatter, Observable)
end
axlist = []
for r = 1:length(select_row)
for c = 1:length(select_col)
ax = Axis(fig[:, :][r, c]; axisOptions...)
# select one topoplot
sel = 1 .== ones(size(to_value(data_mean), 1)) # select all
if !isnothing(col)
sel = sel .&& (to_value(data_mean)[:, col] .== select_col[c]) # subselect
end
if !isnothing(row)
sel = sel .&& (to_value(data_mean)[:, row] .== select_row[r]) # subselect
end
df_single = @lift($data_mean[sel, :])
# select labels
labels = to_value(df_single)[:, label]
# select data
d_vec = @lift($df_single[:, y])
# plot it
if highlight_scatter != false || interactive_scatter != nothing
# pos = @lift topoplot_attributes[:positions][highlight_scatter]
strokecolor = Observable(repeat([:black], length(to_value(d_vec))))
highlight_feature = (; strokecolor = strokecolor)
if :label_scatter ∈ keys(topoplot_attributes)
topoplot_attributes = merge(
topoplot_attributes,
(;
label_scatter = merge(
topoplot_attributes[:label_scatter],
highlight_feature,
)
),
)
else
topoplot_attributes =
merge(topoplot_attributes, (; label_scatter = highlight_feature))
end
end
h_topo = eeg_topoplot!(ax, d_vec, labels; topoplot_attributes...)
@debug typeof(h_topo) typeof(ax)
if rasterize_heatmaps
h_topo.plots[1].plots[1].rasterize = true
end
if r == length(select_row) && col_labels
ax.xlabel = string(to_value(df_single)[1, col])
ax.xlabelvisible = true
end
if c == 1 && length(select_row) > 1 && row_labels
#@show df_single
ax.ylabel = string(to_value(df_single)[1, row])
ax.ylabelvisible = true
end
if interactive_scatter != false
on(events(h_topo).mousebutton) do event
if event.button == Mouse.left && event.action == Mouse.press
plt, p = pick(h_topo)
if isa(plt, Makie.Scatter) && plt == h_topo.plots[1].plots[3]
plt.strokecolor[] .= :black
plt.strokecolor[][p] = :white
notify(plt.strokecolor) # not sure why this is necessary, but oh well..
interactive_scatter[] = (r, c, p)
end
end
end
end
push!(axlist, ax)
end
end
if typeof(fig) != GridLayout && typeof(fig) != GridLayoutBase.GridSubposition
colgap!(fig.layout, 0)
end
return fig, axlist
end
"""
df_timebin(df, Δbin; col_y = :erp, fun = mean, grouping = [])
Split or combine `DataFrame` according to equally spaced time bins.
Arguments:
- `df::AbstractTable`\\
With columns `:time` and `col_y` (default `:erp`), and all columns in `grouping`;
- `Δbin`\\
Bin size in `:time` units;
- `col_y = :erp` \\
The column to combine over (with `fun`);
- `fun = mean()`\\
Function to combine.
- `grouping = []`\\
Vector of symbols or strings, columns to group the data by before aggregation. Values of `nothing` are ignored.
**Return Value:** `DataFrame`.
"""
function df_timebin(df, Δbin; col_y = :erp, fun = mean, grouping = [])
tmin = minimum(df.time)
tmax = maximum(df.time)
bins = range(; start = tmin, step = Δbin, stop = tmax)
df = deepcopy(df) # cut seems to change stuff inplace
df.time = cut(df.time, bins; extend = true)
grouping = grouping[.!isnothing.(grouping)]
df_m = combine(groupby(df, unique([:time, grouping...])), col_y => fun)
#df_m = combine(groupby(df, Not(y)), y=>fun)
rename!(df_m, names(df_m)[end] => col_y) # remove the _fun part of the new column
return df_m
end