diff --git a/.gitignore b/.gitignore index 43d172fdf..371cfa8f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +# Ignore different folders ImportTestData .bundle/ .jekyll-cache/ @@ -5,4 +6,7 @@ Gemfile Gemfile.lock _site/ vendor/ + +# Ignore all the temporary files *.lyx~ +*.m~ \ No newline at end of file diff --git a/doc/PsPM.bib b/doc/PsPM.bib index 24cdda7bc..8308b17b2 100644 --- a/doc/PsPM.bib +++ b/doc/PsPM.bib @@ -10,7 +10,34 @@ @comment{jabref-meta: databaseType:bibtex;} +@techreport{xia:2020, + type = {preprint}, + title = {Saccadic {Scanpath} {Length}: {An} {Index} for {Human} {Threat} {Conditioning}}, + shorttitle = {Saccadic {Scanpath} {Length}}, + url = {https://osf.io/qtpve}, + abstract = {Threat-conditioned cues are thought to capture overt attention in a bottom-up process. Quantification of this phenomenon typically relies on cue competition paradigms. Here, we sought to exploit gaze patterns during presentation of a visual conditioned stimulus only, in order to quantify threat conditioning. To this end, we capitalise on a summary statistic of visual search during CS presentation, scanpath length. In a simple delayed threat conditioning paradigm with full-screen monochrome conditioned stimuli (CS), we observed shorter scanpath length during CS+ compared to CS- presentation. Retrodictive validity, i.e. effect size to distinguish CS+ and CS-, was maximised by considering a 2-s time window before US onset. Taking into account the shape of the scan speed response resulted in similar effect size. The mechanism underlying shorter scanpath length appeared to be longer fixation duration and more fixation on the screen center during CS+ relative to CS- presentation. These findings were replicated in a second experiment with similar setup, and further confirmed in a third experiment using full-screen fractals as CS. This experiment included an extinction session during which scanpath differences disappeared. In a fourth experiment with auditory CS and instruction to fixate screen centre, no scanpath length differences were observed. In conclusion, our study suggests scanpath length as a visual search summary statistic, which may be used as complementary measure to quantify threat conditioning with retrodictive validity similar to that of skin conductance responses.}, + urldate = {2020-07-09}, + institution = {PsyArXiv}, + author = {Xia, Yanfang and Melinscak, Filip and Bach, Dominik R}, + month = feb, + year = {2020}, + doi = {10.31234/osf.io/qtpve}, +} +@article{schutz:2011, + Author = {Schutz, A. C. and Braun, D. I. and Gegenfurtner, K. R.}, + title = {Eye movements and perception: {A} selective review}, + Volume = {11}, + issn = {1534-7362}, + shorttitle = {Eye movements and perception}, + url = {http://jov.arvojournals.org/Article.aspx?doi=10.1167/11.5.9}, + doi = {10.1167/11.5.9}, + Number = {5}, + urldate = {2019-12-16}, + Journal = {Journal of Vision}, + Year = {2011}, + pages = {1--30}, +} @article{Homan:2017aa, Author = {Homan, Philipp and Lin, Qi and Murrough, James W and Soleimani, Laili and Bach, Dominik R and Clem, Roger L and Schiller, Daniela}, diff --git a/doc/PsPM_Developers_Guide.lyx b/doc/PsPM_Developers_Guide.lyx index 140ed842e..52ac602cf 100644 --- a/doc/PsPM_Developers_Guide.lyx +++ b/doc/PsPM_Developers_Guide.lyx @@ -98,7 +98,7 @@ Developer's Guide \begin_layout Standard \align center -Version 4.3.0 +Version 5.0.0 \end_layout \begin_layout Standard @@ -24238,7 +24238,7 @@ Eshref Yozdemir \begin_inset Text \begin_layout Plain Layout -pspm_get_sp_speed +pspm_get_sps \end_layout \end_inset diff --git a/doc/PsPM_Manual.lyx b/doc/PsPM_Manual.lyx index 6e62f0044..8d90ff0c8 100644 --- a/doc/PsPM_Manual.lyx +++ b/doc/PsPM_Manual.lyx @@ -103,7 +103,7 @@ PsPM: Psychophysiological Modelling \begin_layout Standard \align center -Version 4.3.0 +Version 5.0.0 \end_layout \begin_layout Standard @@ -150,8 +150,8 @@ literal "false" \size larger Dominik R Bach, Giuseppe Castegnetti, Laure Ciernik, Samuel Gerster, Saurabh - Khemka, Christoph Korn, Tobias Moser, Philipp C Paulus, Ivan Rojkov, Matthias - Staib, Eshref Yozdemir and collaborators + Khemka, Christoph Korn, Samuel Maxwell, Tobias Moser, Philipp C Paulus, + Ivan Rojkov, Matthias Staib, Eshref Yozdemir and collaborators \size default \begin_inset Newline newline @@ -5028,6 +5028,159 @@ model each trial as separate event type such that the response latency can be estimated per trial, rather than per condition \end_layout +\end_deeper +\begin_layout Section +Scanpath speed (SPS) model +\end_layout + +\begin_layout Subsection +General +\end_layout + +\begin_layout Standard +Various salient stimuli, e.g. + threat-conditioned cues, capture overt attention in a bottom-up process + +\begin_inset CommandInset citation +LatexCommand cite +key "schutz:2011" +literal "false" + +\end_inset + +, as shown in multiple stimulus competition paradigms using aversive cues. + This yields a possibility to assess threat memory by overt attention. + In cue competition paradigms, overt attention is usually measured as gaze + patterns; for example, fixation duration, saccade initiation latency, and + number of erroneous saccades. + Here, we implement a model that captures gaze patterns during exclusive + presentation of a single cue, by assessing scanpath speed, the length of + scanpath per time unit, quantified as degree visual angle per second +\begin_inset CommandInset citation +LatexCommand cite +key "xia:2020" +literal "false" + +\end_inset + +. + In our publication, we refer to scanpath length, which is the integral + of scanpath speed over time. + Since we used constant cue presentation times in our publication, average + scanpath speed and scanpath length were identical up to a multiplicative + constant. +\end_layout + +\begin_layout Subsection +Model for fear-conditioned SPS +\end_layout + +\begin_layout Standard +Scanpath speed/length allow differentiating CS+ and CS- in fear-conditioning + paradigms with visual cues, and without fixation instructions or fixation + cross. + PsPM implements two simple models that build on the GLM inversion algorithm + (see +\begin_inset CommandInset ref +LatexCommand ref +reference "subsec:General-linear-convolution" +plural "false" +caps "false" +noprefix "false" + +\end_inset + +). + The first model is to compute average scanpath speed during a 2-s time + window before US (shock) delivery. + This was validated on three independent samples in +\begin_inset CommandInset citation +LatexCommand cite +key "xia:2020" +literal "false" + +\end_inset + +. + The model is implemented with a simple boxcar response function without + mean-centering and is the default option. + Parameter estimates can be interpreted as average scan path speed per second, + and if they are multiplied with 2 then the resulting values can be interpreted + as scan path length during the 2 s preceding the US. + We also implement a gamma RF that describes the shape of the scanpath speed + during the CS-US interval better than the boxcar function, but did not + yield higher retrodictive validity in inferring the CS-/+ difference. + This RF was fitted to the data of 21 participants and validated in an independe +nt sample; it is described by a gamma probability density function: +\begin_inset Formula +\[ +y=\frac{A}{\mu^{k}\Gamma(k)}(t-t_{0})^{k-1}e^{-\frac{t-t_{0}}{\mu}} +\] + +\end_inset + + with parameters +\begin_inset Formula $k=10.0913,µ=0.4213,t0=-1.9020+(SOA–3)$ +\end_inset + + +\begin_inset CommandInset citation +LatexCommand cite +key "xia:2020" +literal "false" + +\end_inset + +, where +\begin_inset Formula $SOA$ +\end_inset + + is the duration of the interval between CS onset and US onset in seconds. + +\end_layout + +\begin_layout Subsection +Data preconditioning +\end_layout + +\begin_layout Standard +PsPM provides procedures to import scan path speed directly. + More commonly, it is convenient to import gaze coordinates in pixels, distance + units, or visual angle, and convert them to scanpath speed. + This requires interpolation of missing values which is done automatically. + Conversion from pixels to mm or visual angle in degree is also possible. + No filtering or smoothing is required for both RF during model inversion + with GLM. +\end_layout + +\begin_layout Subsection +Recommendations +\end_layout + +\begin_layout Itemize +Experimental design: +\end_layout + +\begin_deeper +\begin_layout Itemize +use visual cues during fear conditioning and avoid fixation guides. +\end_layout + +\begin_layout Itemize +use a SOA longer than 2 s. + Tested SOAs are 3.0 s and 3.5 s. +\end_layout + +\end_deeper +\begin_layout Itemize +Model setup: +\end_layout + +\begin_deeper +\begin_layout Itemize +use the default boxcar function and specify custom SOA. +\end_layout + \end_deeper \begin_layout Part User Guide @@ -5916,7 +6069,7 @@ The default value has been changed to 0 in PsPM revision r803 to reduce \begin_inset CommandInset ref LatexCommand ref -reference "subsec:Pupil-Preprocessing" +reference "subsec:Pupil-Size-Preprocessing" plural "false" caps "false" noprefix "false" @@ -8629,10 +8782,10 @@ replace: Replace previously existing preprocessed channel. \end_layout \begin_layout Subsection -Pupil Preprocessing +Pupil Size Preprocessing \begin_inset CommandInset label LatexCommand label -name "subsec:Pupil-Preprocessing" +name "subsec:Pupil-Size-Preprocessing" \end_inset @@ -9340,6 +9493,117 @@ Defines whether the processed data should be added as a new channel or replace (default: add) \end_layout +\begin_layout Subsection +Gaze Preprocessing +\begin_inset CommandInset label +LatexCommand label +name "subsec:Gaze-Preprocessing" + +\end_inset + + +\end_layout + +\begin_layout Standard + +\shape italic +Related function: +\shape default + +\family typewriter +pspm_convert_gaze_distance +\end_layout + +\begin_layout Standard +Convert gaze data from degree or distance units such as pixels or mm into + degree data or scanpath speed. + The gaze data must be oriented such that the point 0,0 relates the bottom-left + of the screen, and the maximal x and y points relate to the top-right of + the screen. + These functions do not accept input channels, they will seacrch the provided + data file for channels with the gaze_[x|y]_[l|r] chantype in the specified + from unit. +\end_layout + +\begin_layout Subsubsection* +Data file +\end_layout + +\begin_layout Standard +Specify the PsPM datafile containing the gaze recordings. +\end_layout + +\begin_layout Subsubsection* +Conversion +\end_layout + +\begin_layout Paragraph* +Degree To Scanpath Speed +\end_layout + +\begin_layout Standard +Convert existing degree data to scanpath speed, stored as chantype: sps_[l|r] +\end_layout + +\begin_layout Paragraph* +Distance To Degree +\end_layout + +\begin_layout Standard +Convert distance x/y coordinate data to degrees stored as chantype: gaze_[x|y]_[ +l|r], unit: degree +\end_layout + +\begin_layout Itemize +From - unit to covert from, [ pixel, mm, cm, inches, m ] +\end_layout + +\begin_layout Itemize +Width - The width of the screen in mm +\end_layout + +\begin_layout Itemize +Height - The height of the screen in mm +\end_layout + +\begin_layout Itemize +Distance - The distance to the screen from the subjects eyes, in mm +\end_layout + +\begin_layout Paragraph* +Distance To Scanpath Speed +\end_layout + +\begin_layout Standard +Convert distance x/y coordinate data to scanpath speed, stored as chantype: + sps_[l|r] +\end_layout + +\begin_layout Itemize +From - unit to covert from, [ pixel, mm, cm, inches, m ] +\end_layout + +\begin_layout Itemize +Width - The width of the screen in mm +\end_layout + +\begin_layout Itemize +Height - The height of the screen in mm +\end_layout + +\begin_layout Itemize +Distance - The distance to the screen from the subjects eyes, in mm +\end_layout + +\begin_layout Subsubsection* +Channel Action +\end_layout + +\begin_layout Standard +Defines whether the processed data should be added as a new channel or replace + the last existing channel of the same chantype and unit. +\end_layout + \begin_layout Section First level \end_layout @@ -10170,8 +10434,8 @@ SOA \begin_layout Standard Specify custom SOA for response function. - Tested values are 3.5s, 4s and 6s. - Default: 3.5s + Tested values are 3.5 s, 4 s and 6 s. + Default: 3.5 s \end_layout \begin_layout Subsection @@ -10650,19 +10914,33 @@ Basis functions for the peripheral model. \end_layout \begin_layout Paragraph* -SPSRF_BOX +Average scanpath speed +\end_layout + +\begin_layout Standard +This option implements a boxcar function over 2 s before the US time point, + and yields the averaged scan path speed over that interval (i.e. + the scan path length over that interval, divided by 2 s). + (default). +\end_layout + +\begin_layout Paragraph* +SPSRF_FC \end_layout \begin_layout Standard -SPSRF with normal boxfunction (default). +This option implements a gamma function for fear-conditioned scan path speed + responses, time-locked to the end of the CS-US interval. \end_layout \begin_layout Paragraph* -SPSRF_GAMMA +SOA \end_layout \begin_layout Standard -SEBRF with gammafunction. +Specify custom SOA for response function. + Tested values are 3.0 s and 3.5 s. + Default: 3.5 s \end_layout \begin_layout Subsection @@ -12731,7 +13009,7 @@ Related function: \shape default \family typewriter -pspm_pp +pspm_pp, pspm_prepdata, pspm_simple_qa \end_layout \begin_layout Standard @@ -12825,6 +13103,30 @@ Maximum slope: Maximum slope in . \end_layout +\begin_layout Itemize +Missing epochs filename: Name of the .mat file where the missing epochs will + be stored. +\end_layout + +\begin_layout Itemize +Deflection threshold: Amplitude threshold which excludes epochs from the + filter if the overall deflection over this epoch is below it. + Default: 0.1 . +\end_layout + +\begin_layout Itemize +Island threshold: Duration threshold (seconds) which determines the maximum + length of data between NaN epochs. + Islands of data shorter than this threshold will be removed. + Default: 0 s (no threshold). +\end_layout + +\begin_layout Itemize +Expand epochs: Duration threshold (seconds) which determines by how much + data on the flanks of artefact epochs will be removed. + Default: 0.5 s. +\end_layout + \begin_layout Subsubsection* Overwrite Existing File \end_layout @@ -19091,6 +19393,180 @@ pspm_compute_visual_angle to mm. \end_layout +\begin_layout Section +PsPM Version 5.0.0 +\end_layout + +\begin_layout Subsection* +New Features +\end_layout + +\begin_layout Itemize +Allow +\family typewriter +pspm_data_editor +\family default + to load an epoch file. +\end_layout + +\begin_layout Itemize +Allow +\family typewriter +pspm_simple_qa +\family default + to suppress classification of discretisation oscillations as artefacts, + to expand artefact windows, and to automatically remove small data islands + embedded in artefacts. +\end_layout + +\begin_layout Itemize +Allow +\family typewriter +pspm_simple_qa +\family default + to store the epochs of data that are filtered out into an output +\family typewriter +.mat +\family default + file. + The accompanying GUI editor is available under 'Artefact removal' in the + tools section. +\end_layout + +\begin_layout Itemize +Allow +\family typewriter +pspm_convert_gaze_distance +\family default + to convert from distance units to degrees or scanpath speed. + The accompanying GUI editor is available under 'Gaze Convert' in the data + preprocessing section. +\end_layout + +\begin_layout Itemize +Add the possibility to select the flank in the +\family typewriter +Import +\family default + module of the GUI of PsPM. +\end_layout + +\begin_layout Itemize +Add the possibility to import DSV (delimiter separated values) as well as + CSV (comma separated values) data files. +\end_layout + +\begin_layout Subsection* +Changes +\end_layout + +\begin_layout Itemize +Split the data convert functionality in tools into the 'Gaze Convert' and + 'Pupil Size convert' in the data preprocessing section. +\end_layout + +\begin_layout Itemize +Factor out blink/saccade edge filtering logic out of +\family typewriter +pspm_get_eyelink +\family default + to +\family typewriter +pspm_blink_saccade_filt +\family default +. +\end_layout + +\begin_layout Itemize +Deprecate edge filtering functionality in +\family typewriter +pspm_get_eyelink +\family default +. +\end_layout + +\begin_layout Itemize +Make +\family typewriter +pspm_pupil_correct_eyelink +\family default + use the last gaze channel when there are multiple gaze x (similarly y) + channels in the file. +\end_layout + +\begin_layout Itemize +Make +\family typewriter +pspm_extract_segments +\family default + return NaN percentages and not ratios. +\end_layout + +\begin_layout Subsection* +Bugfixes +\end_layout + +\begin_layout Itemize +Scale DCM plot XTick by sample rate. +\end_layout + +\begin_layout Itemize +Correct index offset when dealing with the descending flank for continuous + markers. +\end_layout + +\begin_layout Itemize +Allow +\family typewriter +pspm_display +\family default + to plot any type of marker channels. +\end_layout + +\begin_layout Itemize +Fix +\family typewriter +pspm_display +\family default + behaviour when user tries to load data with less channels than the data + he/she is currently displaying. +\end_layout + +\begin_layout Itemize +Fix +\family typewriter +pspm_split_sessions +\family default + behaviour when the intertrial interval in the data is random. + Add an option +\family typewriter +randomITI +\family default + (0 or 1) which in the latter case it forces the function to evaluate the + mean marker distance using all the markers and not per session. + Default value: 0. +\end_layout + +\begin_layout Itemize +Remove +\family typewriter +startsWith +\family default + and +\family typewriter +endsWith +\family default + from all functions for a better backward compatibility. +\end_layout + +\begin_layout Itemize +Fix bug in +\family typewriter +pspm_trim +\family default + which was wrongly defining the starting trimming point index. +\end_layout + \begin_layout Part Acknowledgements \end_layout diff --git a/doc/PsPM_release_checklist.md b/doc/PsPM_release_checklist.md index 6455615a8..c3ef11fdf 100644 --- a/doc/PsPM_release_checklist.md +++ b/doc/PsPM_release_checklist.md @@ -5,20 +5,20 @@ any revisions (commits) that implement/fix something new in the release branch, don't merge these branches back to trunk. Therefore, it is sensible to create the release branch after making absolutely sure that no new stuff will be implemented. -- [x] Update version number & date in - - [x] `pspm_msg` - - [x] `pspm_quit` - - [x] `pspm.fig`: Load `pspm.fig` into MATLAB, update `fig.Children(9).String` and save back to `pspm.fig` - - [x] Manual and Developers Guide: front pages -- [x] Make sure both manuals are updated -- [x] Add release notes section of the new version to manual (at the end) -- [x] Get the manual reviewed -- [x] Create manual and dev guide PDFs using `lyx` -- [x] Check if underscores and dashes are visible in newly added manual sections -- [x] Create git branch -- [x] Delete `.asv` files if there are any (?) -- [x] Create zip of the new branch -- [x] Make sure zip doesn't contain any svn related files. As a sanity check, the zip file +- [ ] Update version number & date in + - [ ] `pspm_msg` + - [ ] `pspm_quit` + - [ ] `pspm.fig`: Load `pspm.fig` into MATLAB using `openfig`, update `fig.Children(9).String` and save back to `pspm.fig` + - [ ] Manual and Developers Guide: front pages +- [ ] Make sure both manuals are updated +- [ ] Add release notes section of the new version to manual (at the end) and release_notes.tex +- [ ] Get the manual reviewed +- [ ] Create manual and dev guide PDFs using `lyx` +- [ ] Check if underscores and dashes are visible in newly added manual sections +- [ ] Create git branch +- [ ] Delete `.asv` files if there are any (?) +- [ ] Create zip of the new branch +- [ ] Make sure zip doesn't contain any svn related files. As a sanity check, the zip file should be roughly the same size as the previous version zip files (maybe slightly larger but not much) - [ ] Create a release on GitHub - [ ] Upload zip to GitHub diff --git a/doc/release_notes.tex b/doc/release_notes.tex index b99d9ba8b..f6739ccbc 100644 --- a/doc/release_notes.tex +++ b/doc/release_notes.tex @@ -65,7 +65,7 @@ framexleftmargin=1pt, frame=l} \renewcommand{\lstlistingname}{Listing} -\title{PsPM 4.3.0 Release notes} +\title{PsPM 5.0.0 Release notes} \begin{document} \maketitle @@ -607,6 +607,57 @@ \subsection*{Bugfixes} an error in the conversion factor of pixels wrt. to mm. \end{itemize} +\section{PsPM Version 5.0.0} + +\subsection*{New Features} +\begin{itemize} +\item Allow \texttt{pspm\_data\_editor} to load an epoch file. +\item Allow \texttt{pspm\_simple\_qa} to suppress classification of discretisation oscillations as artefacts, to expand artefact windows, and to automatically remove small data islands embedded in artefacts. +\item Allow \texttt{pspm\_simple\_qa} to store the epochs of data that are +filtered out into an output \texttt{.mat} file. The accompanying GUI +editor is available under 'Artefact removal' in the tools section. +\item Allow \texttt{pspm\_convert\_gaze\_distance} to convert from distance +units to degrees or scanpath speed. The accompanying GUI editor is +available under 'Gaze Convert' in the data preprocessing section. +\item Add the possibility to select the flank in the \texttt{Import} module +of the GUI of PsPM. +\item Add the possibility to import DSV (delimiter separated values) as +well as CSV (comma separated values) data files. +\end{itemize} + +\subsection*{Changes} +\begin{itemize} +\item Split the data convert functionality in tools into the 'Gaze Convert' +and 'Pupil Size convert' in the data preprocessing section. +\item Factor out blink/saccade edge filtering logic out of \texttt{pspm\_get\_eyelink} +to \texttt{pspm\_blink\_saccade\_filt}. +\item Deprecate edge filtering functionality in \texttt{pspm\_get\_eyelink}. +\item Make \texttt{pspm\_pupil\_correct\_eyelink} use the last gaze channel +when there are multiple gaze x (similarly y) channels in the file. +\item Make \texttt{pspm\_extract\_segments} return NaN percentages and not +ratios. +\end{itemize} + +\subsection*{Bugfixes} +\begin{itemize} +\item Scale DCM plot XTick by sample rate. +\item Correct index offset when dealing with the descending flank for continuous +markers. +\item Allow \texttt{pspm\_display} to plot any type of marker channels. +\item Fix \texttt{pspm\_display} behaviour when user tries to load data +with less channels than the data he/she is currently displaying. +\item Fix \texttt{pspm\_split\_sessions} behaviour when the intertrial interval +in the data is random. Add an option \texttt{randomITI} (0 or 1) which +in the latter case it forces the function to evaluate the mean marker +distance using all the markers and not per session. Default value: +0. +\item Remove \texttt{startsWith} and \texttt{endsWith} from all functions +for a better backward compatibility. +\item Fix bug in \texttt{pspm\_trim} which was wrongly defining the starting +trimming point index. +\end{itemize} + + \section{References} \bibliographystyle{pnas2009} diff --git a/src/Import/eyelink/import_eyelink.m b/src/Import/eyelink/import_eyelink.m index 44a391b1b..0ea1c6706 100644 --- a/src/Import/eyelink/import_eyelink.m +++ b/src/Import/eyelink/import_eyelink.m @@ -181,7 +181,7 @@ file_info.record_time = '00:00:00'; curr_line = str(linefeeds(line_ctr) + 1 : linefeeds(line_ctr + 1) - 1 - has_backr); tab = sprintf('\t'); - while startsWith(curr_line, '**') + while strncmp(curr_line, '**', numel('**')) if contains(curr_line, 'DATE') colon_idx = strfind(curr_line, ':'); date_part = curr_line(colon_idx + 1 : end); @@ -252,11 +252,11 @@ saccades_R = false(size(dataraw, 1), 1); end - sblink_indices = find(startsWith(messages, 'SBLINK')); - eblink_indices = find(startsWith(messages, 'EBLINK')); - ssacc_indices = find(startsWith(messages, 'SSACC')); - esacc_indices = find(startsWith(messages, 'ESACC')); - msg_indices = startsWith(messages, 'MSG'); + sblink_indices = find(strncmp(messages, 'SBLINK', numel('SBLINK'))); + eblink_indices = find(strncmp(messages, 'EBLINK', numel('EBLINK'))); + ssacc_indices = find(strncmp(messages, 'SSACC', numel('SSACC'))); + esacc_indices = find(strncmp(messages, 'ESACC', numel('ESACC'))); + msg_indices = strncmp(messages, 'MSG', numel('MSG')); for name = {'RECCFG', 'ELCLCFG', 'GAZE_COORDS', 'THRESHOLDS', 'ELCL_', 'PUPIL_DATA_TYPE', '!MODE'} msg_indices = msg_indices & ~contains(messages, name); end @@ -337,7 +337,7 @@ end function [msg_linenums_split, messages_split] = split_messages_to_sessions(msg_linenums, messages) - start_indices = [0 find(startsWith(messages, 'START'))]; + start_indices = [0 find(strncmp(messages, 'START', numel('START')))]; reccfg_indices = find(contains(messages, 'RECCFG')); split_indices = []; @@ -368,7 +368,7 @@ msg = messages{sess_idx}{i}; parts = split(msg); - if startsWith(msg, 'START') + if strncmp(msg, 'START',numel('START')) chan_info{sess_idx}.start_time = str2num(parts{2}); chan_info{sess_idx}.start_msg_idx = prev_n_messages + i; elseif contains(msg, 'GAZE_COORDS') diff --git a/src/Import/smi/read_smi_events.m b/src/Import/smi/read_smi_events.m index 7ae97046a..9ab00bd97 100644 --- a/src/Import/smi/read_smi_events.m +++ b/src/Import/smi/read_smi_events.m @@ -141,7 +141,7 @@ while true if isempty(curr_line) go_forward = 1; - elseif startsWith(curr_line, 'Table Header for') + elseif strncmp(curr_line, 'Table Header for', numel('Table Header for')) go_forward = 2; else break diff --git a/src/Import/viewpoint/import_viewpoint.m b/src/Import/viewpoint/import_viewpoint.m index f289f8eb1..7cb546b55 100644 --- a/src/Import/viewpoint/import_viewpoint.m +++ b/src/Import/viewpoint/import_viewpoint.m @@ -96,7 +96,7 @@ [columns, column_ids, line_ctr] = parse_header(str, line_ctr, linefeeds, has_backr); eyesObserved = 'A'; - if any(startsWith(column_ids, 'B')) + if any(strncmp(column_ids, 'B', numel('B'))) eyesObserved = 'AB'; end @@ -114,7 +114,7 @@ begidx = linefeeds(msg_line) + 1; str(begidx : begidx + 1) = '/'; end - C = textscan(str, fmt_str, 'Delimiter', '\t', 'CollectOutput', 1, 'CommentStyle', '//'); + C = textscan(str, fmt_str, 'CollectOutput', 1, 'CommentStyle', '//'); dataraw = C{1}; marker = C{2}; @@ -137,7 +137,7 @@ file_info.screenSize.ymax = -1; curr_line = str(linefeeds(line_ctr) + 1 : linefeeds(line_ctr + 1) - 1 - has_backr); tab = sprintf('\t'); - while startsWith(curr_line, '3') + while strncmp(curr_line, '3', numel('3')) if contains(curr_line, 'TimeStamp') parts = split(curr_line, tab); date_part = parts{3}; @@ -164,11 +164,11 @@ curr_line = str(linefeeds(line_ctr) + 1 : linefeeds(line_ctr + 1) - 1 - has_backr); tab = sprintf('\t'); n_feeds = numel(linefeeds); - while ~startsWith(curr_line, '10') - if startsWith(curr_line, '6') + while ~strncmp(curr_line, '10', numel('10')) + if strncmp(curr_line, '6', numel('6')) parts = split(curr_line, tab); column_ids = parts(2 : end); - elseif startsWith(curr_line, '5') + elseif strncmp(curr_line, '5',numel('5')) parts = split(curr_line, tab); columns = parts(2 : end); end @@ -235,7 +235,7 @@ read_numeric_columns = ['TYPE'; column_ids]; fmt_array = cell(1, numel(read_numeric_columns)); fmt_array(:) = {'%f'}; - region_indices = find(endsWith(read_numeric_columns, 'RI')); + region_indices = find(~cellfun(@isempty,regexp(read_numeric_columns,'RI$'))); marker_index = find(strcmp(read_numeric_columns, 'MRK')); str_index = find(strcmp(read_numeric_columns, 'STR')); @@ -275,7 +275,9 @@ end elseif msg_type == 14 continue; - elseif (contains(msgline, 'Saccade') || contains(msgline, 'Blink')) && endsWith(msgline, 'sec') + elseif (contains(msgline, 'Saccade') || ... + contains(msgline, 'Blink')) && ... + ~isempty(regexp(msgline,'sec$')) timestamp = str2double(parts{2}); msg = parts{3}; forbeg_idx = strfind(msg, ' for '); diff --git a/src/backroom/blink_saccade_filtering.m b/src/backroom/blink_saccade_filtering.m new file mode 100644 index 000000000..78faac4be --- /dev/null +++ b/src/backroom/blink_saccade_filtering.m @@ -0,0 +1,35 @@ +function [out_data_mat] = blink_saccade_filtering(data_mat, column_names, mask_chans, n_samples, fn_is_left) + if nargin == 4 + fn_is_left = @(x) strcmp(x(end-1:end), '_l'); + end + + out_data_mat = expand_mask_chans(data_mat, column_names, mask_chans, n_samples); + out_data_mat = set_blinks_saccades_to_nan(out_data_mat, column_names, mask_chans); +end + +function data = expand_mask_chans(data, column_names, mask_chans, offset) + for chan = mask_chans + col_idx = find(strcmpi(column_names, chan{1})); + data(:, col_idx) = expand_mask(data(:, col_idx), offset); + end +end + +function mask = expand_mask(mask, offset) + diffmask = diff(mask); + indices_to_expand_towards_left = find(diffmask == 1) + 1; + indices_to_expand_towards_right = find(diffmask == (-1)); + + for ii = 1:numel(indices_to_expand_towards_left) + idx = indices_to_expand_towards_left(ii); + begidx = max(1, idx - offset); + endidx = max(1, idx - 1); + mask(begidx : endidx) = true; + end + ndata = numel(mask); + for ii = 1:numel(indices_to_expand_towards_right) + idx = indices_to_expand_towards_right(ii); + begidx = min(ndata, idx + 1); + endidx = min(ndata, idx + offset); + mask(begidx : endidx) = true; + end +end diff --git a/src/backroom/set_blinks_saccades_to_nan.m b/src/backroom/set_blinks_saccades_to_nan.m index 567c47ef6..3cd179984 100644 --- a/src/backroom/set_blinks_saccades_to_nan.m +++ b/src/backroom/set_blinks_saccades_to_nan.m @@ -11,8 +11,12 @@ % gaze_x_r, etc. % column_names: Name of each column in the input data_mat. % mask_chans: Names of blink and saccade channels. + % + % Optional Inputs + % --------------- % fn_is_left: Function that takes a LOWERCASED channel name as input and returns true if the channel - % name belongs to left eye. Otherwise, it returns false. + % name belongs to left eye. Otherwise, it returns false. By default, this function checks + % if the channel name ends with '_l'. % % Output % ------ @@ -23,7 +27,10 @@ % % - all elements in right data columns (except blink/saccade) that correspond to right % blink or saccade rows are set to NaN - % + if nargin == 3 + fn_is_left = @(x) strcmp(x(end-1:end), '_l'); + end + column_names = cellfun(@(x) lower(x), column_names, 'uni', 0); mask_chans = cellfun(@(x) lower(x), mask_chans, 'uni', 0); diff --git a/src/pspm.fig b/src/pspm.fig index 515b22220..a58c1a6e6 100644 Binary files a/src/pspm.fig and b/src/pspm.fig differ diff --git a/src/pspm.m b/src/pspm.m index 90c5daf92..83640ad9b 100644 --- a/src/pspm.m +++ b/src/pspm.m @@ -285,6 +285,8 @@ function GLM_Callback(hObject, eventdata, handles) cfg_add_module('pspm.first_level.resp.glm_rfr_e'); case 9 cfg_add_module('pspm.first_level.sebr.glm_sebr'); + case 10 + cfg_add_module('pspm.first_level.sps.glm_sps'); end; @@ -316,10 +318,14 @@ function ppDataPreprocessing_Callback(hObject, eventdata, handles) case 7 cfg_add_module('pspm.data_preprocessing.pp_pupil.pupil_preprocess'); case 8 - cfg_add_module('pspm.tools.convert_data'); + cfg_add_module('pspm.data_preprocessing.pupil_size_convert'); case 9 - cfg_add_module('pspm.data_preprocessing.pp_emg.find_sounds'); + cfg_add_module('pspm.data_preprocessing.gaze_convert'); case 10 + cfg_add_module('pspm.tools.convert_data'); + case 11 + cfg_add_module('pspm.data_preprocessing.pp_emg.find_sounds'); + case 12 cfg_add_module('pspm.data_preprocessing.pp_emg.pp_emg_data'); end; diff --git a/src/pspm_bf_spsrf_box.m b/src/pspm_bf_spsrf_box.m index 40a6ce2a7..c64fa64a0 100644 --- a/src/pspm_bf_spsrf_box.m +++ b/src/pspm_bf_spsrf_box.m @@ -1,5 +1,7 @@ function [bs, x] = pspm_bf_spsrf_box(varargin) -% pspm_bf_spsrf_box basis function dependent on SOA +% pspm_bf_spsrf_box constructs a boxcar function to produce the averaged +% scanpath speed over a 2-s time window in the end of SOA, which equals +% to scanpath length over a 2-s time window divided by 2/s % % FORMAT: [bs, x] = pspm_bf_spsrf_box(td, soa) % OR: [bs, x] = pspm_bf_spsrf_box([td, soa]) @@ -7,6 +9,10 @@ % FORMAT: [bf p] = pspm_bf_spsrf_box(td, soa) % with td: time resolution in s % +% REFERENCE +% (1) Xia Y, Melinscak F, Bach DR (2020) +% Saccadic Scanpath Length: An Index for Human Threat Conditioning +% Behavioral Research Methods (submitted) %________________________________________________________________________ % PsPM 4.0 @@ -27,7 +33,7 @@ end; -%create boder of interval +% create border of interval stop = soa; start = soa-2; start_idx = floor(start/td); diff --git a/src/pspm_bf_spsrf_gamma.m b/src/pspm_bf_spsrf_gamma.m index a7d1fbc3c..3310d11e9 100644 --- a/src/pspm_bf_spsrf_gamma.m +++ b/src/pspm_bf_spsrf_gamma.m @@ -1,6 +1,7 @@ function [bs, t] = pspm_bf_spsrf_gamma(varargin) -% pspm_bf_spsrf_box basis function with a total duration of 10 seconds -% and a shift of (SOA-3) seconds (see reference). +% pspm_bf_spsrf_gamma constructs a gamma probability density function for +% scanpath speed responses with a total duration of 10 seconds and a shift +% of (SOA - 3) seconds. % % FORMAT: [bf p] = pspm_bf_spsrf_gamma(td,soa,p) OR % [bf p] = pspm_bf_spsrf_gamma([td,soa,p]) @@ -9,7 +10,11 @@ % p(2) = x0 % p(3) = a % p(4) = b -% +% +% REFERENCE +% (1) Xia Y, Melinscak F, Bach DR (2020) +% Saccadic Scanpath Length: An Index for Human Threat Conditioning +% Behavioral Research Methods (submitted) %________________________________________________________________________ % PsPM 4.0 diff --git a/src/pspm_blink_saccade_filt.m b/src/pspm_blink_saccade_filt.m new file mode 100644 index 000000000..7444f8cbc --- /dev/null +++ b/src/pspm_blink_saccade_filt.m @@ -0,0 +1,131 @@ +function [sts, out_channel] = pspm_blink_saccade_filt(fn, discard_factor, options) + % Perform blink-saccade filtering on a given file containing pupil data. This + % function extends each blink and/or saccade period towards the beginning and the + % end of the signal by an amount specified by the user. + % + % FORMAT: [sts, out_channel] = pspm_blink_saccade_filt(fn, discard_factor, options) + % + % fn: [string] Path to the PsPM file which contains + % the pupil data. + % + % discard_factor: [numeric] Factor used to determine the number of + % samples right before and right after a blink/saccade + % period to discard. This value is multiplied by the + % sampling rate of the recording to determine the + % number of samples to discard from one end. Therefore, + % for each blink/saccade period, 2*this_value*SR many + % samples are discarded in total, and effectively + % blink/saccade period is extended. + % + % This value also corresponds to the duration of + % samples to discard on one end in seconds. For example, + % when it is 0.01, we discard 10 ms worth of data on + % each end of every blink/saccade period. + % options: + % Optional: + % channel: [numeric/string] Channel ID to be preprocessed. + % By default preprocesses all the pupil and gaze + % channels. + % (Default: 0) + % + % channel_action: ['add'/'replace'] Defines whether corrected data + % should be added or the corresponding preprocessed + % channel should be replaced. + % (Default: 'add') + % + % + %__________________________________________________________________________ + % (C) 2020 Eshref Yozdemir (University of Zurich) + + global settings; + if isempty(settings), pspm_init; end + sts = -1; + + if nargin == 2 + options = struct(); + end + if ~isfield(options, 'channel') + options.channel = 0; + end + if ~isfield(options, 'channel_action') + options.channel_action = 'add'; + end + + if ~isnumeric(discard_factor) + warning('ID:invalid_input', 'discard_factor must be numeric'); + return; + end + if ~ismember(options.channel_action, {'add', 'replace'}) + warning('ID:invalid_input', 'Option channel_action must be either ''add'' or ''replace'''); + return; + end + + % READ DATA + % --------- + [lsts, ~, data] = pspm_load_data(fn); + if lsts ~= 1; return; end; + [lsts, ~, data_user] = pspm_load_data(fn, options.channel); + if lsts ~= 1; return; end; + data_user = keep_pupil_gaze_chans(data_user); + + % BUILD MATRICES AND LISTS + % ------------------------ + data_mat = {}; + column_names = {}; + mask_chans = {}; + for i = 1:numel(data) + chantype = data{i}.header.chantype; + if strncmp(chantype, 'blink', numel('blink')) || ... + strncmp(chantype, 'saccade', numel('saccade')) + mask_chans{end + 1} = chantype; + data_mat{end + 1} = data{i}.data; + column_names{end + 1} = chantype; + end + end + n_mask_chans = numel(data_mat); + for i = 1:numel(data_user) + chantype = data_user{i}.header.chantype; + should_add = options.channel ~= 0 || ... + strncmp(chantype, 'pupil', numel('pupil')) || ... + strncmp(chantype, 'gaze', numel('gaze')); + if should_add + data_mat{end + 1} = data_user{i}.data; + column_names{end + 1} = data_user{i}.header.chantype; + end + end + data_mat = cell2mat(data_mat); + + % PERFORM FILTERING + % ----------------- + addpath(pspm_path('backroom')); + sr = data{1}.header.sr; + samples_to_discard = round(sr * discard_factor); + out_mat = blink_saccade_filtering(data_mat, column_names, mask_chans, samples_to_discard); + rmpath(pspm_path('backroom')); + + % WRITE BACK + % ---------- + for i = 1:numel(data_user) + data_user{i}.data = out_mat(:, n_mask_chans + i); + end + channel_str = options.channel; + if isnumeric(channel_str) + channel_str = num2str(channel_str); + end + o.msg.prefix = sprintf('Blink saccade filtering :: Input channel: %s', channel_str); + [lsts, out_id] = pspm_write_channel(fn, data_user, options.channel_action, o); + if lsts ~= 1; return; end; + + out_channel = out_id.channel; + sts = 1; +end + +function [out_cell] = keep_pupil_gaze_chans(in_cell) + out_cell = {}; + for i = 1:numel(in_cell) + channel = lower(in_cell{i}.header.chantype); + if contains(channel, 'pupil') || contains(channel, 'gaze') + out_cell{end + 1} = in_cell{i}; + end + end +end diff --git a/src/pspm_butter.m b/src/pspm_butter.m index a3472ff6f..ad5cbc734 100644 --- a/src/pspm_butter.m +++ b/src/pspm_butter.m @@ -38,12 +38,12 @@ if settings.signal [b, a]=butter(order, freqratio, pass); else - load('pspm_butter.mat'); + F = load('pspm_butter.mat', 'filt'); switch pass case 'low' - f = filt{1}; + f = F.filt{1}; case 'high' - f = filt{2}; + f = F.filt{2}; end; d = abs([f.freqratio] - freqratio); n = find(d < .0001); diff --git a/src/pspm_cfg/pspm_cfg_artefact_rm.m b/src/pspm_cfg/pspm_cfg_artefact_rm.m index b5fee3886..76c2e9b56 100644 --- a/src/pspm_cfg/pspm_cfg_artefact_rm.m +++ b/src/pspm_cfg/pspm_cfg_artefact_rm.m @@ -69,13 +69,86 @@ qa_slope.val = {10}; qa_slope.help = {'Maximum SCR slope in microsiemens per second.'}; +qa_missing_epochs_no_filename = cfg_const; +qa_missing_epochs_no_filename.name = 'Do not write to file'; +qa_missing_epochs_no_filename.tag = 'no_missing_epochs'; +qa_missing_epochs_no_filename.val = {0}; +qa_missing_epochs_no_filename.help = {'Do not store artefacts epochs to file'}; + +qa_missing_epochs_file_name = cfg_entry; +qa_missing_epochs_file_name.name = 'File name'; +qa_missing_epochs_file_name.tag = 'filename'; +qa_missing_epochs_file_name.strtype = 's'; +qa_missing_epochs_file_name.num = [ 1 Inf ]; +qa_missing_epochs_file_name.help = {['Specify the name of the file where to store artefact epochs. ',... + 'Provide only the name and not the extension, the file will be stored as a .mat file']}; + +qa_missing_epochs_file_path = cfg_files; +qa_missing_epochs_file_path.name = 'Output Directory'; +qa_missing_epochs_file_path.tag = 'outdir'; +qa_missing_epochs_file_path.filter = 'dir'; +qa_missing_epochs_file_path.num = [1 1]; +qa_missing_epochs_file_path.help = {'Specify the directory where the .mat file with artefact epochs will be written.'}; + +qa_missing_epochs_file = cfg_exbranch; +qa_missing_epochs_file.name = 'Write to filename'; +qa_missing_epochs_file.tag = 'write_to_file'; +qa_missing_epochs_file.val = {qa_missing_epochs_file_name, qa_missing_epochs_file_path}; +qa_missing_epochs_file.help = {['If you choose to store the artefact epochs please specify a filename ',... + 'as well as an output directory. When giving the filename do not specify ',... + 'any extension, the artefact epochs will be stored as .mat file.']}; + +qa_missing_epochs = cfg_choice; +qa_missing_epochs.name = 'Missing epochs file'; +qa_missing_epochs.tag = 'missing_epochs'; +qa_missing_epochs.val = {qa_missing_epochs_no_filename}; +qa_missing_epochs.values = {qa_missing_epochs_no_filename, qa_missing_epochs_file}; +qa_missing_epochs.help = {'Specify if you want to store the artefact epochs in a separate file of not.', ... + 'Default: artefact epochs are not stored.'}; + +qa_deflection_threshold = cfg_entry; +qa_deflection_threshold.name = 'Deflection threshold'; +qa_deflection_threshold.tag = 'deflection_threshold'; +qa_deflection_threshold.strtype = 'r'; +qa_deflection_threshold.num = [1 1]; +qa_deflection_threshold.val = {0.1}; +qa_deflection_threshold.help = {['Define an threshold in original data units for a slope to pass to be considerd in the filter. ', ... + 'This is useful, for example, with oscillatory wave data. ', ... + 'The slope may be steep due to a jump between voltages but we ', ... + 'likely do not want to consider this to be filtered. ', ... + 'A value of 0.1 would filter oscillatory behaviour with threshold less than 0.1v but not greater.' ],... + 'Default: 0.1', ... + }; + +qa_data_island_threshold = cfg_entry; +qa_data_island_threshold.name = 'Data island threshold'; +qa_data_island_threshold.tag = 'data_island_threshold'; +qa_data_island_threshold.strtype = 'r'; +qa_data_island_threshold.num = [1 1]; +qa_data_island_threshold.val = {0}; +qa_data_island_threshold.help = {['A float in seconds to determine the maximum length of unfiltered data between epochs.', ... + ' If an island exists for less than the threshold it will also be filtered'], ... + 'Default: 0 s - will take no effect on filter', ... + }; + +qa_expand_epochs = cfg_entry; +qa_expand_epochs.name = 'Expand epochs'; +qa_expand_epochs.tag = 'expand_epochs'; +qa_expand_epochs.strtype = 'r'; +qa_expand_epochs.num = [1 1]; +qa_expand_epochs.val = {0.5}; +qa_expand_epochs.help = {'A float in seconds to determine by how much data on the flanks of artefact epochs will be removed.', ... + 'Default: 0.5 s', ... + }; + + qa = cfg_branch; qa.name = 'Simple SCR quality correction'; qa.tag = 'simple_qa'; -qa.val = {qa_min, qa_max, qa_slope}; +qa.val = {qa_min, qa_max, qa_slope, qa_missing_epochs, qa_deflection_threshold, qa_data_island_threshold,qa_expand_epochs}; qa.help = {['Simple SCR quality correction. See I. R. Kleckner et al.,"Simple, Transparent, and' ... - 'Flexible Automated Quality Assessment Procedures for Ambulatory Electrodermal Activity Data," in ' ... - 'IEEE Transactions on Biomedical Engineering, vol. 65, no. 7, pp. 1460-1467, July 2018.']}; + 'Flexible Automated Quality Assessment Procedures for Ambulatory Electrodermal Activity Data," in ' ... + 'IEEE Transactions on Biomedical Engineering, vol. 65, no. 7, pp. 1460-1467, July 2018.']}; %% Data file datafile = cfg_files; diff --git a/src/pspm_cfg/pspm_cfg_data_preprocessing.m b/src/pspm_cfg/pspm_cfg_data_preprocessing.m index c02a4c7d5..f3656005b 100644 --- a/src/pspm_cfg/pspm_cfg_data_preprocessing.m +++ b/src/pspm_cfg/pspm_cfg_data_preprocessing.m @@ -13,6 +13,6 @@ cfg = cfg_repeat; cfg.name = 'Data preprocessing'; cfg.tag = 'data_preprocessing'; -cfg.values = {pspm_cfg_pp_heart_period, pspm_cfg_resp_pp, pspm_cfg_pp_pupil, pspm_cfg_pp_emg}; +cfg.values = {pspm_cfg_pp_heart_period, pspm_cfg_resp_pp, pspm_cfg_pp_pupil, pspm_cfg_pupil_size_convert, pspm_cfg_gaze_convert, pspm_cfg_pp_emg}; cfg.forcestruct = true; cfg.help = {'Help: Data preprocessing'}; \ No newline at end of file diff --git a/src/pspm_cfg/pspm_cfg_gaze_convert.m b/src/pspm_cfg/pspm_cfg_gaze_convert.m new file mode 100644 index 000000000..a900f7dc2 --- /dev/null +++ b/src/pspm_cfg/pspm_cfg_gaze_convert.m @@ -0,0 +1,144 @@ +function [pp_gaze_convert] = pspm_cfg_data_convert +% function [pp_gaze_convert] = pspm_cfg_data_convert(job) +% +% Matlabbatch function for conversion functions of data +%__________________________________________________________________________ +% PsPM 3.1 +% (C) 2016 Tobias Moser (University of Zurich) + +% $Id: pspm_cfg_data_convert.m 635 2019-03-14 10:14:50Z lciernik $ +% $Rev: 635 $ + +% Initialise +global settings +if isempty(settings), pspm_init; end + +%% Datafile +datafile = cfg_files; +datafile.name = 'Data File'; +datafile.tag = 'datafile'; +datafile.num = [1 1]; +datafile.help = {['Specify the PsPM datafile containing the channels ', ... + 'to be converted.'],' ',settings.datafilehelp}; + + +%% width +width = cfg_entry; +width.name = 'Width'; +width.tag = 'width'; +width.strtype = 'r'; +width.num = [1 1]; +width.help = {['Width of the display window. Unit is `mm`.']}; + +%% height +height = cfg_entry; +height.name = 'Height'; +height.tag = 'height'; +height.strtype = 'r'; +height.num = [1 1]; +height.help = {['Height of the display window. Unit is `mm`.']}; + +%% screen distance (Only needed if unit degree is chosen) +screen_distance = cfg_entry; +screen_distance.name = 'Screen distance'; +screen_distance.tag = 'screen_distance'; +screen_distance.strtype = 'r'; +screen_distance.num = [1 1]; +screen_distance.val = {-1}; +screen_distance.help = {['Distance between eye and screen. Unit is `mm` ']}; + + +%% From +from = cfg_menu; +from.name = 'From distance unit'; +from.tag = 'from'; +from.values = { 'pixel', 'mm', 'cm', 'm', 'inches' }; +from.labels = { 'pixel', 'mm', 'cm', 'm', 'inches' }; +from.val = {'mm'}; +from.help = {'Distance unit from which the measurements should be converted.'}; + + +%% Conversions +distance2sps = cfg_branch; +distance2sps.name = 'Distance to scan path speed conversion'; +distance2sps.tag = 'distance2sps'; +distance2sps.val = {width, height, screen_distance, from }; +distance2sps.help = {['Choose conversion information']}; + +distance2degree = cfg_branch; +distance2degree.name = 'Distance to degree conversion'; +distance2degree.tag = 'distance2degree'; +distance2degree.val = {width, height, screen_distance, from }; +distance2degree.help = {['Choose conversion information']}; + +%% Eyes +eyes = cfg_menu; +eyes.name = 'Eyes'; +eyes.tag = 'eyes'; +eyes.val = {'all'}; +eyes.labels = {'All eyes', 'Left eye', 'Right eye'}; +eyes.values = {'lr', 'l', 'r'}; +eyes.help = {['Choose eyes which should be processed. If ''All', ... + 'eyes'' is selected, all eyes which are present in the data will ', ... + 'be processed. Otherwise only the chosen eye will be processed.']}; + +degree2sps = cfg_branch; +degree2sps.name = 'Degree to scan path speed conversion'; +degree2sps.tag = 'degree2sps'; +degree2sps.val = {eyes}; +degree2sps.help = {['Convert degree gaze data to scan path speed.', ... +'This conversion will find the degree unit gaze data from the file automatically.', ... +'The gaze data must not contain any NaN values.']}; + +%% unit +unit = cfg_menu; +unit.name = 'Unit'; +unit.tag = 'unit'; +unit.values = {'mm', 'cm', 'm', 'inches'}; +unit.labels = {'mm', 'cm', 'm', 'inches'}; +unit.val = {'mm'}; +unit.help = {'Unit into which the measurements should be converted.'}; + +%% Channel +channel = cfg_entry; +channel.name = 'Channel'; +channel.tag = 'channel'; +channel.strtype = 'i'; +channel.num = [1 Inf]; +channel.help = {['Specify the channel which should be converted.', ... + 'If 0, conversion will be attmepted on all channels']}; + +pixel2unit = cfg_branch; +pixel2unit.name = 'Pixel to unit'; +pixel2unit.tag = 'pixel2unit'; +pixel2unit.val = {width, height,screen_distance,unit, channel}; +pixel2unit.help = {['Convert pupil gaze coordinates from pixel values ',... + 'to distance unit values']}; + + +%% Conversions +conversion = cfg_choice; +conversion.name = 'Conversion Type'; +conversion.tag = 'conversion'; +conversion.val = {distance2degree}; +conversion.values = {distance2degree, distance2sps, degree2sps,pixel2unit }; +conversion.help = {['']}; + +%% Channel action +chan_action = cfg_menu; +chan_action.name = 'Channel action'; +chan_action.tag = 'channel_action'; +chan_action.val = {'add'}; +chan_action.values = {'replace', 'add'}; +chan_action.labels = {'Replace channel', 'Add channel'}; +chan_action.help = {['Choose whether to ''replace'' the given channel ', ... + 'or ''add'' the converted data as a new channel.']}; + +%% Executable branch +pp_gaze_convert = cfg_exbranch; +pp_gaze_convert.name = 'Gaze convert'; +pp_gaze_convert.tag = 'gaze_convert'; +pp_gaze_convert.val = {datafile, conversion, chan_action}; +pp_gaze_convert.prog = @pspm_cfg_run_gaze_convert; +pp_gaze_convert.help = {['Provides conversion functions for the specified ', ... + 'data (e.g. gaze data).']}; \ No newline at end of file diff --git a/src/pspm_cfg/pspm_cfg_glm_hp_fc.m b/src/pspm_cfg/pspm_cfg_glm_hp_fc.m index b86c18b23..08fc00ce5 100644 --- a/src/pspm_cfg/pspm_cfg_glm_hp_fc.m +++ b/src/pspm_cfg/pspm_cfg_glm_hp_fc.m @@ -33,7 +33,7 @@ soa = cfg_entry; soa.name = 'SOA'; soa.tag = 'soa'; -soa.help = {['Specify custom SOA for response function. Tested values are 3.5s, 4s and 6s. Default: 3.5s']}; +soa.help = {['Specify custom SOA for response function. Tested values are 3.5 s, 4 s and 6 s. Default: 3.5 s']}; soa.strtype = 'r'; soa.num = [1 1]; soa.val = {3.5}; diff --git a/src/pspm_cfg/pspm_cfg_glm_sps.m b/src/pspm_cfg/pspm_cfg_glm_sps.m index 513d3918f..e83180530 100644 --- a/src/pspm_cfg/pspm_cfg_glm_sps.m +++ b/src/pspm_cfg/pspm_cfg_glm_sps.m @@ -30,7 +30,9 @@ soa = cfg_entry; soa.name = 'SOA'; soa.tag = 'soa'; -soa.help = {['Specify custom SOA for response function. Tested values are 3.5s, 4s and 6s. Default: 3.5s']}; +soa.help = {['Specify custom SOA for response function.', ... + 'Tested values are 3.5 s and 4 s.', ... + 'Default: 3.5 s']}; soa.strtype = 'r'; soa.num = [1 1]; soa.val = {3.5}; @@ -39,18 +41,21 @@ % SPS % bf = boxfunction spsrf_box = cfg_const; -spsrf_box.name = 'Boxfunction'; +spsrf_box.name = 'Average scanpath speed'; spsrf_box.tag = 'spsrf_box'; spsrf_box.val = {'spsrf_box'}; -spsrf_box.help = {['SPSRF with boxfunction. (default)']}; +spsrf_box.help = {['This option implements a boxcar function over the SOA, and yields the average ',... + 'scan path speed over that interval (i.e.', ... + 'the scan path length over that interval, divided by the SOA).', ... + '(default).']}; % bf = gammafunction spsrf_gamma = cfg_const; -spsrf_gamma.name = 'Gammafunction'; +spsrf_gamma.name = 'SPSRF_FC'; spsrf_gamma.tag = 'spsrf_gamma'; spsrf_gamma.val = {'spsrf_gamma'}; -spsrf_gamma.help = {['SPSRF with gammafunction.',... - ' Use gamma probability density function to model the scanpath speed. ']}; +spsrf_gamma.help = {['This option implements a gamma function for fear-conditioned scan path speed', ... + 'responses, time-locked to the end of the CS-US interval.']}; rf = cfg_choice; rf.name = 'Function'; @@ -67,3 +72,32 @@ % look for bf and replace b = cellfun(@(f) strcmpi(f.tag, 'bf'), glm_sps.val); glm_sps.val{b} = bf; + +% specific channel +chan_def_left = cfg_const; +chan_def_left.name = 'Last left eye'; +chan_def_left.tag = 'chan_def_left'; +chan_def_left.val = {'pupil_l'}; +chan_def_left.help = {'Use last left eye channel.'}; + +chan_def_right = cfg_const; +chan_def_right.name = 'Last right eye'; +chan_def_right.tag = 'chan_def_right'; +chan_def_right.val = {'pupil_r'}; +chan_def_right.help = {'Use last right eye channel.'}; + +best_eye = cfg_const; +best_eye.name = 'Best eye'; +best_eye.tag = 'best_eye'; +best_eye.val = {'pupil'}; +best_eye.help = {['Use eye with the fewest NaN values.']}; + +chan_def = cfg_choice; +chan_def.name = 'Default'; +chan_def.tag = 'chan_def'; +chan_def.val = {best_eye}; +chan_def.values = {best_eye, chan_def_left, chan_def_right}; + +a = cellfun(@(f) strcmpi(f.tag, 'chan'), glm_sps.val); +glm_sps.val{a}.values{1} = chan_def; +glm_sps.val{a}.val{1} = chan_def; diff --git a/src/pspm_cfg/pspm_cfg_import.m b/src/pspm_cfg/pspm_cfg_import.m index 5d8b6c9fa..b569a75fd 100644 --- a/src/pspm_cfg/pspm_cfg_import.m +++ b/src/pspm_cfg/pspm_cfg_import.m @@ -10,6 +10,7 @@ % Get filetype fileoptions={settings.import.datatypes.long}; chantypesDescription = {settings.chantypes.description}; +chantypesData = {settings.chantypes.data}; %% Predefined struct @@ -114,34 +115,6 @@ 'enabled (> 0) the data will be converted from arbitrary units to ', ... 'length units.']}; -eyelink_edge_discard_factor = cfg_entry; -eyelink_edge_discard_factor.name = 'Blink/saccade discard factor'; -eyelink_edge_discard_factor.tag = 'eyelink_edge_discard_factor'; -eyelink_edge_discard_factor.val = {0}; -eyelink_edge_discard_factor.num = [1 1]; -eyelink_edge_discard_factor.strtype = 'r'; -eyelink_edge_discard_factor.help = {['Factor used to determine the number of', ... - ' samples right before and right after a blink/saccade', ... - ' period to discard. This value is multiplied by the', ... - ' sampling rate of the recording to determine the', ... - ' number of samples to discard from one end. Therefore,', ... - ' for each blink/saccade period, 2*this_value*SR many', ... - ' samples are discarded in total, and effectively', ... - ' blink/saccade period is extended.'], ... - - ['This value also corresponds to the duration of', ... - ' samples to discard on one end in seconds. For example,', ... - ' when it is 0.01, we discard 10 ms worth of data on', ... - ' each end of every blink/saccade period.'] ... - - ['The default value has been changed to 0 in PsPM revision', ... - ' r803 to reduce the amount of discarded data. Note that', ... - ' this might result in noisy samples around blink/saccade', ... - ' points. Therefore, it is highly recommended to perform', ... - ' pupil size data preprocessing and gaze data filtering by', ... - ' finding valid fixations.'] ... -}; - distance_unit = cfg_menu; distance_unit.name = 'Distance unit'; distance_unit.tag = 'distance_unit'; @@ -175,6 +148,35 @@ smi_stimulus_resolution.help = {['The resolution of the stimulus window. This field is required' ... 'to perform px to mm conversions for gaze channels']}; +delimiter = cfg_entry; +delimiter.name = 'Delimiter'; +delimiter.tag = 'delimiter'; +delimiter.strtype = 's'; +delimiter.help = {'The delimiter to be used for file reading, leave blank to use any whitespace character.'}; + +header_lines = cfg_entry; +header_lines.name = 'Header lines'; +header_lines.tag = 'header_lines'; +header_lines.strtype = 'r'; +header_lines.val = {1}; +header_lines.help = {'The number of lines used by the header. By default 1.'}; + +channel_names_line = cfg_entry; +channel_names_line.name = 'Channel names line'; +channel_names_line.tag = 'channel_names_line'; +channel_names_line.strtype = 'r'; +channel_names_line.val = {1}; +channel_names_line.help = {'The line number where the channel/column names are specified. By default 1.'}; + +exclude_columns = cfg_entry; +exclude_columns.name = 'Exclude columns'; +exclude_columns.tag = 'exclude_columns'; +exclude_columns.strtype = 'r'; +exclude_columns.val = {0}; +exclude_columns.help = {['The number of columns which have to be excluded for the importing. By default 0. ',... + 'It is usefull if the first columns have non numeric data (e.g. timestamps). ', ... + 'Be aware that if you exclude some columns you have to adapt the channel number.']}; + %% Datatype dependend items datatype_item = cell(1,length(fileoptions)); for datatype_i=1:length(fileoptions) @@ -223,6 +225,20 @@ 'this channel by its name. Note: the channel number refers to the n-th recorded ' ... 'channel, not to its number during acquisition (if you did not save all recorded ' ... 'channels, these might be different for some data types).']}; + + %% Flank option for 'event' channel types + flank_option = cfg_menu; + flank_option.name = 'Flank of the event impulses to import'; + flank_option.tag = 'flank_option'; + flank_option.values = {'ascending', 'descending', 'all', 'both', 'default'}; + flank_option.labels = {'ascending', 'descending', 'both', 'middle', 'default'}; + flank_option.val = {'default'}; + flank_option.help = {['The flank option specifies which of the rising edge(ascending), ', ... + 'falling edge(descending), both edges or their mean(middle) of a marker impulse should ', ... + 'be imported into the marker channel. The default option is to select the middle of ', ... + 'the impulse, some exceptions are Eyelink, ViewPoint and SensoMotoric Instruments data ', ... + 'for which the default are respectively ''both'', ''ascending'', ''ascending''. ',... + 'If the numbers of rising and falling edges differ, PsPM will throw an error. ']}; %% Channel/Column Type Items importtype_item = cell(1,length(chantypes)); @@ -268,7 +284,12 @@ end end - importtype_item{importtype_i}.val = {chan_nr}; + if strcmp(chantypesData{chantypesDescIdx}, 'events') + importtype_item{importtype_i}.val = {chan_nr,flank_option}; + else + importtype_item{importtype_i}.val = {chan_nr}; + end + % Check for sample rate if samplerate == 0 @@ -344,7 +365,7 @@ % Refactor this part by even possibly dividing pspm_cfg_import to several files. if any(strcmp(settings.import.datatypes(datatype_i).short, 'eyelink')) datatype_item{datatype_i}.val = ... - [datatype_item{datatype_i}.val, {eyelink_trackdist, eyelink_edge_discard_factor, distance_unit}]; + [datatype_item{datatype_i}.val, {eyelink_trackdist, distance_unit}]; end if any(strcmpi(settings.import.datatypes(datatype_i).short, 'viewpoint')) @@ -354,6 +375,18 @@ if any(strcmpi(settings.import.datatypes(datatype_i).short, 'smi')) datatype_item{datatype_i}.val = [datatype_item{datatype_i}.val, {smi_target_unit, smi_stimulus_resolution}]; end + + if any(strcmpi(settings.import.datatypes(datatype_i).short, 'txt')) + datatype_item{datatype_i}.val = [datatype_item{datatype_i}.val, {header_lines,channel_names_line,exclude_columns}]; + end + + if any(strcmpi(settings.import.datatypes(datatype_i).short, 'csv')) + datatype_item{datatype_i}.val = [datatype_item{datatype_i}.val, {header_lines,channel_names_line,exclude_columns}]; + end + + if any(strcmpi(settings.import.datatypes(datatype_i).short, 'dsv')) + datatype_item{datatype_i}.val = [datatype_item{datatype_i}.val, {delimiter,header_lines,channel_names_line,exclude_columns}]; + end end %% Data type diff --git a/src/pspm_cfg/pspm_cfg_pupil_size_convert.m b/src/pspm_cfg/pspm_cfg_pupil_size_convert.m new file mode 100644 index 000000000..544fd4701 --- /dev/null +++ b/src/pspm_cfg/pspm_cfg_pupil_size_convert.m @@ -0,0 +1,78 @@ +function [pp_pupil_size_convert] = pspm_cfg_pupil_size_convert +% function [pp_pupil_size_convert] = pspm_cfg_pupil_size_convert(job) +% +% Matlabbatch function for pupil size conversion +%__________________________________________________________________________ +% PsPM 4.3 +% (C) 2016 Sam Maxwell (University College London) + + +% Initialise +global settings + +%% Datafile +datafile = cfg_files; +datafile.name = 'Data File'; +datafile.tag = 'datafile'; +datafile.num = [1 1]; +datafile.help = {['Specify the PsPM datafile containing the channels ', ... + 'to be converted.'],' ',settings.datafilehelp}; + +%% Channel +channel = cfg_entry; +channel.name = 'Channel'; +channel.tag = 'channel'; +channel.strtype = 'i'; +channel.num = [1 Inf]; +channel.help = {['Specify the channel which should be converted.', ... + 'If 0, functions are executed on all channels.']}; + +%% area2diameter +area2diameter = cfg_const; +area2diameter.name = 'Area to diameter'; +area2diameter.tag = 'area2diameter'; +area2diameter.val = {'area2diameter'}; +area2diameter.help = {['']}; + +%% Mode +mode = cfg_choice; +mode.name = 'Mode'; +mode.tag = 'mode'; +mode.val = {area2diameter}; +mode.values = {area2diameter}; +mode.help = {['Choose conversion mode.']}; + +%% Conversion +conversion = cfg_branch; +conversion.name = 'Conversion'; +conversion.tag = 'conversion'; +conversion.val = {channel, mode}; +conversion.help = {['']}; + +%% Conversions +conversions = cfg_repeat; +conversions.name = 'Conversion list'; +conversions.tag = 'conversions'; +conversions.values = {conversion}; +conversions.num = [1 Inf]; +conversion.help = {['']}; + +%% Channel action +chan_action = cfg_menu; +chan_action.name = 'Channel action'; +chan_action.tag = 'channel_action'; +chan_action.val = {'add'}; +chan_action.values = {'replace', 'add'}; +chan_action.labels = {'Replace channel', 'Add channel'}; +chan_action.help = {['Choose whether to ''replace'' the given channel ', ... + 'or ''add'' the converted data as a new channel.']}; + +%% Executable branch +pp_pupil_size_convert = cfg_exbranch; +pp_pupil_size_convert.name = 'Pupil size convert'; +pp_pupil_size_convert.tag = 'pupil_size_convert'; +pp_pupil_size_convert.val = {datafile, conversion, chan_action}; +pp_pupil_size_convert.prog = @pspm_cfg_run_pupil_size_convert; +pp_pupil_size_convert.help = {['Provides conversion functions for the specified ', ... + 'data (e.g. pupil size data). Currently only area to diameter conversion is ',... + 'available.']}; \ No newline at end of file diff --git a/src/pspm_cfg/pspm_cfg_run_artefact_rm.m b/src/pspm_cfg/pspm_cfg_run_artefact_rm.m index 01371526d..ed4f1a5e5 100644 --- a/src/pspm_cfg/pspm_cfg_run_artefact_rm.m +++ b/src/pspm_cfg/pspm_cfg_run_artefact_rm.m @@ -21,7 +21,47 @@ freq = job.filtertype.(filtertype).freq; out = pspm_pp(filtertype, datafile, freq, channelnumber, options); case 'simple_qa' - qa = job.filtertype.(filtertype); + qa_job = job.filtertype.(filtertype); + + % Option structure sent to pspm_simple_qa + qa = struct(); + + % Check if min is defined + if isfield(qa_job, 'min'), qa.min = qa_job.min; end + + % Check if max is defined + if isfield(qa_job, 'max'), qa.max = qa_job.max; end + + % Check if slope is defined + if isfield(qa_job, 'slope'), qa.slope = qa_job.slope; end + + % Check if missing_epochs is defined + if isfield(qa_job.missing_epochs, 'write_to_file') + if isfield(qa_job.missing_epochs.write_to_file,'filename') && ... + isfield(qa_job.missing_epochs.write_to_file,'outdir') + + qa.missing_epochs_filename = fullfile( ... + qa_job.missing_epochs.write_to_file.outdir{1}, ... + qa_job.missing_epochs.write_to_file.filename); + + end + end + + % Check if deflection_threshold is defined + if isfield(qa_job, 'deflection_threshold') + qa.deflection_threshold = qa_job.deflection_threshold; + end + + % Check if data_island_threshold is defined + if isfield(qa_job, 'data_island_threshold') + qa.data_island_threshold = qa_job.data_island_threshold; + end + + % Check if expand_epochs is defined + if isfield(qa_job, 'expand_epochs') + qa.expand_epochs = qa_job.expand_epochs; + end + out = pspm_pp(filtertype, datafile, qa, channelnumber, options); end diff --git a/src/pspm_cfg/pspm_cfg_run_gaze_convert.m b/src/pspm_cfg/pspm_cfg_run_gaze_convert.m new file mode 100644 index 000000000..13b66c57d --- /dev/null +++ b/src/pspm_cfg/pspm_cfg_run_gaze_convert.m @@ -0,0 +1,25 @@ +function [out] = pspm_cfg_run_gaze_convert(job) + +% $Id$ +% $Rev$ + +channel_action = job.channel_action; +fn = job.datafile{1}; + +options = struct('channel_action', channel_action); +if isfield(job.conversion, 'degree2sps') + % do degree to sps conversion + options.eyes = job.conversion.degree2sps.eyes; + [sts, out] = pspm_convert_visangle2sps(fn, options); +elseif isfield(job.conversion, 'pixel2unit') + args = job.conversion.pixel2unit; + [sts, out] = pspm_convert_pixel2unit(fn, args.channel, args.unit, args.width, args.height, args.screen_distance, options); + +elseif isfield(job.conversion, 'distance2sps') + args = job.conversion.distance2sps; + [sts, out] = pspm_convert_gaze_distance(fn, 'sps', args.from, args.width, args.height, args.screen_distance, options); + +elseif isfield(job.conversion, 'distance2degree') + args = job.conversion.distance2degree; + [ sts, out ] = pspm_convert_gaze_distance(fn, 'degree', args.from, args.width, args.height, args.screen_distance, options); +end diff --git a/src/pspm_cfg/pspm_cfg_run_import.m b/src/pspm_cfg/pspm_cfg_run_import.m index 7d4397fc3..256cc0bd3 100644 --- a/src/pspm_cfg/pspm_cfg_run_import.m +++ b/src/pspm_cfg/pspm_cfg_run_import.m @@ -42,6 +42,14 @@ import{i}.sr = job.datatype.(datatype).importtype{i}.(type{1}).sample_rate; end + % Check if flank option is available + if isfield(job.datatype.(datatype).importtype{i}.(type{1}), 'flank_option') && ... + ~strcmp(job.datatype.(datatype).importtype{i}.(type{1}).flank_option, 'default') + + import{i}.flank = job.datatype.(datatype).importtype{i}.(type{1}).flank_option; + + end + % Check if transfer function available if isfield(job.datatype.(datatype).importtype{i}.(type{1}), 'scr_transfer') transfer = fieldnames(job.datatype.(datatype).importtype{i}.(type{1}).scr_transfer); @@ -76,10 +84,6 @@ end end - if isfield(job.datatype.(datatype), 'eyelink_edge_discard_factor') - import{i}.blink_saccade_edge_discard_factor = job.datatype.(datatype).eyelink_edge_discard_factor; - end - if isfield(job.datatype.(datatype), 'viewpoint_target_unit') import{i}.target_unit = job.datatype.(datatype).viewpoint_target_unit; end @@ -88,6 +92,23 @@ import{i}.target_unit = job.datatype.(datatype).smi_target_unit; import{i}.stimulus_resolution = job.datatype.(datatype).smi_stimulus_resolution; end + + if isfield(job.datatype.(datatype), 'delimiter') + import{i}.delimiter = job.datatype.(datatype).delimiter; + end + + if isfield(job.datatype.(datatype), 'header_lines') + import{i}.header_lines = job.datatype.(datatype).header_lines; + end + + if isfield(job.datatype.(datatype), 'channel_names_line') + import{i}.channel_names_line = job.datatype.(datatype).channel_names_line; + end + + if isfield(job.datatype.(datatype), 'exclude_columns') + import{i}.exclude_columns = job.datatype.(datatype).exclude_columns; + end + end end diff --git a/src/pspm_cfg/pspm_cfg_run_pupil_size_convert.m b/src/pspm_cfg/pspm_cfg_run_pupil_size_convert.m new file mode 100644 index 000000000..4540c235d --- /dev/null +++ b/src/pspm_cfg/pspm_cfg_run_pupil_size_convert.m @@ -0,0 +1,18 @@ +function [out] = pspm_cfg_run_pupil_size_convert(job) + +% $Id$ +% $Rev$ + +channel_action = job.channel_action; +fn = job.datafile{1}; + +for i=1:numel(job.conversion) + options = struct(); + options.channel_action = channel_action; + chan = job.conversion(i).channel; + if isfield(job.conversion(i).mode, 'area2diameter') + pspm_convert_area2diameter(fn, chan, options); + end +end + +out = 1; diff --git a/src/pspm_compute_visual_angle.m b/src/pspm_compute_visual_angle.m index 3b26448ba..b1552ae24 100644 --- a/src/pspm_compute_visual_angle.m +++ b/src/pspm_compute_visual_angle.m @@ -110,53 +110,18 @@ visual_angl_chans{p} = data{gx}; visual_angl_chans{p+1} = data{gy}; - % get channel specific data - gx_d = data{gx}.data; - gy_d = data{gy}.data; - - % The convention is that the origin of the screen is in the bottom - % left corner, so the following line is not needed a priori, but I - % leave it anyway just in case : - % gy_d = data{gy}.header.range(2)-gy_d; - - N = numel(gx_d); - if N~=numel(gy_d) - warning('ID:invalid_input', 'length of data in gaze_x and gaze_y is not the same'); + try; + [ lat, lon, lat_range, lon_range ] = pspm_compute_visual_angle_core(data{gx}.data, data{gy}.data, width, height, distance, options); + catch; + warning('ID:invalid_input', 'Could not convert distance data to degrees'); return; end; - - % move (0,0) into center of the screen - gx_d = gx_d - width/2; - gy_d = gy_d - height/2; - - % compute visual angle for gaze_x and gaze_y data: - % 1) x axis in cartesian coordinates - s_x = gx_d; - % 2) y axis in cartesian coordinates, actually the distance from participant to the screen - s_y = distance * ones(numel(gx_d),1); - % 3) z axis in spherical coordinates, actually the y axis of the screen - s_z = gy_d; - % 4) convert cartesian to spherical coordinates in radians, - % where azimuth = longitude, elevation = latitude - % the center of spherical coordinates are the eyes of the subject - [azimuth, elevation, ~]= cart2sph(s_x,s_y,s_z); - % 5) convert radians into degrees - lat = rad2deg(elevation); - lon = rad2deg(azimuth); - - % compute visual angle for the range (same procedure) - r_x = [-width/2,width/2,0,0]'; - r_y = distance * ones(numel(r_x),1); - r_z = [0,0,-height/2,height/2]'; - [x_range_sp, y_range_sp,~]= cart2sph(r_x,r_y,r_z); - x_range_sp = rad2deg(x_range_sp); - y_range_sp = rad2deg(y_range_sp); % azimuth angle of gaze points % longitude (azimuth angle from positive x axis in horizontal plane) of gaze points visual_angl_chans{p}.data = lon; visual_angl_chans{p}.header.units = 'degree'; - visual_angl_chans{p}.header.range = [x_range_sp(1),x_range_sp(2)]; + visual_angl_chans{p}.header.range = lon_range % visual_angl_chans{p}.header.r = r; % radial coordinates omitted % visual_angl_chans{p}.header.r_range = r_range; % radial coordinates omitted @@ -164,7 +129,7 @@ % latitude (elevation angle from horizontal plane) of gaze points visual_angl_chans{p+1}.data = lat; visual_angl_chans{p+1}.header.units = 'degree'; - visual_angl_chans{p+1}.header.range = [y_range_sp(3),y_range_sp(4)]; + visual_angl_chans{p+1}.header.range = lat_range % visual_angl_chans{p+1}.header.r = r; % radial coordinates omitted % visual_angl_chans{p+1}.header.r_range = r_range; % radial coordinates omitted diff --git a/src/pspm_compute_visual_angle_core.m b/src/pspm_compute_visual_angle_core.m new file mode 100644 index 000000000..212343742 --- /dev/null +++ b/src/pspm_compute_visual_angle_core.m @@ -0,0 +1,80 @@ +function [lat, lon, lat_range, lon_range] = pspm_compute_visual_angle_core(x_data, y_data, width, height, distance, options) +% pspm_compute_visual_angle computes from gaze data the corresponding +% visual angle (for each data point). The convention used here is that the +% origin of coordinate system for gaze data is at the bottom left corner of +% the screen. +% +% FORMAT: +% [lat, lon, lat_range, lon_range ] = pspm_compute_visual_angle_core(x_data, y_data, options) +% +% ARGUMENTS: +% x_data: X axis data +% y_data: y axis data +% width: screen width in same units as data +% height: screen height in same units as data +% distance: screen distance in same units as data +% options: +% .interpolate: Boolean - Interpolate values +% +% RETURN VALUES +% lat: the latitude in degrees +% lon: the longitude in degrees +% lat_range: the latitude range +% lon_range: the longitude range +%__________________________________________________________________________ +% PsPM 4.0 +global settings; +if isempty(settings), pspm_init; end; + +% interpolate channel specific data if required +if (isfield(options, 'interpolate') && options.interpolate) + interpolate_options = struct('extrapolate', 1); + [ sts_x, gx_d ] = pspm_interpolate(x_data, interpolate_options); + [ sts_x, gy_d ] = pspm_interpolate(y_data, interpolate_options); +else + gx_d = x_data; + gy_d = x_data; +end + +% The convention is that the origin of the screen is in the bottom +% left corner, so the following line is not needed a priori, but I +% leave it anyway just in case : +% gy_d = data{gy}.header.range(2)-gy_d; + +N = numel(gx_d); +if N~=numel(gy_d) + warning('ID:invalid_input', 'length of data in gaze_x and gaze_y is not the same'); + return; +end; + +% move (0,0) into center of the screen +gx_d = gx_d - width/2; +gy_d = gy_d - height/2; + +% compute visual angle for gaze_x and gaze_y data: +% 1) x axis in cartesian coordinates +s_x = gx_d; +% 2) y axis in cartesian coordinates, actually the distance from participant to the screen +s_y = distance * ones(numel(gx_d),1); +% 3) z axis in spherical coordinates, actually the y axis of the screen +s_z = gy_d; +% 4) convert cartesian to spherical coordinates in radians, +% where azimuth = longitude, elevation = latitude +% the center of spherical coordinates are the eyes of the subject +[azimuth, elevation, ~]= cart2sph(s_x,s_y,s_z); +% 5) convert radians into degrees +lat = rad2deg(elevation); +lon = rad2deg(azimuth); + +% compute visual angle for the range (same procedure) +r_x = [-width/2,width/2,0,0]'; +r_y = distance * ones(numel(r_x),1); +r_z = [0,0,-height/2,height/2]'; +[x_range_sp, y_range_sp,~]= cart2sph(r_x,r_y,r_z); + +x_range_sp = rad2deg(x_range_sp); +y_range_sp = rad2deg(y_range_sp); + +lon_range = [x_range_sp(1),x_range_sp(2)]; +lat_range = [y_range_sp(3),y_range_sp(4)]; + diff --git a/src/pspm_convert_gaze_distance.m b/src/pspm_convert_gaze_distance.m new file mode 100644 index 000000000..82811a3f6 --- /dev/null +++ b/src/pspm_convert_gaze_distance.m @@ -0,0 +1,165 @@ +function [sts, out] = pspm_convert_gaze_distance(fn, target, from, width, height, distance, options) +% pspm_convert_gaze_distance takes a file with pixel or length unit gaze data +% and converts to scanpath speed. Data will automatically be interpolated if NaNs exist +% Conversion will be attempted for any gaze data present in the provided unit. +% i.e. if only a left eye's data is provided the speed will only be calculated for that eye. +% +% FORMAT: +% [sts, out] = pspm_convert_gaze_distance(fn, from, width, height, distance, options) +% +% ARGUMENTS: +% fn: The actual data file gaze data +% +% target: target unit of conversion. degree | sps +% +% from: Distance unit to convert from. +% pixel, mm, cm, m, inches +% +% width: Width of the screen in the units chosen in the 'from' parameter +% +% height: Height of the screen in the units chosen in the 'from' parameter +% +% distance: Subject distance from the screen in the units chosen in the 'from' parameter +% +% options: +% channel_action: Channel action for sps data, add / replace existing sps data +% +% OUTPUT: +% sts: Status determining whether the execution was +% successfull (sts == 1) or not (sts == -1) +% out: Output struct +% .channel Id of the added channels. +%__________________________________________________________________________ +% PsPM 4.3.1 +% (C) 2020 Sam Maxwell (University College London) + +% $Id: pspm_convert_gaze_distance.m 1 2020-08-13 12:28:08Z sammaxwellxyz $ +% $Rev: 1 $ + +% initialise +% ----------------------------------------------------------------------------- +global settings; +if isempty(settings), pspm_init; end +sts = -1; + +% Number of arguments validation +if nargin < 6; + warning('ID:invalid_input','Not enough input arguments.'); return; +elseif nargin < 7; + options = struct(); +end + +% Options defaults +if ~isfield(options, 'channel_action'); + options.channel_action = 'add'; +end + + +% Input argument validation +if ~ismember(target, { 'degree', 'sps' }) + warning('ID:invalid_input:target', 'target conversion must be sps or degree'); + return; +end; + +if ~ismember(from, { 'pixel', 'mm', 'cm', 'inches', 'm' }) + warning('ID:invalid_input:from', 'from unit must be "pixel", "mm", "cm", "inches", "m"'); + return; +end; + +if ~isnumeric(height) + warning('ID:invalid_input:height', 'height must be numeric'); + return; +end; + +if ~isnumeric(width) + warning('ID:invalid_input:width', 'width must be numeric'); + return; +end; + +if ~isnumeric(distance) + warning('ID:invalid_input:distance', 'distance must be numeric'); + return; +end; + + +% distance to sps conversion +[sts, infos, data] = pspm_load_data(fn,0); + +eyes.l = find(cellfun(@(c) ~isempty(regexp(c.header.chantype, 'gaze_[x|y]_l', 'once'))... + && strcmp(c.header.units, from), data)); +eyes.r = find(cellfun(@(c) ~isempty(regexp(c.header.chantype, 'gaze_[x|y]_r', 'once'))... + && strcmp(c.header.units, from), data)); + +if (length(eyes.l) < 1 && length(eyes.r) < 1) + warning('ID:invalid_input', 'no gaze data found with the units provided') + return; +end + +for gaze_eye = fieldnames(eyes)' + for d = eyes.(gaze_eye{1})' + sr = data{d}.header.sr; + if ~isempty(regexp(data{d}.header.chantype, 'gaze_x_', 'once')) + lon_chan = data{d}; + + if (strcmp(from, 'pixel')); + data_x = pixel_conversion(data{d}.data, width, data{d}.header.range); + else; + [ sts, data_x ] = pspm_convert_unit(data{d}.data, from, 'mm'); + end; + + else + lat_chan = data{d}; + + if (strcmp(from, 'pixel')); + data_y = pixel_conversion(data{d}.data, height, data{d}.header.range); + else; + [ sts, data_y ] = pspm_convert_unit(data{d}.data, from, 'mm'); + end; + end; + end + + if strcmp(target, 'sps') + options.interpolate = 1; + end + + try; + [ lat, lon, lat_range, lon_range ] = pspm_compute_visual_angle_core(data_x, data_y, width, height, distance, options); + catch; + warning('ID:invalid_input', 'Could not convert distance data to degrees'); + return; + end; + + + if strcmp(target, 'degree') + lat_chan.data = lat; + lat_chan.header.units = 'degree'; + lat_chan.header.range = lat_range; + + lon_chan.data = lon; + lon_chan.header.units = 'degree'; + lon_chan.header.range = lon_chan; + + [sts, out] = pspm_write_channel(fn, { lat_chan, lon_chan }, options.channel_action); + elseif strcmp(target, 'sps') + + arclen = pspm_convert_visangle2sps_core(lat, lon); + dist_channel.data = rad2deg(arclen) .* sr; + dist_channel.header.chantype = strcat('sps_', gaze_eye{1}); + dist_channel.header.sr = sr; + dist_channel.header.units = 'degree'; + + [sts, out] = pspm_write_channel(fn, dist_channel, options.channel_action); + end + +end +end + + +% CODE SAME AS IN pspm_pixel2unit +function out = pixel_conversion(data, screen_length, interest_range) + length_per_pixel = screen_length ./ (diff(interest_range) + 1); + % baseline data in pixels wrt. the range (i.e. pixels of interest) + pixel_index = data-interest_range(1); + % convert indices into coordinates in the units of interests + out = pixel_index * length_per_pixel; +end diff --git a/src/pspm_convert_pixel2unit.m b/src/pspm_convert_pixel2unit.m index 74d97072b..85e3316c7 100644 --- a/src/pspm_convert_pixel2unit.m +++ b/src/pspm_convert_pixel2unit.m @@ -14,7 +14,7 @@ % fn: File to convert. % chan: On which subset of channels should the conversion % be done. Supports all values which can be passed -% to pspm_load_data(). The will only work on +% to pspm_load_data(). This will only work on % gaze-channels. Other channels specified will be % ignored.(For conversion into 'degree' there must be % the same amount of gane_x as gaze_y channels) @@ -22,7 +22,7 @@ % converted. % The value can contain any length unit or % 'degree'. In this case the corresponding data -% is firstly convertet into 'mm' and +% is firstly converted into 'mm' and % afterwards the visual angles are computed. % width: Width of the display window. Unit is 'mm' % if 'degree' is chosen, otherwise 'unit'. diff --git a/src/pspm_convert_visangle2sps.m b/src/pspm_convert_visangle2sps.m index 73ccaefb8..f87d93032 100644 --- a/src/pspm_convert_visangle2sps.m +++ b/src/pspm_convert_visangle2sps.m @@ -1,27 +1,30 @@ function [ sts, out ] = pspm_convert_visangle2sps(fn, options) -% pspm_convert_visangle2sp_speed takes a file with data from eyelink recordings -% and computes by time units normalized distances bewteen visual angle data. +% pspm_convert_visangle2sps takes a file with data from eyelink recordings +% and computes by seconds normalized distances bewteen visual angle data. % It saves the result into a new channel with chaneltype 'sps' (Scanpath speed). % It is important that pspm_convert_visangle2sps only takes channels % which are in visual angle. % FORMAT: -% [ sts, out ] = pspm_convert_visangle2sp_speed(fn, options) +% [ sts, out ] = pspm_convert_visangle2sps(fn, options) % ARGUMENTS: -% fn: The actual data file containing the eyelink -% recording with gaze data +% fn: The actual data file containing the eyelink +% recording with gaze data % options. -% chans: On which subset of the channels the visual -% angles between the data point should be -% computed . -% If no channels are given then the function -% computes the scanpath speed of the first -% found gaze data channels with type 'degree' -% eyes: Define on which eye the operations -% should be performed. Possible values -% are: 'l', 'r', 'lr', 'rl'. -% Default: 'lr' -% +% chans: On which subset of the channels the visual +% angles between the data point should be +% computed . +% If no channels are given then the function +% computes the scanpath speed of the first +% found gaze data channels with type 'degree' +% eyes: Define on which eye the operations +% should be performed. Possible values +% are: 'l', 'r', 'lr', 'rl'. +% Default: 'lr' +% .channel_action: ['add'/'replace'] Defines whether the new channels +% should be added or the previous outputs of this function +% should be replaced. +% Default: 'add' % OUTPUT: % sts: Status determining whether the execution was % successfull (sts == 1) or not (sts == -1) @@ -38,7 +41,6 @@ warning('ID:invalid_input', 'Nothing to do.'); return; elseif nargin<2 channels = 0; - options = struct('eyes','lr'); end if isfield(options, 'chans') channels = options.chans; @@ -58,6 +60,13 @@ '''r'', ''rl'' or ''lr''.']); return; end; +% option.channel_action +if ~isfield(options, 'channel_action') + options.channel_action = 'add'; +elseif ~any(strcmpi(options.channel_action, {'add', 'replace'})) + warning('ID:invalid_input', ['''options.channel_action'' must be either ''add'' or ''replace''.']); + return; +end; % fn if ~ischar(fn) || ~exist(fn, 'file') @@ -91,49 +100,23 @@ % get channel specific data lon = data{gx}.data; lat = data{gy}.data; -% lat = data{gy}.header.range(2)-lat; - - % first interpolate longitude to evict NaN-values - [bsts,outdata]=pspm_interpolate(lon); - if bsts ~= 1 - warning('ID:invalid_input', 'Could not load interpolate longitude data correctly.'); - return; - end - lon = outdata; - - % first interpolate latitude to evict NaN-values - [bsts,outdata]=pspm_interpolate(lat); - if bsts ~= 1 - warning('ID:invalid_input', 'Could not load interpolate latitude data correctly.'); - return; - end - lat = outdata; - - %compare if length are the same - N = numel(lon); - if N~=numel(lat) - warning('ID:invalid_input', 'length of data in gaze_x and gaze_y is not the same'); - return; - end; - - %convert lon and lat into radians - lon = deg2rad(lon); - lat = deg2rad(lat); - % compute distances - arclen = zeros(length(lat),1); - for k = 2:length(lat) - lon_diff = abs(lon(k-1)-lon(k)); - arclen(k) = atan(sqrt(((cos(lat(k))*sin(lon_diff))^2)+(((cos(lat(k-1))*sin(lat(k)))-(sin(lat(k-1))*cos(lat(k))*cos(lon_diff)))^2))/((sin(lat(k-1))*sin(lat(k)))+(cos(lat(k-1))*cos(lat(k))*cos(lon_diff)))); + try + arclen = pspm_convert_visangle2sps_core(lat, lon); + catch + warning('ID:invalid_input', 'Could not calculate sps from gaze data'); + return end + + % create new channel with data holding distances - dist_channel.data = rad2deg(arclen); - dist_channel.header.chantype = 'sps'; + dist_channel.data = rad2deg(arclen) .* data{gx}.header.sr; + dist_channel.header.chantype = strcat('sps_', eye); dist_channel.header.sr = data{gx}.header.sr; dist_channel.header.units = 'degree'; - - - [lsts, outinfo] = pspm_write_channel(fn, dist_channel, 'add'); + + + [lsts, outinfo] = pspm_write_channel(fn, dist_channel, options.channel_action); if lsts ~= 1 warning('ID:invalid_input', '~Distance channel could not be written'); @@ -143,8 +126,9 @@ out(i) = outinfo; else + if strcmpi(eye,'r'), eye_long='right'; else, eye_long='left'; end warning('ID:invalid_input', ['Unable to perform visangle2', ... - 'sps. Cannot find gaze channels with degree ',... + 'sps for the ',eye_long,' eye. Cannot find gaze channels with degree ',... 'unit values. Maybe you need to convert them with ', ... 'pspm_convert_pixel2unit()']); end; diff --git a/src/pspm_convert_visangle2sps_core.m b/src/pspm_convert_visangle2sps_core.m new file mode 100644 index 000000000..2c86f44ba --- /dev/null +++ b/src/pspm_convert_visangle2sps_core.m @@ -0,0 +1,27 @@ +function arclen = pspm_convert_visangle2sps_core(lat, lon) + +if sum(isnan(lon)) > 0 || sum(isnan(lat)) > 0 + warning('ID:invalid_input', 'cannot calculate sps from data with NaN values, check gaze degree data for NaNs'); + return; +end + +%compare if length are the same +if numel(lon) ~=numel(lat) + warning('ID:invalid_input', 'length of data in gaze_x and gaze_y is not the same'); + return; +end; + + +%convert lon and lat into radians +lon = deg2rad(lon); +lat = deg2rad(lat); +% compute distances +arclen = zeros(length(lat),1); + +% Haversine +lat_diff = (lat(2:end) - lat(1:end - 1)) / 2; +lon_diff = (lon(2:end) - lon(1:end - 1)) / 2; + +theta = sin(lat_diff).^2 + cos(lat(1:end - 1)) .* cos(lat(2:end)) .* sin(lon_diff).^2; + +arclen(2:end) = 2 * atan2(sqrt(theta),sqrt(1 - theta)); \ No newline at end of file diff --git a/src/pspm_data_editor.fig b/src/pspm_data_editor.fig index 10f501d64..038262e5c 100644 Binary files a/src/pspm_data_editor.fig and b/src/pspm_data_editor.fig differ diff --git a/src/pspm_data_editor.m b/src/pspm_data_editor.m index e7229a0ba..654f2021c 100644 --- a/src/pspm_data_editor.m +++ b/src/pspm_data_editor.m @@ -18,6 +18,10 @@ % .output_file: Use output_file to specify a file the changed data % is saved to when clicking 'save' or 'apply'. Only % works in 'file' mode. +% .epoch_file: Use epoch_file to specify a .mat file to import epoch data +% .mat file must be a struct with an 'epoch' field +% and a e x 2 matrix of epoch on- and offsets +% (n: number of epochs) % % OUTPUT: % out: The output depends on the actual output type chosen in @@ -31,7 +35,7 @@ % $Id$ % $Rev$ -% Last Modified by GUIDE v2.5 21-Jun-2016 17:00:56 +% Last Modified by GUIDE v2.5 23-Jun-2020 14:11:46 % Begin initialization code - DO NOT EDIT gui_Singleton = 1; @@ -103,13 +107,19 @@ function pspm_data_editor_OpeningFcn(hObject, eventdata, handles, varargin) handles.output_file = handles.options.output_file; set(handles.edOutputFile, 'String', handles.output_file); - end; + end + + if isfield(handles.options, 'epoch_file') && ... + ischar(handles.options.epoch_file) + + handles.epoch_file = handles.options.epoch_file; + set(handles.edOpenMissingEpochFilePath, 'String', handles.epoch_file); + end end; % Update handles structure guidata(hObject, handles); - if numel(varargin) > 0 if ischar(varargin{1}) if exist(varargin{1}, 'file') @@ -122,12 +132,21 @@ function pspm_data_editor_OpeningFcn(hObject, eventdata, handles, varargin) end; elseif isnumeric(varargin{1}) set(handles.pnlInput, 'Visible', 'off'); - set(handles.pnlOutput, 'Visible', 'off'); handles.data = varargin{1}; handles.input_mode = 'raw'; guidata(hObject, handles); PlotData(hObject); + + if isfield(handles, 'options') && isfield(handles.options, 'output_file') + set(handles.pnlOutput, 'Visible', 'off'); + end + if isfield(handles, 'options') && isfield(handles.options, 'epoch_file') + set(handles.pnlEpoch, 'Visible', 'off'); + handles = guidata(hObject); + add_epochs(hObject, handles) + end + end; end; uiwait(handles.fgDataEditor); @@ -752,7 +771,6 @@ function SelectedArea(hObject, action) handles = guidata(hObject); if isfield(handles, 'x_data') - start = handles.select.start; if strcmpi(action, 'highlight') pt = get(handles.axData, 'CurrentPoint'); @@ -861,7 +879,7 @@ function InterpolateData(hObject) sd = handles.selected_data; v_pos = find(~isnan(sd)); xd = handles.x_data; -if numel(v_pos)>1 +if numel(v_pos)>=1 epoch_end = xd([v_pos(find(diff(v_pos) > 1)); v_pos(end)]); epoch_start = xd(v_pos([1;find(diff(v_pos) > 1)+1])); @@ -1241,3 +1259,63 @@ function pbSaveOutput_Callback(hObject, eventdata, handles) % eventdata reserved - to be defined in a future version of MATLAB % handles structure with handles and user data (see GUIDATA) CreateOutput(hObject); + + + +function edOpenMissingEpochFilePath_Callback(hObject, eventdata, handles) +% hObject handle to edOpenMissingEpochFilePath (see GCBO) +% eventdata reserved - to be defined in a future version of MATLAB +% handles structure with handles and user data (see GUIDATA) + +% Hints: get(hObject,'String') returns contents of edOpenMissingEpochFilePath as text +% str2double(get(hObject,'String')) returns contents of edOpenMissingEpochFilePath as a double +if isempty(handles.epoch_file) + set(hObject, 'String', 'No input specified'); +else + set(hObject, 'String', handles.epoch_file); +end; + +% --- Executes during object creation, after setting all properties. +function edOpenMissingEpochFilePath_CreateFcn(hObject, eventdata, handles) +% hObject handle to edOpenMissingEpochFilePath (see GCBO) +% eventdata reserved - to be defined in a future version of MATLAB +% handles empty - handles not created until after all CreateFcns called + +% Hint: edit controls usually have a white background on Windows. +% See ISPC and COMPUTER. +if ispc && isequal(get(hObject,'BackgroundColor'), get(0,'defaultUicontrolBackgroundColor')) + set(hObject,'BackgroundColor','white'); +end + + +% --- If Enable == 'on', executes on mouse press in 5 pixel border. +% --- Otherwise, executes on mouse press in 5 pixel border or over edOpenMissingEpochFilePath. +function edOpenMissingEpochFilePath_ButtonDownFcn(hObject, eventdata, handles) +% hObject handle to edOpenMissingEpochFilePath (see GCBO) +% eventdata reserved - to be defined in a future version of MATLAB +% handles structure with handles and user data (see GUIDATA) + + +% --- Executes on button press in pbOpenMissingEpochFile. +function pbOpenMissingEpochFile_Callback(hObject, eventdata, handles) +% hObject handle to pbOpenMissingEpochFile (see GCBO) +% eventdata reserved - to be defined in a future version of MATLAB +% handles structure with handles and user data (see GUIDATA) +[file, path] = uigetfile('*.mat', 'Select missing epoch file'); +if file ~= 0 + handles.epoch_file = [ path, file ]; + add_epochs(hObject, handles); +end + +function handles = add_epochs(hObject, handles) +E = load(handles.epoch_file, 'epochs'); +epochs = E.epochs'; +% for each ep add an area as if drawn by the user and add to epoch list +for ep = epochs + handles.select.start = [ ep(1), 0.5 ]; + handles.select.stop = [ ep(2), 0.5 ]; + handles.select.p = 0; + guidata(hObject, handles); + SelectedArea(hObject, 'add'); + UpdateEpochList(hObject); +end diff --git a/src/pspm_display.m b/src/pspm_display.m index 43da00cf6..6faebc6a4 100644 --- a/src/pspm_display.m +++ b/src/pspm_display.m @@ -411,10 +411,17 @@ function load_Callback(hObject, eventdata, handles) handles.name=filename; guidata(hObject, handles); + % ---set wave and event channel value to none --------------------- + handles.prop.wave='none'; + set(handles.wave_listbox,'Value',1) + + handles.prop.event='none'; + set(handles.event_listbox,'Value',1) + % ---add text to wave listbox-------------------------------------- listitems{1,1}='none'; - handles.prop.wavechans(1)=0; + handles.prop.wavechans(1)=0; j=2; for k=1:length(handles.data) if any(strcmp(handles.data{k,1}.header.chantype,handles.prop.setwave)) @@ -651,6 +658,7 @@ function ed_y_max_CreateFcn(hObject, eventdata, handles) % ---Initialise------------------------------------------------------------ marker=[]; hbeat=[]; +events=[]; % any other marker channels wave=[]; % get eventchan info @@ -658,10 +666,23 @@ function ed_y_max_CreateFcn(hObject, eventdata, handles) marker=handles.data{handles.prop.eventchans(handles.prop.idevent),1}.data; if get(handles.radio_extra,'Value')==1 handles.prop.event='extra'; - else handles.prop.event='integrated'; + else + handles.prop.event='integrated'; end elseif not(isempty(handles.prop.eventchans)) && not(handles.prop.eventchans(handles.prop.idevent)==0) && strcmp(handles.data{handles.prop.eventchans(handles.prop.idevent),1}.header.chantype,'hb') hbeat=handles.data{handles.prop.eventchans(handles.prop.idevent),1}.data; + if get(handles.radio_extra,'Value')==1 + handles.prop.event='extra'; + else + handles.prop.event='integrated'; + end +elseif not(isempty(handles.prop.eventchans)) && not(handles.prop.eventchans(handles.prop.idevent)==0) + events=handles.data{handles.prop.eventchans(handles.prop.idevent),1}.data; + if get(handles.radio_extra,'Value')==1 + handles.prop.event='extra'; + else + handles.prop.event='integrated'; + end end % get wave chan info @@ -672,7 +693,7 @@ function ed_y_max_CreateFcn(hObject, eventdata, handles) % plotting if no channel is selected % ------------------------------------------------------------------------- -if isempty(marker) && isempty(wave) && isempty(hbeat) +if isempty(marker) && isempty(wave) && isempty(hbeat) && isempty(events) hold off x=1; y=1; @@ -680,7 +701,7 @@ function ed_y_max_CreateFcn(hObject, eventdata, handles) text(x,y,' nothing to display - please select channel ','FontSize',20,'BackgroundColor','k',... 'Color','w','HorizontalAlignment','Center','VerticalAlignment',... 'Middle') -elseif not(isempty(marker)) || not(isempty(wave)) || not(isempty(hbeat)) +elseif not(isempty(marker)) || not(isempty(wave)) || not(isempty(hbeat)) || not(isempty(events)) % ---prepare ecg----------------------------------------------------------- if not(isempty(handles.prop.wave)) % only if there is wave info @@ -719,53 +740,76 @@ function ed_y_max_CreateFcn(hObject, eventdata, handles) base(1)=min(wave)-.1*min(wave); base(2)=min(wave)-(max(wave)-min(wave)); - if strcmp(handles.prop.event,'hb') - R=zeros(size(wave)); - R(R==0)=NaN; + if not(isempty(hbeat)) + + hbeat=round(hbeat*sr.wave); - if not(isempty(hbeat)) - hbeat=round(hbeat*sr.wave); - else - warning('No information on heartbeats. Plotting not possible'); - end + HBEAT = nan(size(wave)); - if strcmp(handles.prop.wave,'ecg') - R(hbeat,1)=max(wave); - hold on ; stem(y,R,'r') - else - R(hbeat,1)=max(base(1)); - hold on ; h=stem(y,R,'r'); - hbase=get(h,'Baseline'); - set(hbase,'Basevalue',base(2)); - + if strcmp(handles.prop.event,'extra') + HBEAT(hbeat,1)=min(wave)-.5; + elseif strcmp(handles.prop.event,'integrated') + temp=wave(hbeat,1); + temp(isnan(temp)) = median(temp,'omitnan'); + HBEAT(hbeat,1)=temp; + end + + hold on ; h=stem(y,HBEAT,'ro'); + hbase=get(h,'Baseline'); + + if strcmp(handles.prop.event,'extra') + set(hbase,'BaseValue',base(2),'Visible','off'); + elseif strcmp(handles.prop.event,'integrated') + set(hbase,'BaseValue',base(1),'Visible','off'); end - elseif strcmp(handles.prop.event,'integrated') || strcmp(handles.prop.event,'extra') + elseif not(isempty(marker)) marker=round(marker*sr.wave); if marker(1,1)==0 marker(1,1)=1; end marker=marker(marker~=0); - MARKER=zeros(size(wave)); + MARKER=nan(size(wave)); if strcmp(handles.prop.event,'extra') MARKER(marker,1)=min(wave)-.5; elseif strcmp(handles.prop.event,'integrated') temp=wave(marker,1); - median_non_nan_vals = median(temp,'omitnan'); + temp(isnan(temp)) = median(temp,'omitnan'); MARKER(marker,1)=temp; - MARKER(isnan(MARKER))=median_non_nan_vals; end - MARKER(MARKER==0)=NaN; hold on ; h=stem(y,MARKER,'ro'); hbase=get(h,'Baseline'); if strcmp(handles.prop.event,'extra') set(hbase,'BaseValue',base(2),'Visible','off'); - else set(hbase,'BaseValue',base(1),'Visible','off'); + elseif strcmp(handles.prop.event,'integrated') + set(hbase,'BaseValue',base(1),'Visible','off'); + end + elseif not(isempty(events)) + + events=round(events*sr.wave); + + EVENTS = nan(size(wave)); + + if strcmp(handles.prop.event,'extra') + EVENTS(events,1)=min(wave)-.5; + elseif strcmp(handles.prop.event,'integrated') + temp=wave(events,1); + temp(isnan(temp)) = median(temp,'omitnan'); + EVENTS(events,1)=temp; + end + + hold on ; h=stem(y,EVENTS,'r'); + hbase=get(h,'Baseline'); + + if strcmp(handles.prop.event,'extra') + set(hbase,'BaseValue',base(2),'Visible','off'); + elseif strcmp(handles.prop.event,'integrated') + set(hbase,'BaseValue',base(1),'Visible','off'); end end @@ -792,35 +836,48 @@ function ed_y_max_CreateFcn(hObject, eventdata, handles) end xlabel(' Time in seconds [s] ','Fontsize',16); - - if (strcmp(handles.prop.event,'integrated')||strcmp(handles.prop.event,'extra')) && not(strcmp(handles.prop.wave,'none')) + + if not(isempty(marker)) legend(handles.prop.wave,'marker') - elseif not(strcmp(handles.prop.event,'none')) && not(strcmp(handles.prop.wave,'none')) - legend(handles.prop.wave,handles.prop.event) - elseif not(strcmp(handles.prop.event,'none')) && not(strcmp(handles.prop.wave,'none')) + elseif not(isempty(hbeat)) + legend(handles.prop.wave,'heartbeats') + elseif not(isempty(events)) + legend(handles.prop.wave,[handles.event_listbox.String{handles.prop.idevent},' events']) + elseif not(strcmp(handles.prop.event,'none')) + legend(handles.prop.wave,'unknown events') + else legend(handles.prop.wave) end % plotting if only event channel is selected % ------------------------------------------------------------------------- - elseif isempty(wave) && (not(isempty(hbeat)) || not(isempty(marker))) - if strcmp(handles.prop.event,'hb') + elseif isempty(wave) && (not(isempty(hbeat)) || not(isempty(marker)) || not(isempty(events))) + if not(isempty(hbeat)) MARKER=diff(hbeat); plot(MARKER,'ro') ylabel('duration of ibi [s]','Fontsize',14) - elseif strcmp(handles.prop.event,'extra') || strcmp(handles.prop.event,'integrated') + elseif not(isempty(marker)) MARKER=diff(marker); stem(MARKER,'r') - else MARKER=[]; + ylabel('inter-marker duration [s]','Fontsize',14) + elseif not(isempty(events)) + MARKER=diff(events); + plot(MARKER,'ro') + ylabel('inter-event duration [s]','Fontsize',14) + else + MARKER=[]; end xlabel('Time in seconds [s] ','Fontsize',16); - if strcmp(handles.prop.event,'integrated') || strcmp(handles.prop.event,'extra') + if not(isempty(marker)) legend('marker') - elseif strcmp(handles.prop.event,'hb') + elseif not(isempty(hbeat)) legend('heartbeats') - else legend('unknown marker') + elseif not(isempty(events)) + legend([handles.event_listbox.String{handles.prop.idevent},' events']) + else + legend('unknown events') end end end @@ -947,10 +1004,10 @@ function push_plot_Callback(hObject, eventdata, handles) handles.prop.event=handles.prop.event{handles.prop.idevent,1}; % ---deactivate marker buttons if necessary-------------------------------- -if handles.prop.idevent==1 || strcmp(handles.prop.event,'hb') || handles.prop.idwave==1 +if handles.prop.idevent==1 || handles.prop.idwave==1 set(handles.radio_int,'Enable','Off'); set(handles.radio_extra,'Enable','Off'); -elseif not(handles.prop.idevent==1) && strcmp(handles.prop.event,'marker') +else set(handles.radio_int,'Enable','On'); set(handles.radio_extra,'Enable','On'); end diff --git a/src/pspm_extract_segments.m b/src/pspm_extract_segments.m index ce3d639a3..2b1497abf 100644 --- a/src/pspm_extract_segments.m +++ b/src/pspm_extract_segments.m @@ -613,8 +613,8 @@ segments{c}.std = nanstd(m,0,2); segments{c}.sem = segments{c}.std./sqrt(n_onsets_in_cond{c}); - segments{c}.trial_nan_percent = sum(isnan(m))/size(m,1); - segments{c}.total_nan_percent = sum(sum(isnan(m)))/numel(m); + segments{c}.trial_nan_percent = 100.0 * sum(isnan(m))/size(m,1); + segments{c}.total_nan_percent = 100.0 * sum(sum(isnan(m)))/numel(m); % segments{c}.total_nan_percent = mean(segments{c}.trial_nan_percent); @@ -729,4 +729,4 @@ end; sts = 1; - \ No newline at end of file + diff --git a/src/pspm_get_csv.m b/src/pspm_get_csv.m new file mode 100644 index 000000000..e9d3a7c31 --- /dev/null +++ b/src/pspm_get_csv.m @@ -0,0 +1,19 @@ +function [sts, import, sourceinfo] = pspm_get_csv(datafile, import) +% pspm_get_csv is the main function for import of csv files, +% it adds the comma delimiter to import channels and the runs pspm_get_txt +% +% FORMAT: [sts, import, sourceinfo] = pspm_get_csv(datafile, import); +% datafile: a .csv or .txt file containing numerical data with comma delimiter +% and optionally the channel names in the first line. +% import: import job structure +% A delimiter of ',' is applied to all import channels +% +%__________________________________________________________________________ +% PsPM 5.0 +% (C) 2008-2020 Dominik R Bach (Wellcome Trust Centre for Neuroimaging) + +% $Id$ +% $Rev$ + +import = cellfun(@(c) setfield(c, 'delimiter', ','), import, 'UniformOutput', false); +[sts, import, sourceinfo] = pspm_get_txt(datafile, import); \ No newline at end of file diff --git a/src/pspm_get_events.m b/src/pspm_get_events.m index aefb0f5d9..f29a8aa96 100644 --- a/src/pspm_get_events.m +++ b/src/pspm_get_events.m @@ -90,7 +90,7 @@ mPos = lo2hi+3; elseif isfield(import, 'flank') && strcmpi(import.flank, 'descending') import.data = (hi2lo+1)./import.sr; - mPos = hi2lo+2; + mPos = hi2lo+3; elseif numel(lo2hi) == numel(hi2lo) % only use mean if amount of minima corresponds to amount of maxima % otherwise output a warning @@ -133,7 +133,7 @@ elseif isfield(import, 'markerinfo') && ... (numel(data) - numel(import.markerinfo.value))/import.sr < 1 % also translate marker info if necessary. this code was written - % with and for import_eyelink function. there flank = 'ascending' + % with and for import_eyelink function. there flank = 'all' % has to be set to use import.data as index for the marker values. n_minfo = struct('value', {import.markerinfo.value(round(import.data*import.sr))}, ... diff --git a/src/pspm_get_eyelink.m b/src/pspm_get_eyelink.m index 329671989..3f124ca9b 100644 --- a/src/pspm_get_eyelink.m +++ b/src/pspm_get_eyelink.m @@ -24,28 +24,6 @@ % the unit to which the data should be converted and % in which eyelink_trackdist is given % - % .blink_saccade_edge_discard_factor: - % Factor used to determine the number of - % samples right before and right after a blink/saccade - % period to discard. This value is multiplied by the - % sampling rate of the recording to determine the - % number of samples to discard from one end. Therefore, - % for each blink/saccade period, 2*this_value*SR many - % samples are discarded in total, and effectively - % blink/saccade period is extended. - % - % This value also corresponds to the duration of - % samples to discard on one end in seconds. For example, - % when it is 0.01, we discard 10 ms worth of data on - % each end of every blink/saccade period. - % - % The default value has been changed to 0 in PsPM revision - % r803 to reduce the amount of discarded data. Note that - % this might result in noisy samples around blink/saccade - % points. Therefore, it is highly recommended to perform - % pupil size data preprocessing using pspm_pupil_pp and - % gaze data filtering using pspm_find_valid_fixations. - % (Default: 0) % % % In this function, channels related to eyes will not produce an error, if @@ -66,18 +44,6 @@ sourceinfo = []; sts = -1; % add specific import path for specific import function addpath(pspm_path('Import','eyelink')); - default_blink_saccade_discard_factor = 0; - for i = 1:numel(import) - if ~isfield(import{i}, 'blink_saccade_edge_discard_factor') - import{i}.blink_saccade_edge_discard_factor = default_blink_saccade_discard_factor; - end - - if ~isnumeric(import{i}.blink_saccade_edge_discard_factor) || ... - import{i}.blink_saccade_edge_discard_factor < 0 - warning('ID:invalid_input', 'Edge discard factor must be a positive number'); - return; - end - end % transfer options % ------------------------------------------------------------------------- @@ -103,18 +69,13 @@ mask_chans = {'blink_l', 'blink_r', 'saccade_l', 'saccade_r'}; end expand_factor = 0; - if i <= numel(import) - expand_factor = import{i}.blink_saccade_edge_discard_factor; - else - expand_factor = default_blink_saccade_discard_factor; - end - data{i}.channels = expand_mask_chans(... + + data{i}.channels = blink_saccade_filtering(... data{i}.channels, ... data{i}.channels_header, ... mask_chans, ... expand_factor * data{i}.sampleRate ... ); - data{i}.channels = set_blinks_saccades_to_nan(data{i}.channels, data{i}.channels_header, mask_chans, @(x) endsWith(x, '_l')); end rmpath(pspm_path('backroom')); @@ -256,8 +217,10 @@ % imported data cannot be read at the moment (in later instances) import{k}.markerinfo = markerinfos; - % use 'all' flank for translation from continuous to events - import{k}.flank = 'all'; + % by default use 'all' flank for translation from continuous to events + if ~isfield(import{k},'flank') + import{k}.flank = 'all'; + end else % determine chan id from chantype - eyelink specific % thats why channel ids will be ignored! @@ -409,30 +372,3 @@ sts = 1; return; end - -function data = expand_mask_chans(data, column_names, mask_chans, offset) - for chan = mask_chans - col_idx = find(strcmpi(column_names, chan{1})); - data(:, col_idx) = expand_mask(data(:, col_idx), offset); - end -end - -function mask = expand_mask(mask, offset) - diffmask = diff(mask); - indices_to_expand_towards_left = find(diffmask == 1) + 1; - indices_to_expand_towards_right = find(diffmask == (-1)); - - for ii = 1:numel(indices_to_expand_towards_left) - idx = indices_to_expand_towards_left(ii); - begidx = max(1, idx - offset); - endidx = max(1, idx - 1); - mask(begidx : endidx) = true; - end - ndata = numel(mask); - for ii = 1:numel(indices_to_expand_towards_right) - idx = indices_to_expand_towards_right(ii); - begidx = min(ndata, idx + 1); - endidx = min(ndata, idx + offset); - mask(begidx : endidx) = true; - end -end diff --git a/src/pspm_get_smi.m b/src/pspm_get_smi.m index 217dc758c..cd0a04853 100644 --- a/src/pspm_get_smi.m +++ b/src/pspm_get_smi.m @@ -332,7 +332,10 @@ function [import_cell, chan_id] = import_marker_chan(import_cell, markers, mi_values, mi_names, n_rows, sampling_rate) import_cell.marker = 'continuous'; - import_cell.flank = 'ascending'; + % by default use 'ascending' flank for SMI data + if ~isfield(import_cell,'flank') + import_cell.flank = 'ascending'; + end import_cell.sr = sampling_rate; import_cell.data = false(n_rows, 1); marker_indices = 1 + markers * sampling_rate; diff --git a/src/pspm_get_sp_speed.m b/src/pspm_get_sps.m similarity index 59% rename from src/pspm_get_sp_speed.m rename to src/pspm_get_sps.m index 8e6557b2f..992c60096 100644 --- a/src/pspm_get_sp_speed.m +++ b/src/pspm_get_sps.m @@ -1,9 +1,9 @@ -function [sts, data] = pspm_get_sp_speed(import ) -% pspm_get_sp_speed is a comon function for importing eyelink data (distances +function [sts, data] = pspm_get_sps(import) +% pspm_get_sps is a comon function for importing eyelink data (distances % between following data points) % % FORMAT: -% [sts, data]=pspm_get_sp_speed(import) +% [sts, data]=pspm_get_sps(import) % with import.data: column vector of waveform data % import.sr: sample rate % @@ -11,14 +11,16 @@ global settings; if isempty(settings), pspm_init; end; + + % initialise status sts = -1; -% assign respiratory data +% assign sps data data.data = import.data(:); % add header -data.header.chantype = 'sp_speed'; +data.header.chantype = 'sps'; data.header.units = import.units; data.header.sr = import.sr; data.header.range = import.range; diff --git a/src/pspm_get_sps_l.m b/src/pspm_get_sps_l.m new file mode 100644 index 000000000..54f53edee --- /dev/null +++ b/src/pspm_get_sps_l.m @@ -0,0 +1,32 @@ +function [sts, data] = pspm_get_sps_l(import) +% pspm_get_sps_l is a comon function for importing left eye eyelink data (distances +% between following data points) +% +% FORMAT: +% [sts, data]=pspm_get_sps_l(import) +% with import.data: column vector of waveform data +% import.sr: sample rate +% + +global settings; +if isempty(settings), pspm_init; end; + + + +% initialise status +sts = -1; + +% assign sps data +data.data = import.data(:); + +% add header +data.header.chantype = 'sps_l' +data.header.units = import.units; +data.header.sr = import.sr; +data.header.range = import.range; + +% check status +sts = 1; + +end + diff --git a/src/pspm_get_sps_r.m b/src/pspm_get_sps_r.m new file mode 100644 index 000000000..e9084c5bd --- /dev/null +++ b/src/pspm_get_sps_r.m @@ -0,0 +1,32 @@ +function [sts, data] = pspm_get_sps_r(import) +% pspm_get_sps_r is a comon function for importing right eye eyelink data (distances +% between following data points) +% +% FORMAT: +% [sts, data]=pspm_get_sps_r(import) +% with import.data: column vector of waveform data +% import.sr: sample rate +% + +global settings; +if isempty(settings), pspm_init; end; + + + +% initialise status +sts = -1; + +% assign sps data +data.data = import.data(:); + +% add header +data.header.chantype = 'sps_r' +data.header.units = import.units; +data.header.sr = import.sr; +data.header.range = import.range; + +% check status +sts = 1; + +end + diff --git a/src/pspm_get_txt.m b/src/pspm_get_txt.m index 8cd52b826..c3297c6ec 100644 --- a/src/pspm_get_txt.m +++ b/src/pspm_get_txt.m @@ -2,22 +2,51 @@ % pspm_get_txt is the main function for import of text files % % FORMAT: [sts, import, sourceinfo] = pspm_get_txt(datafile, import); -% datafile: a .txt-file containing numerical data (with any -% delimiter) and optionally the channel names in the first -% line. +% datafile: a .txt-file containing numerical data (with any +% delimiter) and optionally the channel names in the first +% line. +% import: import job structure +% - required fields: +% .type: +% A char array corresponding to a valid PsPM data +% type, see `pspm_init.m` for more details. +% .channel: +% A numeric value representing the column number +% of the corresponding numerical data. +% - optional fields: +% .delimiter: +% A char array corresponding to the delimiter +% used in the datafile to delimit data columns. +% To be used it should be specified on the first +% import cell, e.g.: +% import{1}.delimiter == ',' +% Default: white-space (see textscan function) +% .header_lines: +% A numeric value corresponding to the number of +% header lines. Which means the data start on +% line number: "header_lines + 1". +% To be used it should be specified on the first +% import cell, e.g.: +% import{1}.header_lines == 3 +% Default: 1. +% .channel_names_line: +% A numeric value corresponding to the line +% number where the channel names are specified. +% To be used it should be specified on the first +% import cell, e.g.: +% import{1}.channel_names_line == 2 +% Default: 1. +% .exclude_columns: +% A numeric value corresponding to the number of +% columns to exclude starting from the left. +% To be used it should be specified on the first +% import cell, e.g.: +% import{1}.exclude_columns == 2 +% Default: 0. %__________________________________________________________________________ % PsPM 3.0 % (C) 2008-2015 Dominik R Bach (Wellcome Trust Centre for Neuroimaging) - -% $Id$ -% $Rev$ - -% v005 lr 23.09.2013 added support for channel names -% v004 lr 09.09.2013 removed bugs -% v003 drb 31.07.2013 changed for 3.0 architecture -% v002 drb 11.02.2011 comply with new pspm_import requirements -% v001 drb 16.9.2009 - +% (c) 2020 Ivan Rojkov (UZH) - added dsv support % initialise % ------------------------------------------------------------------------- @@ -25,31 +54,92 @@ if isempty(settings), pspm_init; end; sourceinfo = []; sts = -1; -% load & check data +% check import structure options +% ------------------------------------------------------------------------- +if ~isfield(import{1}, 'delimiter') || isempty(import{1}.delimiter) + delim = 0; +elseif ~ischar(import{1}.delimiter) + warning('ID:invalid_input','The ''delimiter'' option should be a char array.') + return; +else + delim = import{1}.delimiter; +end + +if ~isfield(import{1}, 'header_lines') + header_lines = 1; +elseif ~isnumeric(import{1}.header_lines) + warning('ID:invalid_input','The ''header_lines'' option should be a numeric value.') + return; +else + header_lines = import{1}.header_lines; +end + +if ~isfield(import{1}, 'channel_names_line') + channel_names_line = 1; + if header_lines < channel_names_line, channel_names_line=0; end +elseif ~isnumeric(import{1}.channel_names_line) + warning('ID:invalid_input','The ''channel_names_line'' option should be a numeric value.') + return; +else + channel_names_line = import{1}.channel_names_line; +end + +if ~isfield(import{1}, 'exclude_columns') + exclude_columns = 0; +elseif ~isnumeric(import{1}.exclude_columns) + warning('ID:invalid_input','The ''exclude_columns'' option should be a numeric value.') + return; +else + exclude_columns = import{1}.exclude_columns; +end + +% read channel names % ------------------------------------------------------------------------- fid = fopen(datafile); -channel_names = textscan(fgetl(fid), '%s'); + +% go to the specific line to read the channel names +if channel_names_line ~= 0 + for k=1:channel_names_line-1 + fgetl(fid); % read and dump + end +end + +if ischar(delim) + channel_names = textscan(fgetl(fid), '%s', 'Delimiter', delim); +else + channel_names = textscan(fgetl(fid), '%s'); +end channel_names = channel_names{1}; + fclose(fid); -fline = str2double(channel_names); -if ~any(isnan(fline)) %no headerline - data = dlmread(datafile); -elseif all(isnan(fline)) %headerline - fid = fopen(datafile); - formatSpec = ''; - for i=1:numel(channel_names) - formatSpec = [formatSpec '%f']; - end - data = textscan(fid, formatSpec, 'HeaderLines', 1); - data = cell2mat(data); - fclose(fid); +% load & check data +% ------------------------------------------------------------------------- +fid = fopen(datafile); + +formatSpec = repmat('%f', 1, numel(channel_names)); +if exclude_columns + formatSpec = repmat('%*s', 1, exclude_columns); + formatSpec = [formatSpec,repmat('%f', 1,numel(channel_names)-exclude_columns)]; +end + +if ischar(delim) + data = textscan(fid, formatSpec, 'HeaderLines', header_lines, 'Delimiter', delim); else - warning('The format of %s is not supported', datafile); return; + data = textscan(fid, formatSpec, 'HeaderLines', header_lines); end -if isempty(data), warning('An error occured while reading a textfile.\n'); return; end; - +fclose(fid); + +try + data = cell2mat(data); + if isempty(data), error('The imported data are empty.'); end +catch + warning('ID:textscan_error','An error occured while reading a textfile.\n'); + return; +end + +% warning('An error occured while reading a textfile.\n'); return; end; % select desired channels % ------------------------------------------------------------------------- @@ -57,16 +147,21 @@ % define channel number if import{k}.channel > 0 chan = import{k}.channel; - else + elseif channel_names_line ~= 0 chan = pspm_find_channel(channel_names, import{k}.type); if chan < 1, return; end; - end; + else + warning('ID:invalid_input', ... + ['Neiter ''channel'' nor ''channel_names_line'' options were specified.', ... + ' Not able to import the data.']) + return; + end if chan > size(data, 2), warning('ID:channel_not_contained_in_file', 'Channel %02.0f not contained in file %s.\n', chan, datafile); return; end; import{k}.data = data(:, chan); - if strcmpi(settings.chantypes(import{k}.typeno).data, 'events') + if isfield(import{k},'typeno') && strcmpi(settings.chantypes(import{k}.typeno).data, 'events') import{k}.marker = 'continuous'; end; diff --git a/src/pspm_get_viewpoint.m b/src/pspm_get_viewpoint.m index e2bbdd2dc..76085702b 100644 --- a/src/pspm_get_viewpoint.m +++ b/src/pspm_get_viewpoint.m @@ -122,7 +122,7 @@ else mask_chans = {'blink_l', 'blink_r', 'saccade_l', 'saccade_r'}; end - data_concat = set_blinks_saccades_to_nan(data_concat, chan_struct, mask_chans, @(x) endsWith(x, '_l')); + data_concat = set_blinks_saccades_to_nan(data_concat, chan_struct, mask_chans, @(x) strcmp(x(end-1:end), '_l')); rmpath(pspm_path('backroom')); num_import_cells = numel(import); @@ -323,7 +323,10 @@ mi_values = mi_values(non_empty,1); import_cell.marker = 'continuous'; - import_cell.flank = 'ascending'; + % by default use 'ascending' flank for ViewPoint data + if ~isfield(import_cell,'flank') + import_cell.flank = 'ascending'; + end import_cell.sr = sampling_rate; import_cell.data = false(n_rows, 1); marker_indices = 1 + markers * sampling_rate; diff --git a/src/pspm_glm.m b/src/pspm_glm.m index c49aa9ac7..e7f5d10b2 100644 --- a/src/pspm_glm.m +++ b/src/pspm_glm.m @@ -68,6 +68,10 @@ % at the end of the output. In 'free' models the field % model.window is MANDATORY and single basis functions % are allowed only. +% model.centering: if set to 0 the function would not perform the +% mean centering of the convolved X data. For example, to +% invert SPS model, set centering to 0. +% Default: 1 % % OPTIONS (optional argument) % options.overwrite: overwrite existing model output; default 0 diff --git a/src/pspm_import.m b/src/pspm_import.m index 1848ef12a..3468b02d3 100644 --- a/src/pspm_import.m +++ b/src/pspm_import.m @@ -32,7 +32,9 @@ % Can be one of the following units:'mm', 'cm', 'm','inches'. % - .denoise: for marker channels in CED spike format (recorded % as 'level'), filters out markers duration longer than the -% value given here (in ms) +% value given here (in ms) +% - .delimiter: for delimiter separated values, value used as delimiter for file read +% % options: options.overwrite - overwrite existing files by default % % RETURNS diff --git a/src/pspm_init.m b/src/pspm_init.m index ef371a1e1..b65bd9bfd 100644 --- a/src/pspm_init.m +++ b/src/pspm_init.m @@ -373,27 +373,39 @@ defaults.chantypes(30) = ... struct('type', 'sps', ... 'description', 'Scanpath speed', ... - 'import', @pspm_get_sp_speed, ... + 'import', @pspm_get_sps, ... 'data', 'wave'); -% documented defaults.chantypes(31) = ... + struct('type', 'sps_r', ... + 'description', 'Scanpath speed', ... + 'import', @pspm_get_sps_r, ... + 'data', 'wave'); + +defaults.chantypes(32) = ... + struct('type', 'sps_l', ... + 'description', 'Scanpath speed', ... + 'import', @pspm_get_sps_l, ... + 'data', 'wave'); + +% documented +defaults.chantypes(33) = ... struct('type', 'custom', ... 'description', 'Custom', ... 'import', @pspm_get_custom, ... 'data', 'wave'); -defaults.chantypes(32) = ... +defaults.chantypes(34) = ... struct('type', 'pupil_r_pp', ... 'description', 'Pupil right preprocessed', ... 'import', @none, ... 'data', 'wave'); % documented -defaults.chantypes(33) = ... +defaults.chantypes(35) = ... struct('type', 'pupil_l_pp', ... 'description', 'Pupil left preprocessed', ... 'import', @none, ... 'data', 'wave'); -defaults.chantypes(34) = ... +defaults.chantypes(36) = ... struct('type', 'pupil_lr_pp', ... 'description', 'Pupil combined preprocessed', ... 'import', @none, ... @@ -472,9 +484,43 @@ 'comma as used in some non-English speaking countries). At the moment, ', ... 'no import of event markers is possible']); +% Delimiter Separated files +% ---------- +defaults.import.datatypes(4) = ... + struct('short', 'dsv', ... + 'long', 'Delimiter Separated Values', ... + 'ext', 'any', ... + 'funct', @pspm_get_txt, ... + 'chantypes', {{defaults.importchantypes(strcmpi('wave',{defaults.importchantypes.data}) | ... + strcmpi('marker', {defaults.importchantypes.type})).type}}, ... %all wave channels + marker + 'chandescription', 'column', ... + 'multioption', 1, ... + 'searchoption', 1, ... + 'automarker', 0, ... + 'autosr', 0, ... + 'help', ['Reads a file using a custom delimiter, for example',... + 'a delimiter or a comma (,) would read the same as a csv']); + +% CSV - copy of dsv with partially applied delimiter +defaults.import.datatypes(5) = ... + struct('short', 'csv', ... + 'long', 'Comma Separated Values', ... + 'ext', 'csv', ... + 'funct', @pspm_get_csv, ... + 'chantypes', {{defaults.importchantypes(strcmpi('wave',{defaults.importchantypes.data}) | ... + strcmpi('marker', {defaults.importchantypes.type})).type}}, ... %all wave channels + marker + 'chandescription', 'column', ... + 'multioption', 1, ... + 'searchoption', 1, ... + 'automarker', 0, ... + 'autosr', 0, ... + 'help', ['Read using comma as a delimiter']); + + + % Biopac Acknowledge up to version 3.9.0 % -------------------------------------- -defaults.import.datatypes(4) = ... +defaults.import.datatypes(6) = ... struct('short', 'acq', ... 'long', 'Biopac Acqknowledge 3.9.0 or lower (.acq)', ... 'ext', 'acq', ... @@ -489,7 +535,7 @@ % exported Biopac Acqknowledge (tested on version 4.2.0) % ----------------------------------------------------- -defaults.import.datatypes(5) = ... +defaults.import.datatypes(7) = ... struct('short', 'acqmat', ... 'long', 'matlab-exported Biopac Acqknowledge 4.0 or higher', ... 'ext', 'mat', ... @@ -504,7 +550,7 @@ % bioread converted Biopac Acqknowledge (any version) % ----------------------------------------------------- -defaults.import.datatypes(6) = ... +defaults.import.datatypes(8) = ... struct('short', 'acq_bioread', ... 'long', 'bioread-converted Biopac Acqknowledge (any version)', ... 'ext', 'mat', ... @@ -523,7 +569,7 @@ % ADInstruments Labchart (any Version) % ----------------------------------------------------- -defaults.import.datatypes(7) = ... +defaults.import.datatypes(9) = ... struct('short', 'labchartmat', ... 'long', 'ADInstruments LabChart (any Version, Windows only)', ... 'ext', 'adicht', ... @@ -540,7 +586,7 @@ % exported ADInstruments Labchart up to 7.1 % ----------------------------------------------------- -defaults.import.datatypes(8) = ... +defaults.import.datatypes(10) = ... struct('short', 'labchartmat_ext', ... 'long', 'matlab-exported ADInstruments LabChart 7.1 or lower', ... 'ext', 'mat', ... @@ -556,7 +602,7 @@ % exported ADInstruments Labchart 7.2 or higher % ----------------------------------------------------- -defaults.import.datatypes(9) = ... +defaults.import.datatypes(11) = ... struct('short', 'labchartmat_in', ... 'long', 'matlab-exported ADInstruments LabChart 7.2 or higher', ... 'ext', 'mat', ... @@ -571,7 +617,7 @@ % VarioPort % ----------------------------------------------------- -defaults.import.datatypes(10) = ... +defaults.import.datatypes(12) = ... struct('short', 'vario', ... 'long', 'VarioPort (.vdp)', ... 'ext', 'vpd', ... @@ -586,7 +632,7 @@ % exported Biograph Infiniti % ----------------------------------------------------- -defaults.import.datatypes(11) = ... +defaults.import.datatypes(13) = ... struct('short', 'biograph', ... 'long', 'text-exported Biograph Infiniti', ... 'ext', 'txt', ... @@ -602,7 +648,7 @@ % exported MindMedia Biotrace % ----------------------------------------------------- -defaults.import.datatypes(12) = ... +defaults.import.datatypes(14) = ... struct('short', 'biotrace', ... 'long', 'text-exported MindMedia Biotrace', ... 'ext', 'txt', ... @@ -617,7 +663,7 @@ % Brain Vision % ----------------------------------------------------- -defaults.import.datatypes(13) = ... +defaults.import.datatypes(15) = ... struct('short', 'brainvision', ... 'long', 'BrainVision (.eeg)', ... 'ext', 'eeg', ... @@ -632,7 +678,7 @@ % Dataq Windaq (e. g. provided by Coulbourn Instruments) % ----------------------------------------------------- -defaults.import.datatypes(14) = ... +defaults.import.datatypes(16) = ... struct('short', 'windaq', ... 'long', 'DATAQ Windaq (.wdq) (read with ActiveX-Lib)', ... 'ext', 'wdq', ... @@ -649,7 +695,7 @@ % Dataq Windaq (PsPM Version) % ----------------------------------------------------- -defaults.import.datatypes(15) = ... +defaults.import.datatypes(17) = ... struct('short', 'windaq_n', ... 'long', 'DATAQ Windaq (.wdq)', ... 'ext', 'wdq', ... @@ -671,7 +717,7 @@ % Noldus Observer XT compatible .txt files % ----------------------------------------------------- -defaults.import.datatypes(16) = ... +defaults.import.datatypes(18) = ... struct('short', 'observer', ... 'long', 'Noldus Observer XT compatible text file', ... 'ext', 'any', ... @@ -686,7 +732,7 @@ % NeuroScan % ----------------------------------------------------- -defaults.import.datatypes(17) = ... +defaults.import.datatypes(19) = ... struct('short', 'cnt', ... 'long', 'Neuroscan (.cnt)', ... 'ext', 'cnt', ... @@ -701,7 +747,7 @@ % BioSemi % ----------------------------------------------------- -defaults.import.datatypes(18) = ... +defaults.import.datatypes(20) = ... struct('short', 'biosemi', ... 'long', 'BioSemi (.bdf)', ... 'ext', 'bdf', ... @@ -716,7 +762,7 @@ % Eyelink 1000 files % --------------------------------------------- -defaults.import.datatypes(19) = ... +defaults.import.datatypes(21) = ... struct('short', 'eyelink', ... 'long', 'Eyelink 1000 (.asc)', ... 'ext', 'asc', ... @@ -748,7 +794,7 @@ % European Data Format (EDF) % ----------------------------------------------------- -defaults.import.datatypes(20) = ... +defaults.import.datatypes(22) = ... struct('short', 'edf', ... 'long', 'European Data Format (.edf)', ... 'ext', 'edf', ... @@ -763,7 +809,7 @@ % Philips Scanphyslog (.log) % ----------------------------------------------------- -defaults.import.datatypes(21) = ... +defaults.import.datatypes(23) = ... struct('short', 'physlog', ... 'long', 'Philips Scanphyslog (.log)', ... 'ext', 'log', ... @@ -791,7 +837,7 @@ % ViewPoint EyeTracker files % --------------------------------------------- -defaults.import.datatypes(22) = ... +defaults.import.datatypes(24) = ... struct('short', 'viewpoint', ... 'long', 'ViewPoint EyeTracker (.txt)', ... 'ext', 'txt', ... @@ -808,7 +854,7 @@ % SMI EyeTracker files % --------------------------------------------- -defaults.import.datatypes(23) = ... +defaults.import.datatypes(25) = ... struct('short', 'smi', ... 'long', 'SensoMotoric Instruments iView X EyeTracker (.txt)', ... 'ext', 'txt', ... diff --git a/src/pspm_load_data.m b/src/pspm_load_data.m index cf977756b..37837c156 100644 --- a/src/pspm_load_data.m +++ b/src/pspm_load_data.m @@ -271,25 +271,25 @@ best_eye = lower(best_eye); chantype_list = cellfun(@(x) x.header.chantype, data, 'uni', false); pupil_channels = cell2mat(cellfun(... - @(chantype) startsWith(chantype, 'pupil'),... + @(chantype) strncmp(chantype, 'pupil',numel('pupil')),... chantype_list,... 'uni',... false... )); preprocessed_channels = cell2mat(cellfun(... - @(chantype) endsWith(chantype, '_pp'),... + @(chantype) strcmp(chantype(end-2:end), '_pp'),... chantype_list,... 'uni',... false... )); combined_channels = cell2mat(cellfun(... - @(chantype) contains(chantype, '_lr_') && endsWith(chantype, '_pp'),... + @(chantype) contains(chantype, '_lr_') && strcmp(chantype(end-2:end), '_pp'),... chantype_list,... 'uni',... false... )); besteye_channels = cell2mat(cellfun(... - @(chantype) endsWith(chantype, ['_' best_eye]) || contains(chantype, ['_' best_eye '_']),... + @(chantype) strcmp(chantype(end-1:end), ['_' best_eye]) || contains(chantype, ['_' best_eye '_']),... chantype_list,... 'uni',... false... diff --git a/src/pspm_msg.txt b/src/pspm_msg.txt index 888f1989f..9d2f87a40 100644 --- a/src/pspm_msg.txt +++ b/src/pspm_msg.txt @@ -1,10 +1,10 @@ $___________________________________________________________________________ Welcome to PsPM - PsychoPhysiological Modelling (incorporating SCRalyze) -Version 4.3.0 (25.03.2020) +Version 5.0.0 (21.08.2020) $ ------------------------------------------ -(c) 2008-2019 +(c) 2008-2020 Dominik R Bach * Wellcome Trust Centre for Neuroimaging University College London diff --git a/src/pspm_pp.m b/src/pspm_pp.m index 199ed86c1..6565c5561 100644 --- a/src/pspm_pp.m +++ b/src/pspm_pp.m @@ -8,15 +8,24 @@ % pspm_pp('simple_qa', datafile, qa, channelnumber, options) % % Currently implemented: -% 'median': medianfilter for SCR -% n: number of timepoints for median filter -% 'butter': 1st order butterworth low pass filter for SCR -% freq: cut off frequency (min 20 Hz) -% 'simple_qa': Simple quality assessment for SCR -% qa: A struct with quality assessment settings -% min: Minimum value in microsiemens -% max: Maximum value in microsiemens -% slope: Maximum slope in microsiemens per second +% 'median': medianfilter for SCR +% n: number of timepoints for median filter +% 'butter': 1st order butterworth low pass filter for SCR +% freq: cut off frequency (min 20 Hz) +% 'simple_qa': Simple quality assessment for SCR +% qa: A struct with quality assessment settings +% min: Minimum value in microsiemens +% max: Maximum value in microsiemens +% slope: Maximum slope in microsiemens per second +% missing_epochs_filename: If provided will create a .mat file with the missing epochs, +% e.g. abc will create abc.mat +% deflection_threshold: Define an threshold in original data units for a slope to pass to be considerd in the filter. +% This is useful, for example, with oscillatory wave data +% The slope may be steep due to a jump between voltages but we +% likely do not want to consider this to be filtered. +% A value of 0.1 would filter oscillatory behaviour with threshold less than 0.1v but not greater +% Default: 0 - ie will take no effect on filter +% data_island_threshold: A float in seconds to determine the maximum length of unfiltered data between epochs %__________________________________________________________________________ % % References: For 'simple_qa' method, refer to: diff --git a/src/pspm_pupil_correct_eyelink.m b/src/pspm_pupil_correct_eyelink.m index 92df8d691..8e67ccf20 100644 --- a/src/pspm_pupil_correct_eyelink.m +++ b/src/pspm_pupil_correct_eyelink.m @@ -217,6 +217,15 @@ [lsts, infos, gaze_y_data] = pspm_load_data(fn, gaze_y_chan); if lsts ~= 1; return; end + if numel(gaze_x_data) > 1 + warning('ID:multiple_channels', 'There are more than one gaze x channel. We will use the last one'); + gaze_x_data = gaze_x_data(end:end); + end + if numel(gaze_y_data) > 1 + warning('ID:multiple_channels', 'There are more than one gaze y channel. We will use the last one'); + gaze_y_data = gaze_y_data(end:end); + end + % conditionally mandatory input checks % ------------------------------------------------------------------------- if strcmp(gaze_x_data{1}.header.units, 'pixel') || strcmp(gaze_y_data{1}.header.units, 'pixel') @@ -255,7 +264,7 @@ % save data % ------------------------------------------------------------------------- pupil_data{1}.data = pupil_corrected; - if ~endsWith(old_chantype, '_pp') + if ~strcmp(old_chantype(end-2:end), '_pp') pupil_data{1}.header.chantype = [old_chantype '_pp']; end channel_str = num2str(options.channel); diff --git a/src/pspm_pupil_pp.m b/src/pspm_pupil_pp.m index dde781ad5..d7db8263a 100644 --- a/src/pspm_pupil_pp.m +++ b/src/pspm_pupil_pp.m @@ -289,7 +289,7 @@ model.filterRawData(); if combining smooth_signal.header.chantype = 'pupil_lr_pp'; - elseif endsWith(data{1}.header.chantype, '_pp') + elseif strcmp(data{1}.header.chantype(end-2:end), '_pp') smooth_signal.header.chantype = data{1}.header.chantype; else smooth_signal.header.chantype = [data{1}.header.chantype '_pp']; diff --git a/src/pspm_quit.m b/src/pspm_quit.m index a2973f0c0..7b2911b91 100644 --- a/src/pspm_quit.m +++ b/src/pspm_quit.m @@ -1,21 +1,11 @@ % pspm_quit clears settings, removes paths & closes figures %__________________________________________________________________________ -% PsPM 4.3.0 -% (C) 2008-2019 Dominik R Bach (Wellcome Trust Centre for Neuroimaging) +% PsPM 5.0.0 +% (C) 2008-2020 Dominik R Bach (Wellcome Trust Centre for Neuroimaging) % % $Id: pspm_quit.m 805 2019-09-16 07:12:08Z esrefo $ % $Rev: 805 $ % -% v109 drb 14.08.2013 removed import paths -% v108 30.05.2013 removed fil distribution -% v107 17.05.2013 updated footer -% v106 08.05.2012 updated footer & added more import paths -% v105 20.07.2011 updated footer -% v104 7.9.2010 changed import path structure -% v103 drb 2.9.2010 made compatible with other OSs (filesep), added vario -% v102 drb 20.3.2010 added DAVB & acq path -% v101 drb 8.9.2009 - global settings if isempty(settings), pspm_init; end; fs = filesep; @@ -33,6 +23,6 @@ disp(' '); disp('Thanks for using PsPM.'); disp('_____________________________________________________________________________________________'); -disp('PsPM 4.3.0 (c) 2008-2019 Dominik R. Bach'); +disp('PsPM 5.0.0 (c) 2008-2020 Dominik R. Bach'); disp('University of Zurich, CH -- University College London, UK'); diff --git a/src/pspm_rev_dcm.m b/src/pspm_rev_dcm.m index 16efb824c..9a682d7e7 100644 --- a/src/pspm_rev_dcm.m +++ b/src/pspm_rev_dcm.m @@ -78,6 +78,8 @@ function pspm_rev_dcm(dcm, job, sn, trl) data = [y(win), yhat(win)]; subplot(f.r, f.c, n); plot(data); + xt = get(gca, 'XTick'); + set(gca, 'XTickLabel', xt * dcm.input.sr); set(gca, 'YLim', [min(yhat), max(yhat)]); end; diff --git a/src/pspm_simple_qa.m b/src/pspm_simple_qa.m index 99ec1cf72..6b4bcab11 100644 --- a/src/pspm_simple_qa.m +++ b/src/pspm_simple_qa.m @@ -8,17 +8,33 @@ % [sts, out] = pspm_simple_qa(data, sr, options) % % ARGUMENTS: -% data: A numeric vector. Data should be in -% microsiemens. -% sr: Samplerate of the data. This is needed to -% determine the slopes unit. -% options: A struct with algorithm specific settings. -% min: Minimum value in microsiemens (default: 0.05). -% max: Maximum value in microsiemens (default: 60). -% slope: Maximum slope in microsiemens per sec (default: 10). +% data: A numeric vector. Data should be in +% microsiemens. +% sr: Samplerate of the data. This is needed to +% determine the slopes unit. +% options: A struct with algorithm specific settings. +% min: Minimum value in microsiemens (default: 0.05). +% max: Maximum value in microsiemens (default: 60). +% slope: Maximum slope in microsiemens per sec (default: 10). +% missing_epochs_filename: If provided will create a .mat file with the missing epochs, +% e.g. abc will create abc.mat +% deflection_threshold: Define an threshold in original data units for a slope to pass to be considerd in the filter. +% This is useful, for example, with oscillatory wave data due to limited A/D bandwidth +% The slope may be steep due to a jump between voltages but we +% likely do not want to consider this to be filtered. +% A value of 0.1 would filter oscillatory behaviour with threshold less than 0.1v but not greater +% Default: 0.1 +% data_island_threshold: A float in seconds to determine the maximum length of data between NaN epochs. Islands of data +% shorter than this threshold will be removed. +% Default: 0 s - no effect on filter +% expand_epochs: A float in seconds to determine by how much data on the flanks of artefact epochs will be removed. +% Default: 0.5 s +% +% %__________________________________________________________________________ -% PsPM 3.2 -% (C) 2009-2017 Tobias Moser (University of Zurich) +% PsPM 5.0 +% 2009-2017 Tobias Moser (University of Zurich) +% 2020 Samuel Maxwell & Dominik Bach (UCL) % $Id: pspm_pp.m 450 2017-07-03 15:17:02Z tmoser $ % $Rev: 450 $ @@ -48,6 +64,18 @@ options.slope = 10; end +if ~isfield(options, 'deflection_threshold') + options.deflection_threshold = 0.1; +end + +if ~isfield(options, 'data_island_threshold') + options.data_island_threshold = nan; +end + +if ~isfield(options, 'expand_epochs') + options.expand_epochs = 0.5; +end + % sanity checks if ~isnumeric(data) warning('ID:invalid_input', 'Argument ''data'' must be numeric.'); return; @@ -61,19 +89,105 @@ warning('ID:invalid_input', 'Argument ''options.max'' must be numeric.'); return; elseif ~isnumeric(options.slope) warning('ID:invalid_input', 'Argument ''options.slope'' must be numeric.'); return; +elseif isfield(options, 'missing_epochs_filename') + if ~ischar(options.missing_epochs_filename) + warning('ID:invalid_input', 'Argument ''options.missing_epochs_filename'' must be char array.'); return; + end + + [pth, ~, ext] = fileparts(options.missing_epochs_filename); + if ~isempty(pth) && exist(pth,'dir')~=7 + warning('ID:invalid_input','Please specify a valid output directory if you want to save artefact epochs.') + return; + end + if ~isempty(ext) + warning('ID:invalid_input','Please specify a valid filename (without extension) if you want to save artefact epochs.') + return; + end end % create filters d = NaN(size(data)); range_filter = data < options.max & data > options.min; slope_filter = true(size(data)); -slope_filter(2:end) = abs(diff(data)*sr) < options.slope; +diff_data = diff(data); +slope_filter(2:end) = abs(diff_data*sr) < options.slope; + +if (options.deflection_threshold ~= 0) && ~all(slope_filter==1) + + slope_epochs = filter_to_epochs(slope_filter); + for r = slope_epochs' + if range(data(r(1):r(2))) < options.deflection_threshold + slope_filter(r(1):r(2)) = 1; + end + end + +end % combine filters filt = range_filter & slope_filter; + +% find data islands and expand artefact islands +if options.data_island_threshold > 0 || options.expand_epochs > 0 + + % work out data epochs + data_epochs = filter_to_epochs(1-filt); % gives data (rather than artefact) epochs + + if options.expand_epochs > 0 + % remove data epochs too short to be shortened + epoch_duration = diff(data_epochs, 1, 2); + data_epochs(epoch_duration < 2 * ceil(options.expand_epochs * sr), :) = []; + % shorten data epochs + data_epochs(:, 1) = data_epochs(:, 1) + ceil(options.expand_epochs * sr); + data_epochs(:, 2) = data_epochs(:, 2) - ceil(options.expand_epochs * sr); + end + + % correct possibly negative values + data_epochs(data_epochs(:, 2) < 1, 2) = 1; + + if options.data_island_threshold > 0 + epoch_duration = diff(data_epochs, 1, 2); + data_epochs(epoch_duration < options.data_island_threshold * sr, :) = []; + end + + + % write back into data + index(data_epochs(:, 1)) = 1; + index(data_epochs(:, 2)) = -1; + filt = (cumsum(index(:)) == 1); % (thanks Jan: https://www.mathworks.com/matlabcentral/answers/324955-replace-multiple-intervals-in-array-with-nan-no-loops) +end + + d(filt) = data(filt); +% write epochs to mat if missing_epochs_filename option is present +if isfield(options, 'missing_epochs_filename') + if ~isempty(find(filt == 0, 1)) + epochs = filter_to_epochs(filt); + else + epochs = []; + end + + save(options.missing_epochs_filename, 'epochs'); +end + out = d; sts = 1; end + +function epochs = filter_to_epochs(filt) +epoch_on = find(diff(filt) == -1) + 1; +epoch_off = find(diff(filt) == 1); + +% ends on +if (epoch_on(end) > epoch_off(end)) + epoch_off(end + 1) = length(filt); +end + +% starts on +if (epoch_on(1) > epoch_off(1)) + epoch_on = [ 1; epoch_on ]; +end + +epochs = [ epoch_on, epoch_off ]; +end \ No newline at end of file diff --git a/src/pspm_split_sessions.m b/src/pspm_split_sessions.m index d7770cf41..b75c3b265 100644 --- a/src/pspm_split_sessions.m +++ b/src/pspm_split_sessions.m @@ -30,6 +30,10 @@ % Default = 0 % options.verbose Tell the function to display information % about the state of processing. Default = 0 +% options.randomITI Tell the function to use all the markers to +% evaluate the mean distance between them. +% Usefull for random ITI since it reduce the +% variance. Default = 0 % % REMARK for suffix and prefix: % The prefix and suffix intervals will only be applied to data - @@ -90,6 +94,8 @@ options.min_break_ratio = settings.split.min_break_ratio; end +try options.randomITI; catch, options.randomITI = 0; end + % check input arguments % ------------------------------------------------------------------------- if nargin < 1 @@ -124,6 +130,10 @@ warning('ID:invalid_input', 'options.splitpoints has to be numeric.'); return; end +if ~isnumeric(options.randomITI) || ~ismember(options.randomITI, [0, 1]) + warning('ID:invalid_input', 'options.randomITI has to be numeric and equal to 0 or 1.'); return; +end + % work on all data files % ------------------------------------------------------------------------- for d = 1:numel(D) @@ -185,7 +195,7 @@ % relevant data within the mean space % add global mean space - if sta == sto + if sta == sto || options.randomITI mean_space = mean(diff(mrk)); else mean_space = mean(diff(mrk(sta:sto))); diff --git a/src/pspm_trim.m b/src/pspm_trim.m index 789d51edd..6526dec94 100644 --- a/src/pspm_trim.m +++ b/src/pspm_trim.m @@ -262,12 +262,16 @@ % trim file --- for k = 1:numel(data) if ~strcmpi(data{k}.header.units, 'events') % waveform channels - % set start and endpoint - newstartpoint = floor((sta_p + sta_offset) * data{k}.header.sr); + % set start point (`ceil` for protect against having duration < data*sr, + % the "+1" is here because of matlabs convention to start indices from 1) + newstartpoint = ceil((sta_p + sta_offset) * data{k}.header.sr)+1; if newstartpoint == 0, newstartpoint = 1; end + + % set end point newendpoint = floor((sto_p + sto_offset) * data{k}.header.sr); if newendpoint > numel(data{k}.data), ... newendpoint = numel(data{k}.data); end + % trim data data{k}.data=data{k}.data(newstartpoint:newendpoint); else % event channels diff --git a/src/pspm_write_channel.m b/src/pspm_write_channel.m index 8b7015d18..54895abd4 100644 --- a/src/pspm_write_channel.m +++ b/src/pspm_write_channel.m @@ -124,6 +124,7 @@ % ------------------------------------------------------------------------- [nsts, infos, data] = pspm_load_data(fn); if nsts == -1, return; end +importdata = data; %% Find channel according to action % ------------------------------------------------------------------------- @@ -139,8 +140,9 @@ channeli = find(strcmpi(options.channel,fchannels),1,options.delete); end elseif options.channel == 0 - channeli = cellfun(@(x) find(strcmpi(x.header.chantype, fchannels),1,'last'), ... - newdata, 'UniformOutput', 0); + funits = cellfun(@(x) x.header.units, data,'UniformOutput',0); + % if the chantype matches, and unit matches if one is provided + channeli = cellfun(@(n) match_channel(fchannels, funits, n), newdata, 'UniformOutput', 0); channeli = cell2mat(channeli); else channel = options.channel; @@ -228,3 +230,14 @@ sts = 1; end + + +function matches = match_channel(existing_channels, exisiting_units, channel) + if isfield(channel.header, 'units') + matches = find(... + strcmpi(channel.header.chantype, existing_channels)... + & strcmpi(channel.header.units, exisiting_units),1,'last'); + else + matches = find(strcmpi(channel.header.chantype, existing_channels) ,1,'last'); + end +end \ No newline at end of file diff --git a/test/import_eyelink_test.m b/test/import_eyelink_test.m index 850e599e5..bb52d23ae 100644 --- a/test/import_eyelink_test.m +++ b/test/import_eyelink_test.m @@ -46,21 +46,21 @@ function test_import_eyelink_on_file(this, filepath) is_dataline = ~isempty(str2num(parts{1})); is_msgline = strcmp(parts{1}, 'MSG') && (contains(tline, 'CS') || contains(tline, 'US') || contains(tline, 'TS')); - if startsWith(tline, 'SBLINK L') + if strncmp(tline, 'SBLINK L', numel('SBLINK L')) blink_l = true; - elseif startsWith(tline, 'SBLINK R') + elseif strncmp(tline, 'SBLINK R', numel('SBLINK R')) blink_r = true; - elseif startsWith(tline, 'EBLINK L') + elseif strncmp(tline, 'EBLINK L', numel('EBLINK L')) blink_l = false; - elseif startsWith(tline, 'EBLINK R') + elseif strncmp(tline, 'EBLINK R', numel('EBLINK R')) blink_r = false; - elseif startsWith(tline, 'SSACC L') + elseif strncmp(tline, 'SSACC L', numel('SSACC L')) sacc_l = true; - elseif startsWith(tline, 'SSACC R') + elseif strncmp(tline, 'SSACC R', numel('SSACC R')) sacc_r = true; - elseif startsWith(tline, 'ESACC L') + elseif strncmp(tline, 'ESACC L', numel('ESACC L')) sacc_l = false; - elseif startsWith(tline, 'ESACC R') + elseif strncmp(tline, 'ESACC R', numel('ESACC R')) sacc_r = false; end diff --git a/test/import_viewpoint_test.m b/test/import_viewpoint_test.m index 5cd05f4fd..1b43b73d8 100644 --- a/test/import_viewpoint_test.m +++ b/test/import_viewpoint_test.m @@ -56,7 +56,8 @@ function test_import_viewpoint_on_file(this, fn) for i = 1:numel(eventlines) line = eventlines{i}; parts = split(line, sprintf('\t')); - if any(startsWith(parts{3}, {'A:Blink', 'A:Saccade', 'B:Blink', 'B:Saccade'})) && endsWith(parts{3}, 'sec') + if any(strncmp(parts{3}, {'A:Blink', 'A:Saccade', 'B:Blink', 'B:Saccade'},numel(parts{3})))... + && strcmp(parts{3}(end-2:end), 'sec') tend = to_num(parts{2}); foridx = strfind(line, 'for'); @@ -67,13 +68,13 @@ function test_import_viewpoint_on_file(this, fn) begidx = find(timecol == tbeg); endidx = find(timecol == tend); - if startsWith(parts{3}, 'A:Blink') + if strncmp(parts{3}, 'A:Blink', numel('A:Blink')) this.verifyTrue(all(data{1}.channels(begidx : endidx, blink_A_chan) == 1)); - elseif startsWith(parts{3}, 'B:Blink') + elseif strncmp(parts{3}, 'B:Blink', numel('B:Blink')) this.verifyTrue(all(data{1}.channels(begidx : endidx, blink_B_chan) == 1)); - elseif startsWith(parts{3}, 'A:Saccade') + elseif strncmp(parts{3}, 'A:Saccade', numel('A:Saccade')) this.verifyTrue(all(data{1}.channels(begidx : endidx, sacc_A_chan) == 1)); - elseif startsWith(parts{3}, 'B:Saccade') + elseif strncmp(parts{3}, 'B:Saccade', numel('B:Saccade')) this.verifyTrue(all(data{1}.channels(begidx : endidx, sacc_B_chan) == 1)); end end @@ -82,7 +83,7 @@ function test_import_viewpoint_on_file(this, fn) % --------------------------------------------------------------------------- msg_counter = 1; for line = datalines - parts = split(line, sprintf('\t')); + parts = strsplit(line{1},'\t'); msg = parts{marker_index}; if ~isempty(msg) tbeg = to_num(parts{2}); @@ -126,13 +127,13 @@ function test_import_viewpoint(this) end header = fgetl(fid); tline = fgetl(fid); - while ~startsWith(tline, '10') + while ~strncmp(tline, '10', 2) tline = fgetl(fid); end datalines = {}; eventlines = {}; while isstr(tline) - if startsWith(tline, '10') + if strncmp(tline, '10', 2) datalines{end + 1} = tline; else eventlines{end + 1} = tline; diff --git a/test/pspm_blink_saccade_filt_test.m b/test/pspm_blink_saccade_filt_test.m new file mode 100644 index 000000000..3c9531972 --- /dev/null +++ b/test/pspm_blink_saccade_filt_test.m @@ -0,0 +1,71 @@ +classdef pspm_blink_saccade_filt_test < pspm_get_superclass +% PSPM_BLINK_SACCADE_FILT_TEST +% unittest class for the pspm_blink_saccade_filt function +%__________________________________________________________________________ +% (C) 2019 Eshref Yozdemir (University of Zurich) + + properties + fn = fullfile('ImportTestData', 'eyelink', 'u_sc4b31.asc'); + testcases; + fhandle = @pspm_blink_saccade_filt; + end + + methods + function define_testcases(this) + + end + end + + methods (Test) + function invalid_input(this) + this.verifyWarning(@()pspm_blink_saccade_filt(this.fn, 'str'), 'ID:invalid_input'); + options.channel_action = 'delete'; + this.verifyWarning(@()pspm_blink_saccade_filt(this.fn, 0, options), 'ID:invalid_input'); + end + + function test_filtering(this) + factor_list = [0, 0.001, 0.01, 0.1, 1]; + for discard_factor = factor_list + import{1}.type = 'pupil_r'; + import{1}.eyelink_trackdist = 700; + import{1}.distance_unit = 'mm'; + import{2}.type = 'gaze_x_r'; + import{3}.type = 'gaze_y_r'; + import{4}.type = 'blink_r' + import{5}.type = 'saccade_r'; + options.eyelink_trackdist = 700; + options.distance_unit = 'mm'; + options.overwrite = true; + + fn_imported = pspm_import(this.fn, 'eyelink', import, options); + fn_imported = fn_imported{1}; + [sts, ~, data_old] = pspm_load_data(fn_imported); + + options = struct('channel_action', 'replace'); + pspm_blink_saccade_filt(fn_imported, discard_factor, options); + [sts, ~, data_new] = pspm_load_data(fn_imported); + + N = numel(data_old{1}.data); + n_remove = round(discard_factor * data_old{1}.header.sr); + blink_r_indices = find(data_old{4}.data); + sacc_r_indices = find(data_old{5}.data); + for i = 1:3 + this.verifyTrue(assert_nan(data_new{i}.data, blink_r_indices, N, n_remove)); + this.verifyTrue(assert_nan(data_new{i}.data, sacc_r_indices, N, n_remove)); + end + end + end + end +end + +function out = assert_nan(data, indices, N, n_remove) + for idx = indices + lo = max(1, idx - n_remove); + hi = min(N, idx + n_remove); + if ~all(isnan(data(lo:hi))) + out = false; + return; + end + end + out = true; +end diff --git a/test/pspm_convert_gaze_distance_test.m b/test/pspm_convert_gaze_distance_test.m new file mode 100644 index 000000000..4d8e26e64 --- /dev/null +++ b/test/pspm_convert_gaze_distance_test.m @@ -0,0 +1,116 @@ +classdef pspm_convert_gaze_distance_test < matlab.unittest.TestCase +% pspm_convert_gaze_distance_test +% unittest class for the pspm_convert_gaze_distance_test function + + + properties + raw_input_filename = fullfile('ImportTestData', 'eyelink', 'S114_s2.asc'); + fn = ''; + end + + properties (TestParameter) + channel_action = { 'add', 'replace' }; + from = { 'pixel', 'mm', 'inches' }; + target = { 'degree', 'sps' }; + end + + methods + % get the gaze data channels for a specific unit + function len = get_gaze_and_unit(this, data, unit) + len = find(cellfun(@(c) strcmp(c.header.units, unit) && ~isempty(regexp(c.header.chantype, 'gaze_[x|y]_[r|l]')), data)); + end; + end + + methods(TestMethodSetup) + function backup(this) + import = {}; + import{end + 1}.type = 'pupil_r'; + import{end}.eyelink_trackdist = 600; + import{end}.distance_unit = 'mm'; + import{end + 1}.type = 'pupil_l'; + import{end}.eyelink_trackdist = 600; + import{end}.distance_unit = 'mm'; + import{end + 1}.type = 'gaze_x_r'; + import{end + 1}.type = 'gaze_y_r'; + import{end + 1}.type = 'gaze_x_l'; + import{end + 1}.type = 'gaze_y_l'; + import{end + 1}.type = 'marker'; + options.overwrite = true; + this.fn = pspm_import(this.raw_input_filename, 'eyelink', import, options); + this.fn = this.fn{1}; + end + end + + + methods (Test) + + function validations(this, target) + this.verifyWarningFree(@() pspm_convert_gaze_distance(this.fn, target, 'pixel', 111, 222, 333)); + this.verifyWarning(@() pspm_convert_gaze_distance(this.fn, target, "not_a_unit", 111, 222, 333), 'ID:invalid_input:from'); + this.verifyWarning(@() pspm_convert_gaze_distance(this.fn, target, "pixel", 'not_a_number', 222, 333), 'ID:invalid_input:width'); + this.verifyWarning(@() pspm_convert_gaze_distance(this.fn, target, "pixel", 111, 'not_a_number', 333), 'ID:invalid_input:height'); + this.verifyWarning(@() pspm_convert_gaze_distance(this.fn, target, "pixel", 111, 222, 'not_a_number'), 'ID:invalid_input:distance'); + this.verifyWarning(@() pspm_convert_gaze_distance(this.fn, 'invalid_conversion', 'pixel', 111, 222, 333), 'ID:invalid_input:target'); + end + + + function conversion(this, target, from, channel_action) + load(this.fn); + width = 323; + height = 232; + distance = 600; + + if (~strcmp(from, 'pixel')); + pspm_convert_pixel2unit(this.fn, 0, from, width, height, distance); + load(this.fn); + this.verifyLength(this.get_gaze_and_unit(data, from), 4); + end; + + data_length = length(data); + if strcmp(target, 'degree') + this.verifyLength(this.get_gaze_and_unit(data, 'degree'), 0); + else + this.verifyLength(find(cellfun(@(c) strcmp(c.header.chantype, 'sps_l'), data)), 0); + this.verifyLength(find(cellfun(@(c) strcmp(c.header.chantype, 'sps_r'), data)), 0); + end + + [sts, out_channel] = this.verifyWarningFree(@() pspm_convert_gaze_distance(... + this.fn, target, from, width, height, distance, struct('channel_action', channel_action))); + load(this.fn); + this.verifyTrue(length(out_channel.channel) > 0); + + extra = 2; + if strcmp(target, 'degree') + extra = 4; + end + + this.verifyLength(data, data_length + extra); + data_length = length(data); + + if strcmp(target, 'degree') + this.verifyLength(this.get_gaze_and_unit(data, 'degree'), 4); + else + this.verifyLength(find(cellfun(@(c) strcmp(c.header.chantype, 'sps_l'), data)), 1); + this.verifyLength(find(cellfun(@(c) strcmp(c.header.chantype, 'sps_r'), data)), 1); + end + + [sts, out_channel] = this.verifyWarningFree(@() pspm_convert_gaze_distance(... + this.fn, target, from, width, height, distance, struct('channel_action', channel_action))); + load(this.fn); + + extra = 0; + if (strcmp(channel_action, 'add')); + if strcmp(target, 'degree') + extra = extra + 4; + else + extra = extra + 2; + end + end; + + this.verifyLength(data, data_length + extra); + this.verifyEqual(sts, 1); + + end + end + +end \ No newline at end of file diff --git a/test/pspm_extract_segments_test.m b/test/pspm_extract_segments_test.m index ad8feeb6c..4bc82c5f1 100644 --- a/test/pspm_extract_segments_test.m +++ b/test/pspm_extract_segments_test.m @@ -147,8 +147,8 @@ control_data{i}.cond_name = cond_names{i}; control_data{i}.mean = cond_trial_mean{i}; control_data{i}.std = cond_trial_std{i}; - control_data{i}.trial_nan_percent = cond_nan_trial{i}; - control_data{i}.total_nan_percent = cond_nan_total{i}; + control_data{i}.trial_nan_percent = cond_nan_trial{i}*100; + control_data{i}.total_nan_percent = cond_nan_total{i}*100; end end end @@ -217,7 +217,7 @@ function test_manual_length(this,nr_trial,nan_ratio) this.verifyEqual(seg.name,control.cond_name); this.verifyEqual(seg.mean,control.mean); this.verifyEqual(seg.std,control.std); - this.verifyEqual(seg.trial_nan_percent,control.trial_nan_percent); + this.verifyTrue(all(abs(seg.trial_nan_percent-control.trial_nan_percent)<1e-12)); this.verifyTrue(abs(seg.total_nan_percent-control.total_nan_percent)<1e-12); end end @@ -247,7 +247,7 @@ function test_manual_duration(this,nr_trial,nan_ratio) this.verifyEqual(seg.name,control.cond_name); this.verifyEqual(seg.mean,control.mean); this.verifyEqual(seg.std,control.std); - this.verifyEqual(seg.trial_nan_percent,control.trial_nan_percent); + this.verifyTrue(all(abs(seg.trial_nan_percent-control.trial_nan_percent)<1e-12)); this.verifyTrue(abs(seg.total_nan_percent-control.total_nan_percent)<1e-12); end end diff --git a/test/pspm_get_marker_test.m b/test/pspm_get_marker_test.m index b5943c767..b89989afe 100644 --- a/test/pspm_get_marker_test.m +++ b/test/pspm_get_marker_test.m @@ -3,10 +3,15 @@ % unittest class for the pspm_get_marker function %__________________________________________________________________________ % SCRalyze TestEnvironment -% (C) 2013 Linus Rüttimann (University of Zurich) +% (C) 2013 Linus Rüttimann (University of Zurich) + + properties (TestParameter) + flank = { 'descending', 'ascending' }; + sr = { 1, 2 }; + end methods (Test) - function test(this) + function timestamps(this) import.sr = 1; import.data = 1:10; import.marker = 'timestamps'; @@ -20,6 +25,26 @@ function test(this) this.verifyEqual(data.header.sr, 1); end + + function continuous(this, flank, sr) + import.sr = sr; + import.data = [ 42, 42, 84, 84, 84, 42, 42, 42, 84, 42 ]; + import.marker = 'continuous'; + import.flank = flank; + [sts, data] = pspm_get_marker(import); + + expected = struct(... + 'descending', [ 6; 10 ], ... + 'ascending', [ 3; 9 ]... + ); + + this.verifyEqual(sts, 1); + this.verifyEqual(data.data, expected.(flank) ./ sr); + this.verifyTrue(strcmpi(data.header.chantype, 'marker')); + this.verifyTrue(strcmpi(data.header.units, 'events')); + this.verifyEqual(data.header.sr, 1); + end + end end \ No newline at end of file diff --git a/test/pspm_get_sps_test.m b/test/pspm_get_sps_test.m new file mode 100644 index 000000000..c6bb26b17 --- /dev/null +++ b/test/pspm_get_sps_test.m @@ -0,0 +1,33 @@ +classdef pspm_get_sps_test < matlab.unittest.TestCase +% SCR_GET_SPIKE_TEST +% unittest class for the pspm_get_sps_test function +%__________________________________________________________________________ +% SCRalyze TestEnvironment +% (C) 2013 Linus R�ttimann (University of Zurich) + + methods (Test) + function invalid_eye(this) + + import.sr = 100; + import.data = ones(1,1000); + import.units = 'degree'; + import.range = [ 0, 1]; + + [ sts, out ] = this.verifyWarningFree(@() pspm_get_sps(import)); + this.verifyEqual(sts, 1); + this.verifyEqual(out.header.chantype, 'sps'); + + [ sts, out ] = this.verifyWarningFree(@() pspm_get_sps_l(import)); + this.verifyEqual(sts, 1); + this.verifyEqual(out.header.chantype, 'sps_l'); + + [ sts, out ] = this.verifyWarningFree(@() pspm_get_sps_r(import)); + this.verifyEqual(sts, 1); + this.verifyEqual(out.header.chantype, 'sps_r'); + + end + + end + +end + diff --git a/test/pspm_get_txt_test.m b/test/pspm_get_txt_test.m index 254f102ad..48491cb5a 100644 --- a/test/pspm_get_txt_test.m +++ b/test/pspm_get_txt_test.m @@ -3,7 +3,7 @@ % unittest class for the pspm_get_txt function %__________________________________________________________________________ % SCRalyze TestEnvironment -% (C) 2013 Linus Rüttimann (University of Zurich) +% (C) 2013 Linus R�ttimann (University of Zurich) properties testcases; @@ -16,11 +16,11 @@ function define_testcases(this) %-------------------------------------------------------------- this.testcases{1}.pth = 'testdatafile79887.txt'; - this.testcases{1}.import{1} = struct('type', 'scr' , 'channel', 1, 'sr', 100); - this.testcases{1}.import{2} = struct('type', 'scr' , 'channel', 2, 'sr', 100); - this.testcases{1}.import{3} = struct('type', 'hr' , 'channel', 5, 'sr', 100); - this.testcases{1}.import{4} = struct('type', 'resp' , 'channel', 6, 'sr', 100); - this.testcases{1}.import{5} = struct('type', 'scr' , 'channel', 7, 'sr', 100); + this.testcases{1}.import{1} = struct('type', 'scr' , 'channel', 1, 'sr', 100, 'header_lines', 0); + this.testcases{1}.import{2} = struct('type', 'scr' , 'channel', 2, 'sr', 100, 'header_lines', 0); + this.testcases{1}.import{3} = struct('type', 'hr' , 'channel', 5, 'sr', 100, 'header_lines', 0); + this.testcases{1}.import{4} = struct('type', 'resp' , 'channel', 6, 'sr', 100, 'header_lines', 0); + this.testcases{1}.import{5} = struct('type', 'scr' , 'channel', 7, 'sr', 100, 'header_lines', 0); %generate testdata data = rand(900, 8); @@ -45,6 +45,46 @@ function define_testcases(this) fprintf(fid,'%f\t%f\t%f\t%f\n', data(k,1), data(k,2), data(k,3), data(k,4)); end fclose(fid); + + %testcase 3 (csv with header) + %-------------------------------------------------------------- + this.testcases{3}.pth = 'testdatafile132435.csv'; + + this.testcases{3}.import{1} = struct('type', 'scr' , 'channel', 0, 'sr', 100, 'delimiter', ','); + this.testcases{3}.import{2} = struct('type', 'ecg' , 'channel', 0, 'sr', 100, 'delimiter', ','); + this.testcases{3}.import{3} = struct('type', 'hr' , 'channel', 0, 'sr', 100, 'delimiter', ','); + this.testcases{3}.import{4} = struct('type', 'resp' , 'channel', 0, 'sr', 100, 'delimiter', ','); + + %generate testdata + header = {'scr' 'ecg' 'heart' 'resp'}; + data = rand(900, 4); + + fid = fopen(this.testcases{3}.pth, 'w'); + fprintf(fid,'scr,ecg,rate,resp\n'); + for k=1:size(data,1) + fprintf(fid,'%f,%f,%f,%f\n', data(k,1), data(k,2), data(k,3), data(k,4)); + end + fclose(fid); + + %testcase 4 (delimiter separated value with custom delimiter (|)) + %-------------------------------------------------------------- + this.testcases{4}.pth = 'testdatafile132435.psv'; + + this.testcases{4}.import{1} = struct('type', 'scr' , 'channel', 0, 'sr', 100, 'delimiter', '|'); + this.testcases{4}.import{2} = struct('type', 'ecg' , 'channel', 0, 'sr', 100, 'delimiter', '|'); + this.testcases{4}.import{3} = struct('type', 'hr' , 'channel', 0, 'sr', 100, 'delimiter', '|'); + this.testcases{4}.import{4} = struct('type', 'resp' , 'channel', 0, 'sr', 100, 'delimiter', '|'); + + %generate testdata + header = {'scr' 'ecg' 'heart' 'resp'}; + data = rand(900, 4); + + fid = fopen(this.testcases{4}.pth, 'w'); + fprintf(fid,'scr|ecg|rate|resp\n'); + for k=1:size(data,1) + fprintf(fid,'%f|%f|%f|%f\n', data(k,1), data(k,2), data(k,3), data(k,4)); + end + fclose(fid); end end @@ -62,13 +102,40 @@ function del_testdata_files(this) function invalid_datafile(this) fn = 'testdatafile79887.txt'; - import{1} = struct('type', 'scr' , 'channel', 1); - import{2} = struct('type', 'scr' , 'channel', 2); - import{3} = struct('type', 'scr' , 'channel',15); + % Test wrong delimiter + import{1} = struct('type', 'scr' , 'channel', 1, 'delimiter', 24); + import = this.assign_chantype_number(import); + this.verifyWarning(@()pspm_get_txt(fn, import), 'ID:invalid_input'); + % Test wrong header_lines + import{1} = struct('type', 'scr' , 'channel', 1, 'header_lines', 'A'); import = this.assign_chantype_number(import); + this.verifyWarning(@()pspm_get_txt(fn, import), 'ID:invalid_input'); + % Test wrong channel_names_line + import{1} = struct('type', 'scr' , 'channel', 1, 'channel_names_line', 'A'); + import = this.assign_chantype_number(import); + this.verifyWarning(@()pspm_get_txt(fn, import), 'ID:invalid_input'); + + % Test wrong exclude_columns + import{1} = struct('type', 'scr' , 'channel', 1, 'exclude_columns', 'A'); + import = this.assign_chantype_number(import); + this.verifyWarning(@()pspm_get_txt(fn, import), 'ID:invalid_input'); + + % Test channel number larger than number of columns + import{1} = struct('type', 'scr' , 'channel', 1); + import{2} = struct('type', 'scr' , 'channel', 2); + import{3} = struct('type', 'scr' , 'channel',35); + import = this.assign_chantype_number(import); this.verifyWarning(@()pspm_get_txt(fn, import), 'ID:channel_not_contained_in_file'); + + % Test "no indication what to select" + import{1} = struct('type', 'scr' , 'channel', 0, 'channel_names_line', 0); + import{2} = struct('type', 'scr' , 'channel', 0, 'channel_names_line', 0); + import{3} = struct('type', 'scr' , 'channel', 0, 'channel_names_line', 0); + import = this.assign_chantype_number(import); + this.verifyWarning(@()pspm_get_txt(fn, import), 'ID:invalid_input'); + end end diff --git a/test/pspm_glm_test.m b/test/pspm_glm_test.m index 7a8aea7e1..582ff3300 100644 --- a/test/pspm_glm_test.m +++ b/test/pspm_glm_test.m @@ -650,10 +650,10 @@ function invalid_input(this) nr_nan_toadd = round(nan_percent * nr_samples); idx_replace = randsample(nr_samples, nr_nan_toadd); Y(idx_replace) = NaN; - new_nan_percent = sum(isnan(Y))/nr_samples; + new_nan_percent = sum(isnan(Y))/nr_samples * 100; else - new_nan_percent = nan_percent; + new_nan_percent = nan_percent * 100; end %t @@ -666,7 +666,7 @@ function invalid_input(this) this.verifyEqual(length(glm.stats_missing),exptected_number_of_conditions, sprintf('test_extract_missing: glm.stats_missing does not have the expected number (%i) of elements', exptected_number_of_conditions)); this.verifyEqual(length(glm.stats_exclude),exptected_number_of_conditions, sprintf('test_extract_missing: glm.stats_exclude does not have the expected number (%i) of elements', exptected_number_of_conditions)); - this.verifyTrue((abs(mean(glm.stats_missing)-new_nan_percent)<0.01), sprintf('test_extract_missing: mean of glm.stats_missing (%i) does not correspond to expected nan_percentage (%i)', mean(glm.stats_missing), new_nan_percent)); + this.verifyTrue((abs(mean(glm.stats_missing)-new_nan_percent) < 1), sprintf('test_extract_missing: mean of glm.stats_missing (%i) does not correspond to expected nan_percentage (%i)', mean(glm.stats_missing), new_nan_percent)); check_values = glm.stats_missing > cutoff; this.verifyTrue(all(glm.stats_exclude == check_values), sprintf('test_extract_missing: glm.stats_exclude does not exclude the right conditions')); diff --git a/test/pspm_pp_test.m b/test/pspm_pp_test.m index 9e7e85d53..407efd6ef 100644 --- a/test/pspm_pp_test.m +++ b/test/pspm_pp_test.m @@ -3,7 +3,7 @@ % unittest class for the pspm_pp function %__________________________________________________________________________ % SCRalyze TestEnvironment -% (C) 2013 Linus Rüttimann (University of Zurich) +% (C) 2013 Linus R�ttimann (University of Zurich) properties end @@ -23,6 +23,7 @@ function invalid_input(this) % perform the other tests with invalid input data this.verifyWarning(@()pspm_pp('foo', fn, 100), 'ID:invalid_input'); this.verifyWarning(@()pspm_pp('butter', fn, 19), 'ID:invalid_freq'); + this.verifyWarning(@()pspm_pp('simple_qa', fn, struct('missing_epochs_filename', 1)), 'ID:invalid_input'); end function median_test(this) @@ -90,6 +91,50 @@ function butter_test(this) %delete testdata delete(fn); end + + + function simple_qa_test(this) + %generate testdata + channels{1}.chantype = 'scr'; + + fn = 'missing_epochs_test_generated_data.mat'; + pspm_testdata_gen(channels, 10, fn); + + %filter one channel + missing_epoch_filename = 'missing_epochs_test_out'; + qa = struct('missing_epochs_filename', missing_epoch_filename, ... + 'deflection_threshold', 0, ... + 'expand_epochs', 0 ); + newfile = pspm_pp('simple_qa', fn, qa); + + [sts, infos, data, filestruct] = pspm_load_data(newfile, 'none'); + + this.verifyTrue(sts == 1, 'the returned file couldn''t be loaded'); + this.verifyTrue(filestruct.numofchan == numel(channels), 'the returned file contains not as many channels as the inputfile'); + + delete(newfile); + + out = load(missing_epoch_filename); + this.verifySize(out.epochs, [ 10, 2 ], 'the written epochs are not of the correct size') + delete(string(missing_epoch_filename) + ".mat"); + + + %no missing epochs filename option + newfile = pspm_pp('simple_qa', fn); + + [sts, infos, data, filestruct] = pspm_load_data(newfile, 'none'); + + this.verifyTrue(sts == 1, 'the returned file couldn''t be loaded'); + this.verifyTrue(filestruct.numofchan == numel(channels), 'the returned file contains not as many channels as the inputfile'); + + delete(newfile); + % test no file exists when not provided + this.verifyError(@()load('missing_epochs_test_out'), 'MATLAB:load:couldNotReadFile'); + + %delete testdata + delete(fn); + end + function overwrite_test(this) % generate test data diff --git a/test/pspm_test.m b/test/pspm_test.m index abbe67869..0de2cc015 100644 --- a/test/pspm_test.m +++ b/test/pspm_test.m @@ -23,7 +23,8 @@ function pspm_test(varargin) % build suits % ------------------------------------------------------------------------- - suite = [ TestSuite.fromClass(?pspm_load_data_test), ... + suite = [... + TestSuite.fromClass(?pspm_load_data_test), ... TestSuite.fromClass(?pspm_write_channel_test), ... TestSuite.fromClass(?pspm_trim_test), ... TestSuite.fromClass(?pspm_find_channel_test), ... @@ -51,10 +52,14 @@ function pspm_test(varargin) TestSuite.fromClass(?pspm_pupil_correct_eyelink_test), ... TestSuite.fromClass(?pspm_pupil_correct_test), ... TestSuite.fromClass(?pspm_pupil_pp_test), ... - TestSuite.fromClass(?set_blinks_saccades_to_nan_test)]; + TestSuite.fromClass(?set_blinks_saccades_to_nan_test), ... + TestSuite.fromClass(?pspm_blink_saccade_filt_test), ... + TestSuite.fromClass(?pspm_convert_gaze_distance_test), ... + ]; - import_suite = [ TestSuite.fromClass(?pspm_get_acq_test), ... + import_suite = [... + TestSuite.fromClass(?pspm_get_acq_test), ... TestSuite.fromClass(?pspm_get_acqmat_test), ... TestSuite.fromClass(?pspm_get_acq_bioread_test), ... TestSuite.fromClass(?pspm_get_biograph_test), ... @@ -76,16 +81,20 @@ function pspm_test(varargin) TestSuite.fromClass(?pspm_get_smi_test), ... TestSuite.fromClass(?import_eyelink_test), ... TestSuite.fromClass(?import_viewpoint_test), ... - TestSuite.fromClass(?import_smi_test)]; + TestSuite.fromClass(?import_smi_test), ... + ]; - chantype_suite = [ TestSuite.fromClass(?pspm_get_ecg_test), ... + chantype_suite = [... + TestSuite.fromClass(?pspm_get_ecg_test), ... TestSuite.fromClass(?pspm_get_events_test), ... TestSuite.fromClass(?pspm_get_hb_test), ... TestSuite.fromClass(?pspm_get_hr_test), ... TestSuite.fromClass(?pspm_get_marker_test), ... TestSuite.fromClass(?pspm_get_pupil_test), ... TestSuite.fromClass(?pspm_get_resp_test), ... - TestSuite.fromClass(?pspm_get_scr_test)]; + TestSuite.fromClass(?pspm_get_scr_test), ... + TestSuite.fromClass(?pspm_get_sps_test), ... + ]; full_suite = [suite, import_suite, chantype_suite]; diff --git a/test/pspm_testdata_gen.m b/test/pspm_testdata_gen.m index e392f6b15..48ff00235 100755 --- a/test/pspm_testdata_gen.m +++ b/test/pspm_testdata_gen.m @@ -15,6 +15,7 @@ % - .chantype: 'scr', 'hr', 'hb', 'resp', 'trigger', 'scanner' % and optional fields: % if .chantype is 'scr' or 'hr' (continuous channels): +% - .units: units to write to channel, defaults to 'unknown' for continuous and 'events' for event data % - .sr: sampling rate for waveform (default value is 100Hz) % - .freq: frequencey of the waveform (default value is 1Hz) % - .noise: (default: 0) if 1 will add random normally @@ -90,6 +91,9 @@ cont_channels{3} = 'resp'; cont_channels{4} = 'snd'; +% regex expression for scr OR hr OR resp OR snd OR gaze with x/y and r/l +cont_channels_regex = '^(scr|hr|resp|snd|gaze_[x|y]_[r|l])$'; + % Eventbased Channels event_channels{1} = 'hb'; event_channels{2} = 'rs'; @@ -112,7 +116,7 @@ if ~isfield(channels{k}, 'chantype') warning('No type given for channels job %2.0f', k); outfile = cell(0); return; - elseif any(strcmp(channels{k}.chantype, cont_channels)) + elseif regexp(channels{k}.chantype, cont_channels_regex) %default values if ~isfield(channels{k}, 'freq') channels{k}.freq = 1; @@ -121,7 +125,13 @@ channels{k}.noise = 0; end outfile.data{k,1}.header = channels{k}; - outfile.data{k,1}.header.units = 'unknown'; + + if isfield(channels{k}, 'units') + outfile.data{k,1}.header.units = channels{k}.units; + else + outfile.data{k,1}.header.units = 'unknown'; + end + outfile.data{k,1}.header.chantype = channels{k}.chantype; %Generate sinewaveform @@ -188,7 +198,13 @@ end outfile.data{k,1}.header = channels{k}; - outfile.data{k,1}.header.units = 'events'; + + if isfield(channels{k}, 'units') + outfile.data{k,1}.header.units = channels{k}.units; + else + outfile.data{k,1}.header.units = 'events'; + end + outfile.data{k,1}.header.chantype = channels{k}.chantype; outfile.data{k,1}.data = ev_data; diff --git a/test/pspm_trim_test.m b/test/pspm_trim_test.m index 215e78603..bc13346e5 100755 --- a/test/pspm_trim_test.m +++ b/test/pspm_trim_test.m @@ -237,7 +237,7 @@ function trimtest(testCase, datafile, reference, testnum, markerchan) nfrom = exp_val.data{filestruct.posofmarker}.data(1)+from; nto = exp_val.data{filestruct.posofmarker}.data(end)+to; - startpoint = floor(testCase.sr * nfrom); + startpoint = ceil(testCase.sr * nfrom)+1; endpoint = floor(testCase.sr * nto); for k=1:length(testCase.cont_channels) @@ -288,7 +288,7 @@ function trimtest(testCase, datafile, reference, testnum, markerchan) from = 2.1; to = exp_val.infos.duration - 2.5; - startpoint = floor(testCase.sr * from); + startpoint = ceil(testCase.sr * from)+1; endpoint = floor(testCase.sr * to); for k=1:length(testCase.cont_channels) @@ -345,7 +345,7 @@ function trimtest(testCase, datafile, reference, testnum, markerchan) nfrom = exp_val.data{filestruct.posofmarker}.data(num(1))+from; nto = exp_val.data{filestruct.posofmarker}.data(num(2))+to; - startpoint = floor(testCase.sr * nfrom); + startpoint = ceil(testCase.sr * nfrom)+1; endpoint = floor(testCase.sr * nto); for k=1:length(testCase.cont_channels) diff --git a/test/pspm_write_channel_test.m b/test/pspm_write_channel_test.m index b018579ec..cb45808c8 100644 --- a/test/pspm_write_channel_test.m +++ b/test/pspm_write_channel_test.m @@ -26,6 +26,8 @@ function generate_testdatafile(this) c{1}.chantype = 'scr'; c{2}.chantype = 'marker'; c{3}.chantype = 'scr'; + c{4}.chantype = 'gaze_x_l'; + c{4}.units = 'mm'; data_settings.channels = c; data_settings.sr = 100; data_settings.duration = 500; @@ -208,6 +210,39 @@ function test_replace(this) this.verifyWarningFree(@() this.verify_write(new, old, gen_data, 'replace', outinfos)); end; + function test_replace_units(this) + % change channel setting and regenerate data + c{1}.chantype = 'gaze_x_l'; + c{1}.units = 'mm'; + c{1}.sr = 20; + gen_data = pspm_testdata_gen(c, 500); + + % load file before + [~, old.infos, old.data] = pspm_load_data(this.testdatafile); + + % channel should exist -> should actually replace + [~, outinfos] = this.verifyWarningFree(@() pspm_write_channel(this.testdatafile, gen_data.data{1}, 'replace')); + + % load changed data + [~, new.infos, new.data] = pspm_load_data(this.testdatafile); + + % check if has been replaced + this.verifyWarningFree(@() this.verify_write(new, old, gen_data, 'replace', outinfos)); + + % change units + gen_data.data{1}.header.units = 'degree'; + [~, outinfos] = this.verifyWarningFree(@() pspm_write_channel(this.testdatafile, gen_data.data{1}, 'replace')); + [~, post_unit_change.infos, post_unit_change.data] = pspm_load_data(this.testdatafile); + + % should be one more channel as degrees did not exist + this.verifyEqual(length(post_unit_change.data), length(new.data) + 1); + + % assert one mm gaze channel and one degree gaze channel + this.verifyEqual(length(find(cellfun(@(c) strcmp(c.header.units, 'mm') && strcmp(c.header.chantype, 'gaze_x_l'), post_unit_change.data))), 1); + this.verifyEqual(length(find(cellfun(@(c) strcmp(c.header.units, 'degree') && strcmp(c.header.chantype, 'gaze_x_l'), post_unit_change.data))), 1); + + end; + function test_delete_single(this) %% Delete with chantype diff --git a/test/set_blinks_saccades_to_nan_test.m b/test/set_blinks_saccades_to_nan_test.m index 721fdeb1a..07e79b342 100644 --- a/test/set_blinks_saccades_to_nan_test.m +++ b/test/set_blinks_saccades_to_nan_test.m @@ -22,7 +22,7 @@ function add(this) function test_only_left_eye(this) column_names = {'blink_l', 'saccade_l', 'pupil_l', 'gaze_x_l', 'gaze_y_l'}; mask_chans = {'blink_l', 'saccade_l'}; - fn_is_left = @(channame) endsWith(channame, 'l'); + fn_is_left = @(channame) strcmp(channame(end), 'l'); data = [false(100, 2), randn(100, 3)]; for i = 1:4 @@ -42,7 +42,7 @@ function test_only_left_eye(this) function test_only_right_eye(this) column_names = {'pupil_r', 'gaze_x_r', 'gaze_y_r', 'blink_r', 'saccade_r'}; mask_chans = {'blink_r', 'saccade_r'}; - fn_is_left = @(channame) endsWith(channame, 'l'); + fn_is_left = @(channame) strcmp(channame(end), 'l'); data = [randn(100, 3), false(100, 2)]; for i = 1:4 @@ -63,7 +63,7 @@ function test_both_eyes(this) column_names = {'Pupil L', 'Pupil R', 'Gaze X L', 'Gaze X R', 'Gaze Y L',... 'Gaze Y R', 'Blink L', 'Blink R', 'Saccade L', 'Saccade R'}; mask_chans = {'Blink L', 'Blink R', 'Saccade L', 'Saccade R'}; - fn_is_left = @(channame) endsWith(channame, ' l'); + fn_is_left = @(channame) strcmp(channame(end-1:end), ' l'); data = [randn(100, 6), false(100, 4)]; for i = 1:4