diff --git a/+dat/constructExpRef.m b/+dat/constructExpRef.m index 7fb19aef..f5346f00 100644 --- a/+dat/constructExpRef.m +++ b/+dat/constructExpRef.m @@ -1,6 +1,6 @@ function ref = constructExpRef(subjectRef, expDate, expSequence) %DAT.CONSTRUCTEXPREF Constructs an experiment reference string -% ref = DAT.CONSTRUCTEXPREF(subject, dat, seq) constructs and returns a +% ref = DAT.CONSTRUCTEXPREF(subject, date, seq) constructs and returns a % standard format string reference, for the experiment using the 'subject', % the 'date' of the experiment (a MATLAB datenum), and the daily sequence % number of the experiment, 'seq' (must be an integer). diff --git a/+dat/expFilePath.m b/+dat/expFilePath.m index 6679fcfd..0470a0a1 100644 --- a/+dat/expFilePath.m +++ b/+dat/expFilePath.m @@ -20,11 +20,19 @@ % 2013-03 CB created -assert(length(varargin) > 1, 'Error: Not enough arguments supplied.') +assert(length(varargin) > 1, 'Rigbox:dat:expFilePath:NotEnoughInputs',... + 'Not enough input arguments.') parsed = catStructs(regexp(varargin{1}, dat.expRefRegExp, 'names')); if isempty(parsed) % Subject, not ref - if nargin > 4 + if nargin < 3 + error('Rigbox:dat:expFilePath:NotEnoughInputs', ... + ['Not enough input arguments; check expRef formatted correcly ' ... + 'or enter subject, date and sequence as separate arguments']) + elseif nargin == 3 + error('Rigbox:dat:expFilePath:NotEnoughInputs', ... + 'Not enough input arguments; missing file type') + elseif nargin > 4 location = varargin{5}; varargin(5) = []; else @@ -32,13 +40,16 @@ end typeIdx = 4; else % Ref, not subject - typeIdx = 2; - if nargin > 2 + if nargin < 2 + error('Rigbox:dat:expFilePath:NotEnoughInputs', ... + 'Not enough input arguments; missing file type') + elseif nargin > 2 location = varargin{3}; varargin(3) = []; else location = {}; end + typeIdx = 2; end % tabulate the args to get complete rows diff --git a/+dat/expPath.m b/+dat/expPath.m index abcc8294..ff1edb07 100644 --- a/+dat/expPath.m +++ b/+dat/expPath.m @@ -23,6 +23,9 @@ reposArgs = varargin(end); varargin = varargin(1:end - 1); else + % Check for minimum inputs + assert(nargin > 2, ... + 'Rigbox:dat:expPath:NotEnoughInputs', 'Must provide repo location') reposArgs = varargin((end - 1):end); varargin = varargin(1:end - 2); end diff --git a/+dat/loadParamProfiles.m b/+dat/loadParamProfiles.m index b63a70cb..17327dc6 100644 --- a/+dat/loadParamProfiles.m +++ b/+dat/loadParamProfiles.m @@ -1,6 +1,24 @@ function p = loadParamProfiles(expType) %DAT.LOADPARAMPROFILES Loads the parameter sets for given experiment type -% TODO +% Loads a struct of parameter structures from a MAT file called +% 'parameterProfiles'. Each field of this struct is a parameter set name +% for a given expType. Parameters of a given expType can be saved using +% the DAT.SAVEPARAMPROFILE function. +% +% Input: +% expType (char): The name of the experiment type, e.g. ChoiceWorld. +% +% Output: +% p (struct): a scalar struct of parameter sets for the given +% experiment type. Each fieldname holds a different parameter +% structure. The fields are sorted in ASCII dictionary order. +% +% Example: +% dat.saveParamProfile('ChoiceWorld', 'defSet', exp.choiceWorldParams) +% profiles = dat.loadParamProfiles('ChoiceWorld'); +% p = exp.Parameters(profiles.defSet); +% +% See also DAT.SAVEPARAMPROFILE, DAT.PATHS % % Part of Rigbox diff --git a/+dat/reposPath.m b/+dat/reposPath.m index 62fe7802..13890103 100644 --- a/+dat/reposPath.m +++ b/+dat/reposPath.m @@ -4,21 +4,26 @@ % repository specified by 'name'. % % Each repository can have multiple locations with one location being the -% "master" copy and others considered backups (e.g. copies on local -% machines). Users of this function wanting to *save* data should do so -% in all locations. To *load* data, the master may be the only location -% containing all data (i.e. because local copies will only be on specific -% machines). The optional 'location' parameter specifies one or more -% locations, with "all" being the default that returns all locations for -% that repository, and "master" will return the path to the master -% location. +% 'master' copy and others considered backups or archives (e.g. copies on +% local machines). Users of this function wanting to *save* data should +% do so in all locations (i.e. master and local). To *load* data, the +% remote locations (i.e. master and archives) should be used (i.e. +% because local copies will only be on specific machines). The optional +% 'location' parameter specifies one or more locations, with 'all' being +% the default that returns the master and local locations for that +% repository, 'master' will return the path to the master location, and +% 'remote' will return the master and archive/alternate paths (in that +% order). % -% e.g. to get all paths you should save to for the "main" repository: -% savePaths = DAT.REPOSPATH('main') % savePaths is a string cell array +% e.g. to get all paths you should save to for the 'main' repository: +% savePaths = DAT.REPOSPATH('main') % savePaths is a cell string % -% To get the master location for the "main" repository: +% To get the master location for the 'main' repository: % loadPath = DAT.REPOSPATH('main', 'master') % loadPath is a string % +% When data are spread across multiple remote locations such as archives: +% loadPath = DAT.REPOSPATH('main', 'remote') % loadPath is a cell string +% % Part of Rigbox % 2013-03 CB created diff --git a/+dat/saveParamProfile.m b/+dat/saveParamProfile.m index 8a08f3bb..c97db3bd 100644 --- a/+dat/saveParamProfile.m +++ b/+dat/saveParamProfile.m @@ -1,38 +1,49 @@ function saveParamProfile(expType, profileName, params) %DAT.SAVEPARAMPROFILE Stores the named parameters for experiment type -% TODO -% - Figure out how to save struct without for-loop in 2016b! +% Saves a parameter structure in a MAT file called 'parameterProfiles'. +% Each field of this struct is an expType, and each nested field +% is the set name. Parameters of a given expType can be loaded using the +% DAT.LOADPARAMPROFILES function. +% +% Inputs: +% expType (char): The name of the experiment type, e.g. ChoiceWorld. +% profileName (char): The name of the parameter set being saved. If +% the name already exists in the file for a given expType, it is +% overwritten. +% params (struct): A parameter structure to be saved. +% +% Example: +% dat.saveParamProfile('ChoiceWorld', 'defSet', exp.choiceWorldParams) +% profiles = dat.loadParamProfiles('ChoiceWorld'); +% p = exp.Parameters(profiles.defSet); +% +% See also DAT.LOADPARAMPROFILES, DAT.PATHS +% % Part of Rigbox % 2013-07 CB created % 2017-02 MW adapted to work in 2016b -%path to repositories +% If main repo folders don't exist yet, create them +repos = dat.reposPath('main'); +cellfun(@mkdir, repos(~file.exists(repos))) + +% Path to repository files fn = 'parameterProfiles.mat'; -repos = fullfile(dat.reposPath('main'), fn); +repos = fullfile(repos, fn); -%load existing profiles for specified expType +% Load existing profiles for specified expType profiles = dat.loadParamProfiles(expType); -%add (or replace) the params with a field named by profile +% Add (or replace) the params with a field named by profile profiles.(profileName) = params; -%wrap in a struct for saving +% Wrap in a struct for saving set = struct; set.(expType) = profiles; -%save the updated set of profiles to each repos -%where files exist already, append -% cellfun(@(p) save(p, '-struct', 'set', '-append'), -% file.filterExists(repos, true)); % Had to change her to a for loop, sorry -% Chris! -p = file.filterExists(repos, true); -for i = 1:length(p) - save(p{i}, '-struct', 'set', '-append') -end -%and any that don't we should create from scratch -p = file.filterExists(repos, false); -for i = 1:length(p) - save(p{i}, '-struct', 'set') -end -% cellfun(@(p) save(p, '-struct', 'set'), file.filterExists(repos, false)); +% Save the updated set of profiles to each repos where files exist already, +% append +saveFn = @(p,name,varargin) save(p, '-struct', 'name', varargin{:}); +cellfun(@(p) saveFn(p, set, '-append') , file.filterExists(repos, true)); -end \ No newline at end of file +% Any that don't we should create from scratch +cellfun(@(p) saveFn(p, set), file.filterExists(repos, false)); \ No newline at end of file diff --git a/+dat/updateLogEntry.m b/+dat/updateLogEntry.m index 47678185..a5128bd7 100644 --- a/+dat/updateLogEntry.m +++ b/+dat/updateLogEntry.m @@ -10,7 +10,7 @@ function updateLogEntry(subject, id, newEntry) % 2013-03 CB created -if isfield(newEntry, 'AlyxInstance') +if isfield(newEntry, 'AlyxInstance') && ~isempty(getOr(dat.paths, 'databaseURL')) % Update session narrative on Alyx if ~isempty(newEntry.comments) && ~strcmp(subject, 'default') try diff --git a/+eui/ChoiceExpPanel.m b/+eui/ChoiceExpPanel.m index 8d112b74..e9991af2 100644 --- a/+eui/ChoiceExpPanel.m +++ b/+eui/ChoiceExpPanel.m @@ -95,7 +95,7 @@ function refresh(obj) end end - methods %(Access = protected) + methods (Access = protected) function newTrial(obj, num, condition) %attempt num is red when on higher than third attemptColour = iff(condition.repeatNum > 3, [1 0 0], [0 0 0]); diff --git a/+eui/ConditionPanel.m b/+eui/ConditionPanel.m index ff9ab9b8..277211f1 100644 --- a/+eui/ConditionPanel.m +++ b/+eui/ConditionPanel.m @@ -105,7 +105,7 @@ function onEdit(obj, src, eventData) % See also FILLCONDITIONTABLE, EUI.PARAMEDITOR/UPDATE row = eventData.Indices(1); col = eventData.Indices(2); - assert(all(cellfun(@strcmpi, strrep(obj.ConditionTable.ColumnName, ' ', ''), ... + assert(all(cellfun(@strcmpi, erase(obj.ConditionTable.ColumnName, ' '), ... obj.ParamEditor.Parameters.TrialSpecificNames)), 'Unexpected condition names') paramName = obj.ParamEditor.Parameters.TrialSpecificNames{col}; newValue = obj.ParamEditor.update(paramName, eventData.NewData, row); diff --git a/+eui/Contents.m b/+eui/Contents.m new file mode 100644 index 00000000..4a85719c --- /dev/null +++ b/+eui/Contents.m @@ -0,0 +1,32 @@ +% +EUI Experiment UI package +% +% This +eui package contains all code pertaining to graphical user +% interfaces in Rigbox. There are five base classes in this folder: +% +% 1. MControl - The class behind the Master Control (MC) GUI. +% 2. ExpPanel - The superclass for UI panels that process and plot remote +% experiment event updates (i.e. the panels under the Current +% Experiments tab of MC). +% 3. Log - UI control for viewing experiment log entries (the table under +% the Log tab of MC). +% 4. AlyxPanel - UI for interacting with the Alyx database (the Alyx panel +% in the New Experiments tab of MC). Can be run as a stand-alone GUI. +% 5. ParamEditor - UI for viewing and editing parameters (the Parameter +% panel in the New Experiments table of MC). Can be run as a +% stand-alone GUI. +% +% Files +% AlyxPanel - A GUI for interating with the Alyx database +% ChoiceExpPanel - UI control for monitoring a 2AFC experiment +% ConditionPanel - Deals with formatting trial conditions UI table +% ExpPanel - Basic UI control for monitoring an experiment +% FieldPanel - Deals with formatting global parameter UI elements +% Log - UI control for viewing experiment log entries +% MappingExpPanel - Preliminary UI for monitoring a mapping experiment +% MControl - GUI for the control of experiments +% ParamEditor - GUI for visualizing and editing experiment parameters +% SignalsTest - A GUI for testing SignalsExp experiment definitions +% SqueakExpPanel - Basic UI control for monitoring a Signals experiment +% +% See Also +% docs/html/using_mc.m, docs/html/using_ParamEditor.m \ No newline at end of file diff --git a/+eui/ExpPanel.m b/+eui/ExpPanel.m index e38b93f8..40dc9086 100644 --- a/+eui/ExpPanel.m +++ b/+eui/ExpPanel.m @@ -9,10 +9,10 @@ % EXPPANEL is not stand-alone and thus requires a handle to a parent % window. This class has a number of subclasses, one for each % experiment type, for example CHOICEEXPPANEL for ChoiceWorld and - % SQUEAKEXPPANEL for Signals experiments. + % SIGNALSEXPPANEL for Signals experiments. % % - % See also SQUEAKEXPPANEL, CHOICEEXPPANEL, MCONTROL, MC + % See also SIGNALSEXPPANEL, CHOICEEXPPANEL, MCONTROL, MC % % Part of Rigbox @@ -20,44 +20,117 @@ % 2017-05 MW added Alyx compatibility properties - Block = struct('numCompletedTrials', 0, 'trial', struct([])) % A structure to hold update information relevant for the plotting of psychometics and performance calculations - %log entry pertaining to this experiment + % A structure to hold update information relevant for the plotting of + % psychometics and performance calculations. This is updated as new + % ExpUpdate events occur. See also mergeTrialData + Block = struct('numCompletedTrials', 0, 'trial', struct([])) + % Log entry pertaining to this experiment LogEntry + % An array of listener handles for the remote rig, added by the live + % static constructor method Listeners end properties (Access = protected) - ExpRunning = false % A flag indicating whether the experiment is still running + % A flag indicating whether the experiment is still running + ExpRunning = false + % A list of active experiment phases ActivePhases = {} + % The root BoxPanel container Root - Ref % The experimental reference (expRef). - SubjectRef % A string representing the subject's name - InfoGrid % Handle to the UIX.GRID UI object that holds contains the InfoFields and InfoLabels - InfoLabels %label text controls for each info field - InfoFields %field controls for each info field - StatusLabel % A text field displaying the status of the experiment, i.e. the current phase of the experiment - TrialCountLabel % A counter displaying the current trial number + % The experimental reference string (expRef) + Ref + % A string representing the subject's name + SubjectRef + % Handle to the UIX.GRID UI object that holds contains the InfoFields + % and InfoLabels + InfoGrid + % Label text controls for each info field + InfoLabels + % Field UI controls for each info field + InfoFields + % A text field displaying the status of the experiment, i.e. the + % current phase of the experiment + StatusLabel + % A counter displaying the current trial number + TrialCountLabel + % A condition index counter. Only used if the parameters contains a + % conditionId parameter ConditionLabel + % A counter for the experiment duration DurationLabel - StopButtons % Handles to the End and Abort buttons, used to terminate an experiment through the UI + % Handles to the End and Abort buttons, used to terminate an experiment + % through the UI + StopButtons + % The datetime when the ExpPanel was instantiated StartedDateTime -% ElapsedTimer - CloseButton % The little x at the top right of the panel. Deletes the object. - CommentsBox % A handle to text box. Text inputed to this box is saved in the subject's LogEntry - CustomPanel % Handle to a UI box where any number of platting axes my be placed by subclasses - MainVBox % Handle to the main box containing all labels, buttons and UI boxes for this panel - Parameters % A structure of experimental parameters used by this experiment + % The little x at the top right of the panel. Deletes the object + CloseButton + % A handle to text box. Text inputed to this box is saved in the + % subject's LogEntry + CommentsBox + % Handle to a UI box where any number of platting axes my be placed by + % subclasses + CustomPanel + % Handle to the main box containing all labels, buttons and UI boxes + % for this panel + MainVBox + % A structure of experimental parameters used by this experiment + Parameters exp.Parameters + % Holds a context menu for show/hide options for info fields + UIContextMenu end methods (Static) - function p = live(parent, ref, remoteRig, paramsStruct) - subject = dat.parseExpRef(ref); % Extract subject, date and seq from experiment ref - try - logEntry = dat.addLogEntry(... % Add new entry to log - subject, now, 'experiment-info', struct('ref', ref), '', remoteRig.AlyxInstance); - catch ex - logEntry.comments = ''; - warning(ex.getReport()); + function p = live(parent, ref, remoteRig, paramsStruct, varargin) + % LIVE Constuct a new ExpPanel based on experiment parameter provided + % Create a new ExpPanel for monitoring an experiment. Depending on + % the `type` and, in the case of a Signals Experiment, `expPanelFun` + % parameters, a different subclass may be invoked. + % + % Inputs: + % parent : the parent figure or container for the panel. + % ref (char) : an experiment reference. + % remoteRig (srv.StimulusControl) : the remote rig communicator + % object for receiving experiment events. + % paramsStruct (struct) : the experiment parameters structure. + % The type parameter is used to determine which subclass is to + % be instantiated. For type 'custom' the default panel may be + % overridden via the `expPanelFun` parameter. + % + % Optional Name-Value pairs: + % ActivateLog (logical) : flag indicating whether to save a new + % log entry for the experiment (default true). For test + % experiments this flag may be set to false. + % StartedTime (double) : If the experiment has already started, + % the datetime of the experiment start (default []). + % + % Outputs: + % p (eui.ExpPanel) : handle to the panel object. + % + in = inputParser; + addRequired(in, 'parent'); + addRequired(in, 'ref'); + addRequired(in, 'remoteRig'); + addRequired(in, 'paramsStruct'); + % Activate log + addOptional(in, 'activateLog', true); + % Resume experiment listening (experiment had alread started) + addOptional(in, 'startedTime', []); + in.parse(parent, ref, remoteRig, paramsStruct, varargin{:}) + + in = in.Results; % Final parameters + if in.activateLog + subject = dat.parseExpRef(ref); % Extract subject, date and seq from experiment ref + try + logEntry = dat.addLogEntry(... % Add new entry to log + subject, now, 'experiment-info', struct('ref', ref), '', remoteRig.AlyxInstance); + catch ex + logEntry.comments = ''; + warning(ex.getReport()); + end + else + logEntry = []; end params = exp.Parameters(paramsStruct); % Get parameters % Can define your own experiment panel @@ -70,14 +143,10 @@ switch params.Struct.type case {'SingleTargetChoiceWorld' 'ChoiceWorld' 'DiscWorld' 'SurroundChoiceWorld'} p = eui.ChoiceExpPanel(parent, ref, params, logEntry); -% case 'GaborMapping' -% p = eui.GaborMappingExpPanel(parent, ref, params, logEntry); case 'BarMapping' p = eui.MappingExpPanel(parent, ref, params, logEntry); - case {'PositionTargetRange'} - p = eui.RangeExpPanel(parent, ref, params, logEntry); case 'custom' - p = eui.SqueakExpPanel(parent, ref, params, logEntry); + p = eui.SignalsExpPanel(parent, ref, params, logEntry); otherwise p = eui.ExpPanel(parent, ref, params, logEntry); end @@ -92,20 +161,26 @@ @() remoteRig.quitExperiment(true),... @() set(p.StopButtons, 'Enable', 'off'))); p.Root.Title = sprintf('%s on ''%s''', p.Ref, remoteRig.Name); % Set experiment panel title + + if ~isempty(in.startedTime) + % If the experiment has all ready started, trigger all dependent + % events. + p.expStarted(remoteRig, srv.ExpEvent('started', ref, p.startedTime)); + p.event('experimentStarted', p.startedTime) + end + p.Listeners = [... ...event.listener(remoteRig, 'Connected', @p.expStarted) ...event.listener(remoteRig, 'Disconnected', @p.expStopped) event.listener(remoteRig, 'ExpStarted', @p.expStarted) event.listener(remoteRig, 'ExpStopped', @p.expStopped) event.listener(remoteRig, 'ExpUpdate', @p.expUpdate)]; -% p.ElapsedTimer = timer('Period', 0.9, 'ExecutionMode', 'fixedSpacing',... -% 'TimerFcn', @(~,~) set(p.DurationLabel, 'String',... -% sprintf('%i:%02.0f', floor(p.elapsed/60), mod(p.elapsed, 60)))); end end methods function obj = ExpPanel(parent, ref, params, logEntry) + % Subclasses must chain a call to this. obj.Ref = ref; obj.SubjectRef = dat.parseExpRef(ref); obj.LogEntry = logEntry; @@ -113,15 +188,25 @@ obj.build(parent); end + function cleanup(obj) + % CLEANUP Cleanup panel For subclasses to implement. Use this method + % to release listener handles and clear any accumulated data that is + % no longer required after the experiment has ended. + end + function delete(obj) disp('ExpPanel destructor called'); - obj.cleanup(); if obj.Root.isvalid obj.Root.delete(); end end function update(obj) + % UPDATE Update the panel + % Updates the duration label counter. This method is the callback + % to the RefreshTimer in MC. Subclasses must chain a call to this. + % + % See also eui.ExpPanel/update if obj.ExpRunning elapsed = round(etime(datevec(now), datevec(obj.StartedDateTime))); set(obj.DurationLabel, 'String',... @@ -130,30 +215,60 @@ function update(obj) end end - methods %(Access = protected) - function cleanup(obj) -% if ~isempty(obj.ElapsedTimer) -% t = obj.ElapsedTimer; -% stop(t); -% delete(t); -% obj.ElapsedTimer = []; -% end - end + methods (Access = protected) function closeRequest(obj, src, evt) + % CLOSEREQUEST Callback to the close button + % Callback to the little 'x' in the corner of the panel. Deletes + % the panel. obj.delete(); end function newTrial(obj, num, condition) - %do nothing, this is for subclasses to override and react to + % NEWTRIAL Process new trial conditions + % Do nothing, this is for subclasses to override and react to, e.g. + % to update plots, etc. based on a new trial's conditional + % parameters. Called by expUpdate method upon 'newTrial' event. + % + % Inputs: + % num (int) : The new trial number. May be used to index into + % Block property + % condition (struct) : Condition data for the new trial + % + % See also expUpdate, trialCompleted end function trialCompleted(obj, num, data) - %do nothing, this is for subclasses to override and react to + % TRIALCOMPLETED Process completed trial data + % Do nothing, this is for subclasses to override and react to, e.g. + % to update plots, etc. based on a complete trial's data. Called by + % expUpdate method upon 'trialData' event. + % + % Inputs: + % num (int) : The new trial number. May be used to index into + % Block property + % data (struct) : Completed trial data + % + % See also expUpdate, trialCompleted end function event(obj, name, t) - %called when an experiment event occurs + % EVENT Called when an experiment event occurs + % Called by expUpdate callback to process all miscellaneous events, + % i.e. experiment phases. This method is downstream of srv.ExpEvent + % events. Updates ActivePhases list as well as the panel title + % colour and, upon phase changes, the Status info field. + % + % Inputs: + % name (char) : The event name + % t (date vec) : The time the event occured + % + % Example: + % if strcmp(evt.Data{1}, 'event') % srv.ExpEvent object + % % Pass event info to be processed + % obj.event(evt.Data{2}, evt.Data{3}) + % end + phaseChange = false; if strEndsWith(name, 'Started') if strcmp(name, 'experimentStarted') @@ -191,12 +306,17 @@ function expStarted(obj, rig, evt) % EXPSTARTED Callback for the ExpStarted event. % Updates the ExpRunning flag, the panel title and status label to % show that the experiment has officially begun. + % + % Inputs: + % rig (srv.StimulusControl) : The source of the event + % evt (srv.ExpEvent) : The experiment event object % % See also EXPSTOPPED - if strcmp(evt.Ref, obj.Ref) + if strcmp(evt.Ref, obj.Ref) || isempty([evt.Ref, obj.Ref]) set(obj.StatusLabel, 'String', 'Running'); %staus to running set(obj.StopButtons, 'Enable', 'on', 'Visible', 'on'); %enable stop buttons - obj.StartedDateTime = now; %take note of the experiment start time + % Take note of the experiment start time + obj.StartedDateTime = iff(isempty(evt.Data), now, evt.Data); obj.ExpRunning = true; else %started experiment does not match expected @@ -214,8 +334,11 @@ function expStopped(obj, rig, ~) % panel title and status label to show that the experiment has % ended. This function also records to Alyx the amount of water, % if any, that the subject received during the task. + % + % Inputs: + % rig (srv.StimulusControl) : The source of the event + % evt (srv.ExpEvent) : The experiment event object % - % TODO: Move water to save data functions % See also EXPSTARTED, ALYX.POSTWATER set(obj.StatusLabel, 'String', 'Completed'); %staus to completed obj.ExpRunning = false; @@ -223,39 +346,18 @@ function expStopped(obj, rig, ~) %stop listening to further rig events obj.Listeners = []; obj.Root.TitleColor = [1 0.3 0.22]; % red title area - %post water to Alyx -% ai = rig.AlyxInstance; -% subject = obj.SubjectRef; -% if ~isempty(ai)&&~strcmp(subject,'default') -% switch class(obj) -% case 'eui.ChoiceExpPanel' -% if ~isfield(obj.Block.trial,'feedbackType'); return; end % No completed trials -% if any(strcmp(obj.Parameters.TrialSpecificNames,'rewardVolume')) % Reward is trial specific -% condition = [obj.Block.trial.condition]; -% reward = [condition.rewardVolume]; -% amount = sum(reward(:,[obj.Block.trial.feedbackType]==1), 2); -% else % Global reward x positive feedback -% amount = obj.Parameters.Struct.rewardVolume(1)*... -% sum([obj.Block.trial.feedbackType]==1); -% end -% if numel(amount)>1; amount = amount(1); end % Take first element (second being laser) -% otherwise -% % Done in exp.SignalsExp/saveData -% %infoFields = {obj.InfoFields.String}; -% %inc = cellfun(@(x) any(strfind(x(:)','µl')), {obj.InfoFields.String}); % Find event values ending with 'ul'. -% %reward = cell2mat(cellfun(@str2num,strsplit(infoFields{find(inc,1)},'µl'),'UniformOutput',0)); -% %amount = iff(isempty(reward),0,@()reward); -% end -% if ~any(amount); return; end % Return if no water was given -% try -% ai.postWater(subject, amount*0.001, now, 'Water', ai.SessionURL); -% catch -% warning('Failed to post the %.2fml %s recieved during the experiment to Alyx', amount*0.001, subject); -% end -% end end function expUpdate(obj, rig, evt) + % EXPUPDATE Callback to the remote rig ExpUpdate event + % Processes a new experiment event. Events include 'newTrial', + % 'trialData', 'signals', 'event'. + % + % Inputs: + % rig (srv.StimulusControl) : The source of the event + % evt (srv.ExpEvent) : The experiment event object + % + % See also live, event, srv.StimulusControl, srv.ExpEvent type = evt.Data{1}; switch type case 'newTrial' @@ -323,17 +425,57 @@ function viewParams(obj) end function [fieldCtrl] = addInfoField(obj, label, field) + % ADDINFOFIELD Add new event info field to InfoGrid + % Adds a given field to the grid and adjusts the total height of the + % grid to accomodate all current fields. + % + % FIXME Fields with large values, e.g. arrays or chars are cut off + rowH = 20; % default height of each field obj.InfoLabels = [bui.label(label, obj.InfoGrid); obj.InfoLabels]; fieldCtrl = bui.label(field, obj.InfoGrid); obj.InfoFields = [fieldCtrl; obj.InfoFields]; - %reorder the chilren on the grid since it expects controls to be - %ordered in descending columns + if isempty(obj.UIContextMenu) + obj.UIContextMenu = uicontextmenu(ancestor(obj.Root, 'Figure')); + uimenu(obj.UIContextMenu, 'Label', 'Hide field',... + 'MenuSelectedFcn', @(~,~) obj.hideInfoField); + uimenu(obj.UIContextMenu, 'Label', 'Reset hidden',... + 'MenuSelectedFcn', @(~,~) obj.showAllFields); + end + set([obj.InfoLabels(1), fieldCtrl], 'UIContextMenu', obj.UIContextMenu) + % reorder the chilren on the grid since it expects controls to be + % ordered in descending columns obj.InfoGrid.Children = [obj.InfoFields; obj.InfoLabels]; - FieldHeight = 20; %default - nRows = numel(obj.InfoLabels); - obj.InfoGrid.RowSizes = repmat(FieldHeight, 1, nRows); - %specify more space in parent control for infogrid - obj.MainVBox.Sizes(1) = FieldHeight*nRows; + fieldHeights = fliplr(strcmp({obj.InfoFields.Visible},'on') * rowH); + obj.InfoGrid.RowSizes = fieldHeights; + % specify more space in parent control for infogrid + obj.MainVBox.Sizes(1) = sum(fieldHeights); + end + + function showAllFields(obj) + % SHOWALLFIELDS Show all hidden info fields + % Callback for the 'Reset hidden' ui menu item. Sets all fields to + % visible and resets row sizes to default height. + % + % See also HIDEINFOFIELD, ADDINFOFIELD + rowHeight = 20; + set([obj.InfoGrid.Children], 'Visible', 'on'); + obj.InfoGrid.RowSizes(obj.InfoGrid.RowSizes == 0) = rowHeight; + obj.MainVBox.Sizes(1) = sum(obj.InfoGrid.RowSizes); + end + + function hideInfoField(obj) + % HIDEINFOFIELD Hides the currently selected field row + % Callback for the 'Hide field' ui menu item. Turns off the + % visiblity of the currently selected field and sets its row height + % to 0. + % + % See also SHOWALLFIELDS, ADDINFOFIELD + selected = get(ancestor(obj.Root, 'Figure'), 'CurrentObject'); + [row, ~] = find([obj.InfoFields, obj.InfoLabels] == selected, 1); + set([obj.InfoFields(row), obj.InfoLabels(row)], 'Visible', 'off') + invisible = fliplr(strcmp({obj.InfoFields.Visible}, 'off')); + obj.InfoGrid.RowSizes(invisible) = 0; + obj.MainVBox.Sizes(1) = obj.MainVBox.Sizes(1)-20; end function commentsChanged(obj, src, ~) @@ -347,7 +489,39 @@ function commentsChanged(obj, src, ~) obj.saveLogEntry(); end + function toggleCommentsBox(obj, src, ~) + % TOGGLECOMMENTSBOX Show/hide the comments box + % Callback for the comments uimenu. If 'Hide Comments' uimenu + % selected, set the height of obj.CommentsBox to 0 and change menu + % option to 'Show Comments'. The previous height of the box is + % stored in the object's UserData field. + + % Find the position of the CommentsBox within its parent container + idx = flipud(obj.CommentsBox.Parent.Children == obj.CommentsBox); + if strcmp(src.Text, 'Show comments') + src.Text = 'Hide Comments'; + obj.CommentsBox.Visible = 'on'; + % Get previous height from UserData field, otherwise choose 80 + boxHeight = pick(obj.CommentsBox, 'UserData', 'def', 80); + obj.CommentsBox.Parent.Heights(idx) = boxHeight; + set(findobj('String', 'Comments [...]'), 'String', 'Comments') + else % Hide comments + src.Text = 'Show comments'; + obj.CommentsBox.Visible = 'off'; + % Save the previous height in UserData + obj.CommentsBox.UserData = obj.CommentsBox.Parent.Heights(idx); + obj.CommentsBox.Parent.Heights(idx) = 0; + set(findobj('String', 'Comments'), 'String', 'Comments [...]') + end + end + function build(obj, parent) + % BUILD Build the panel UI + % Creates the BoxPanel and within it a container for info fields + % (InfoGrid), a container for subclasses to add custom plots + % (CustomPanel) and the buttons and comments box. If the LogEntry + % is empty, the comments box is skipped. Subclasses must chain a + % call to this. obj.Root = uiextras.BoxPanel('Parent', parent,... 'Title', obj.Ref,... %default title is the experiment reference 'TitleColor', [0.98 0.65 0.22],...%amber title area @@ -363,21 +537,30 @@ function build(obj, parent) %panel for subclasses to add their own controls to obj.CustomPanel = uiextras.VBox('Parent', obj.MainVBox); % Custom Panel is where the live plots will go - bui.label('Comments', obj.MainVBox); % Comments label at bottom of experiment panel - - obj.CommentsBox = uicontrol('Parent', obj.MainVBox,... - 'Style', 'edit',... %text editor - 'String', obj.LogEntry.comments,... - 'Max', 2,... %make it multiline - 'HorizontalAlignment', 'left',... %make it align to the left - 'BackgroundColor', [1 1 1],...%background to white - 'Callback', @obj.commentsChanged); %update comment in log + if ~isempty(obj.LogEntry) + c = uicontextmenu(ancestor(obj.Root, 'Figure')); + uimenu(c, 'Label', 'Hide comments',... + 'MenuSelectedFcn', @obj.toggleCommentsBox); + bui.label('Comments', obj.MainVBox, 'UIContextMenu', c); + + obj.CommentsBox = uicontrol('Parent', obj.MainVBox,... + 'Style', 'edit',... %text editor + 'String', obj.LogEntry.comments,... + 'Max', 2,... %make it multiline + 'HorizontalAlignment', 'left',... %make it align to the left + 'BackgroundColor', [1 1 1],...%background to white + 'UIContextMenu', c,... + 'Callback', @obj.commentsChanged); %update comment in log + h = [15 80]; + else + h = []; + end buttonpanel = uiextras.HBox('Parent', obj.MainVBox); %info grid size will be updated as fields are added, the other %default panels get reasonable space, and the custom panel gets %whatever's left - obj.MainVBox.Sizes = [0 -1 15 80 24]; + obj.MainVBox.Sizes = [0 -1 h 24]; %add the default set of info fields to the grid obj.StatusLabel = obj.addInfoField('Status', 'Pending'); diff --git a/+eui/MControl.m b/+eui/MControl.m index d80a63f4..d71ad8f7 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -201,20 +201,16 @@ function delParamProfile(obj) % Called when 'Delete...' button is pressed next t profiles = obj.NewExpParamProfile.Option; % Get parameter profile obj.NewExpParamProfile.Option = profiles(~strcmp(profiles, profile)); % Set new list without deleted profile %log the parameters as being deleted - obj.log('Deleted parameters as ''%s''', profile); + obj.log('Deleted parameter set ''%s''', profile); end end function saveParamProfile(obj) % Called by 'Save...' button press, save a new parameter profile selProfile = obj.NewExpParamProfile.Selected; % Find which set is currently selected - if selProfile(1) ~= '<' % This statement is for autofilling the save as input dialog - %default value is currently selected profile name - def = selProfile; - else - %begins with left bracket: a special case profile is selected - %no default value - def = ''; - end + % This statement is for autofilling the save as input dialog; default + % value is currently selected profile name, however if a special case + % profile is selected there is no default value + def = iff(selProfile(1) ~= '<', selProfile, ''); ipt = inputdlg('Enter a name for the parameters profile', 'Name', 1, {def}); if isempty(ipt) return @@ -240,6 +236,7 @@ function saveParamProfile(obj) % Called by 'Save...' button press, save a new pa if ~any(strcmp(obj.NewExpParamProfile.Option, validName)) obj.NewExpParamProfile.Option = [profiles; validName]; end + obj.NewExpParamProfile.Selected = validName; %set label for loaded profile set(obj.ParamProfileLabel, 'String', validName, 'ForegroundColor', [0 0 0]); obj.log('Saved parameters as ''%s''', validName); @@ -384,18 +381,28 @@ function rigConnected(obj, rig, ~) % If rig is connected check no experiments are running... expRef = rig.ExpRunning; % returns expRef if running if expRef -% error('Experiment %s already running of %s', expDef, rig.Name) choice = questdlg(['Attention: An experiment is already running on ', rig.Name], ... upper(rig.Name), 'View', 'Cancel', 'Cancel'); switch choice case 'View' % Load the parameters from file - paramStruct = load(dat.expFilePath(expRef, 'parameters', 'master')); + paramsPath = dat.expFilePath(expRef, 'parameters', 'master'); + paramStruct = load(paramsPath); if ~isfield(paramStruct.parameters, 'type') paramStruct.type = 'custom'; % override type name with preferred end + % Determine the experiment start time + try % Try getting data from Alyx + ai = obj.AlyxPanel.AlyxInstance; + assert(ai.IsLoggedIn) + meta = ai.getSessions(expRef); + startedTime = ai.datenum(meta.start_time); + catch % Fall back on parameter file's system mod date + startedTime = file.modDate(paramsPath); + end % Instantiate an ExpPanel and pass it the expRef and parameters - panel = eui.ExpPanel.live(obj.ActiveExpsGrid, expRef, rig, paramStruct.parameters); + panel = eui.ExpPanel.live(obj.ActiveExpsGrid, expRef, rig,... + paramStruct.parameters, 'StartedTime', startedTime); obj.LastExpPanel = panel; % Add a listener for the new panel panel.Listeners = [panel.Listeners diff --git a/+eui/MappingExpPanel.m b/+eui/MappingExpPanel.m index 493e9029..b23dbacf 100644 --- a/+eui/MappingExpPanel.m +++ b/+eui/MappingExpPanel.m @@ -19,7 +19,7 @@ end end - methods %(Access = protected) + methods (Access = protected) function event(obj, name, t) event@eui.ExpPanel(obj, name, t); %call superclass method switch name diff --git a/+eui/ParamEditor.m b/+eui/ParamEditor.m index b7c7d6d1..83fd57e9 100644 --- a/+eui/ParamEditor.m +++ b/+eui/ParamEditor.m @@ -392,7 +392,7 @@ function onResize(obj) rethrow(ex); end end - elseif iscellstr(currParam) + elseif iscellstr(currParam) %#ok C = textscan(data, '%s',... 'ReturnOnError', false,... 'delimiter', {' ', ','}, 'MultipleDelimsAsOne', 1); diff --git a/+eui/README.md b/+eui/README.md new file mode 100644 index 00000000..a3949ddf --- /dev/null +++ b/+eui/README.md @@ -0,0 +1,30 @@ +## Experiment UI Package (+eui): +This `+eui` package contains all code pertaining to graphical user interfaces in Rigbox. +There are five base classes in this folder: + +1. `eui.MControl` - The class behind the Master Control (MC) GUI. +2. `eui.ExpPanel` - The superclass for UI panels that process and plot remote experiment event updates (i.e. the panels under the Current Experiments tab of MC). +3. `eui.Log` - UI control for viewing experiment log entries (the table under the Log tab of MC). +4. `eui.AlyxPanel` - UI for interacting with the Alyx database (the Alyx panel in the New Experiments tab of MC). Can be run as a stand-alone GUI. +5. `eui.ParamEditor` - UI for viewing and editing parameters (the Parameter panel in the New Experiments table of MC). Can be run as a stand-alone GUI. + +## Contents: + +Below is a list of all files present: + +- `MControl.m` - Whatever it is, take control of your experiments from this GUI +- `AlyxPanel.m` - A GUI for interating with the Alyx database. +- `SignalsTest.m` - A GUI for testing a Signals Experiment. +- `ExpPanel.m` - Basic UI control for monitoring an experiment. +- `SqueakExpPanel.m` - Basic UI control for monitoring a Signals Experiment. +- `ChoiceExpPanel.m` - An eui.ExpPanel subclass for monitoring ChoiceWorld experiments. +- `MappingExpPanel.m` - Preliminary UI for monitoring a mapping experiment. +- `ParamEditor.m` - GUI for visualizing and editing experiment parameters. +- `ConditionPanel.m` - A class for displaying the trial condition parameters in eui.ParamEditor. +- `FieldPanel.m` - A class for displaying global parameters in eui.ParamEditor. +- `Log.m` - UI control for viewing experiment log entries. + +## See Also: + +- `docs/html/using_mc.m` - A guide to using MC. +- `docs/html/using_ParamEditor.m` - A guide to using the eui.ParamEditor UI. diff --git a/+eui/SignalsExpPanel.m b/+eui/SignalsExpPanel.m new file mode 100644 index 00000000..f02308bb --- /dev/null +++ b/+eui/SignalsExpPanel.m @@ -0,0 +1,258 @@ +classdef SignalsExpPanel < eui.ExpPanel + %EUI.SIGNALSEXPPANEL Basic UI control for monitoring a Signals experiment + % Displays all values of events, inputs and outputs signals as they + % arrive from the remote stimulus server. These events arrive as + % 'signals' ExpEvents which are added to the SignalUpdates queue by + % expUpdate and processed by processUpdates upon calling the update + % method. + % + % + % Part of Rigbox + + % 2015-03 CB created + + properties + % Structure of signals event updates from SignalExp. As new updates + % come in, previous updates in the list are overwritten + SignalUpdates = struct('name', cell(500,1), 'value', cell(500,1), 'timestamp', cell(500,1)) + % List of updates to exclude (when Exclude == true) or to exclusively + % show (Exclude == false) in the InfoGrid. + UpdatesFilter = ["inputs.wheel", "pars"] + % Flag for excluding updates in UpdatesFilter list from InfoGrid. When + % false only those in the list are shown, when false those in the list + % are hidden + Exclude = true + % Flag for formating Signals updates in the InfoField Labels + FormatLabels = false + % The total number of + NumSignalUpdates = 0 + % containers.Map of InfoGrid ui labels mapped to their corresponding + % Signal name + LabelsMap + % The colour of recently updated Signals update events in the InfoGrid + RecentColour = [0 1 0] + end + + + methods + function obj = SignalsExpPanel(parent, ref, params, logEntry) + % Subclasses must chain a call to this. + obj = obj@eui.ExpPanel(parent, ref, params, logEntry); + obj.LabelsMap = containers.Map(); % Initialize labels map + end + + function update(obj) + % UPDATE Update the panel + % Processes any new updates via a call to the processUpdates method + % and changes colours of info field labels based on how recently + % they updated. This method is the callback to the RefreshTimer in + % MC. Subclasses must chain a call to this. + % + % See also eui.ExpPanel/update + update@eui.ExpPanel(obj); % Elapsed timer updated by superclass + processUpdates(obj); % Update labels with latest signal values + labelsMapVals = values(obj.LabelsMap)'; + labels = deal([labelsMapVals{:}]); + if ~isempty(labels) % Colour decay by recency on labels + dt = cellfun(@(t)etime(clock,t),... + ensureCell(get(labels, 'UserData'))); + c = num2cell(exp(-dt/1.5)*obj.RecentColour, 2); + set(labels, {'ForegroundColor'}, c); + end + end + end + + methods (Access = protected) + function newTrial(obj, num, condition) + % NEWTRIAL Process new trial conditions + % Do nothing, this is for subclasses to override and react to, e.g. + % to update plots, etc. based on a new trial's conditional + % parameters. For a SignalsExp experiment, this may be called by + % the processUpdates method upon an events.newTrial signal update. + % In the future SignalsExp may send newTrial events (i.e. + % independant of the 'signals' event updates) + % + % Inputs: + % num (int) : The new trial number. May be used to index into + % Block property + % condition (struct) : Condition data for the new trial + % + % See also processUpdates, expUpdate, trialCompleted + end + + function trialCompleted(obj, num, data) + % TRIALCOMPLETED Process completed trial data + % Do nothing, this is for subclasses to override and react to, e.g. + % to update plots, etc. based on a complete trial's data. Called by + % expUpdate method upon 'trialData' event (currently not used by + % exp.SignalsExp). + % + % Inputs: + % num (int) : The new trial number. May be used to index into + % Block property + % data (struct) : Completed trial data + % + % See also expUpdate, processUpdates, trialCompleted + end + + function event(obj, name, t) + % EVENT Process none-signals experiment event + % Called by expUpdate callback to process all miscellaneous events, + % i.e. experiment phases. This method is downstream of srv.ExpEvent + % events. Updates ActivePhases list as well as the panel title + % colour and, upon phase changes, the Status info field. + % + % Inputs: + % name (char) : The event name + % t (date vec) : The time the event occured + % + % Example: + % if strcmp(evt.Data{1}, 'event') % srv.ExpEvent object + % % Pass event info to be processed + % obj.event(evt.Data{2}, evt.Data{3}) + % end + + %called when an experiment event occurs + phaseChange = false; + if strEndsWith(name, 'Started') + if strcmp(name, 'experimentStarted') + obj.Root.TitleColor = [0 0.8 0.05]; % green title area + else + %phase has started, add it to active phases + phase = name; + phase(strfind(name, 'Started'):end) = []; + obj.ActivePhases = [obj.ActivePhases; phase]; + phaseChange = true; + end + elseif strEndsWith(name, 'Ended') + if strcmp(name, 'experimentEnded') + obj.Root.TitleColor = [0.98 0.65 0.22]; %amber title area + obj.ActivePhases = {}; + phaseChange = true; + else + %phase has ended, remove it from active phases + phase = name; + phase(strfind(name, 'Ended'):end) = []; + obj.ActivePhases(strcmp(obj.ActivePhases, phase)) = []; + phaseChange = true; + end + % else + % disp(name); + end + if phaseChange % only update if there was a change for efficiency + %update status with list of running phases + phasesStr = ['[' strJoin(obj.ActivePhases, ',') ']']; + set(obj.StatusLabel, 'String', sprintf('Running %s', phasesStr)); + end + end + + function fieldCtrl = addInfoField(obj, name, value) + % ADDINFOFIELD Add new event info field to InfoGrid + % Adds a given field to the grid and adjusts the total height of the + % grid to accomodate all current fields. If the FormatLabels + % property is true, the updates are formatted with a space beween + % capital letters. + % + % Example: + % obj.FormatLabels = true; + % obj.addInfoField('events.newTrial', 0) + % obj.InfoLabels(1).String % Formatted as 'New trial' + % + % See also eui.ExpPanel/addInfoField + if any(name=='.') && obj.FormatLabels == true + name = extractAfter(name, '.'); % Take substring after dot + name = lower(regexprep(name, '([a-z])([A-Z])', '$1 $2')); % Spaces + name(1) = upper(name(1)); % Capitalize first letter + end + fieldCtrl = addInfoField@eui.ExpPanel(obj, name, value); + end + + function processUpdates(obj) + % PROCESSUPDATES Process all accumulated signals event updates + % Process the signals events that have occured since the method was + % last called. Any new field labels are created and all fields are + % updated with the most recent signal values. + % + % This function is downstream of the update method, which is + % + % See also expUpdate, update + updates = obj.SignalUpdates(1:obj.NumSignalUpdates); + obj.NumSignalUpdates = 0; + % fprintf('processing %i signal updates\n', length(updates)); + for ui = 1:length(updates) + signame = updates(ui).name; + switch signame + case 'events.trialNum' + set(obj.TrialCountLabel, ... + 'String', num2str(updates(ui).value)); + otherwise + % Check whether to display update using UpdatesFilter + onList = any(ismember(signame, obj.UpdatesFilter)); + if (obj.Exclude && ~onList) || (~obj.Exclude && onList) + if ~isKey(obj.LabelsMap, signame) % If new update, add field + obj.LabelsMap(signame) = obj.addInfoField(signame, ''); + end + str = toStr(updates(ui).value); % Convert the value to string + set(obj.LabelsMap(signame), 'String', str, 'UserData', clock,... + 'ForegroundColor', obj.RecentColour); % Update value + end + end + end + end + + function expUpdate(obj, rig, evt) + % EXPUPDATE Callback to the remote rig ExpUpdate event + % Processes a new experiment event. Signals events are added to the + % SignalUpdates queue for processing by the processUpdates method. + % + % Inputs: + % rig (srv.StimulusControl) : The source of the event + % evt (srv.ExpEvent) : The experiment event object + % + % See also live, event, srv.StimulusControl, srv.ExpEvent + if strcmp(evt.Name, 'signals') + type = 'signals'; + else + type = evt.Data{1}; + end + switch type + case 'signals' %queue signal updates + updates = evt.Data; + newNUpdates = obj.NumSignalUpdates + length(updates); + if newNUpdates > length(obj.SignalUpdates) + %grow message queue to accommodate + obj.SignalUpdates(2*newNUpdates).value = []; + end + try + obj.SignalUpdates(obj.NumSignalUpdates+1:newNUpdates) = updates; + catch % see github.com/cortex-lab/Rigbox/issues/72 + id = 'Rigbox:eui:SignalsExpPanel:signalsUpdateMismatch'; + msg = 'Error caught in signals updates: length of updates = %g, length newNUpdates = %g'; + warning(id, msg, length(updates), newNUpdates-(obj.NumSignalUpdates+1)) + end + obj.NumSignalUpdates = newNUpdates; + case 'newTrial' + cond = evt.Data{2}; %condition data for the new trial + trialCount = obj.Block.numCompletedTrials; + %add the trial condition to a new trial in the block + obj.mergeTrialData(trialCount + 1, struct('condition', cond)); + obj.newTrial(trialCount + 1, cond); + case 'trialData' + %a trial just completed + data = evt.Data{2}; %the final data from that trial + nTrials = obj.Block.numCompletedTrials + 1; + obj.Block.numCompletedTrials = nTrials; %inc trial number in block + %merge the new data with the rest of the trial data in the block + obj.mergeTrialData(nTrials, data); + obj.trialCompleted(nTrials, data); + set(obj.TrialCountLabel, 'String', sprintf('%i', nTrials)); + case 'event' + % disp(evt.Data); + obj.event(evt.Data{2}, evt.Data{3}); + end + end + + end + +end + diff --git a/+eui/SignalsTest.m b/+eui/SignalsTest.m new file mode 100644 index 00000000..6a66a272 --- /dev/null +++ b/+eui/SignalsTest.m @@ -0,0 +1,707 @@ +classdef (Sealed) SignalsTest < handle %& exp.SignalsExp + %SIGNALSTEST A GUI for testing SignalsExp experiment definitions + % A GUI for running Signals Experiments, loading/saving parameters, + % testing custom ExpPanels and live-plotting Signals. The wheel input + % is simulated with mouse movements by default. + % + % Example: + % PsychDebugWindowConfiguration % Transparent window + % e = eui.SignalsTest('advancedChoiceWorld') + % + % TODO This may be generalized for all Experiment classes! + % + % See also: EXP.SIGNALSEXPTEST, EUI.MCONTROL + + properties + % A struct of hardware objects to be used with the Experiment + Hardware + % An experimental reference string that will be posted on expStart + Ref + % Window within Parent for showing log output + LoggingDisplay + end + + properties (SetAccess = private) % Should only be public with setters + % Option for live-plotting signals during experiment + LivePlot matlab.lang.OnOffSwitchState = 'off' + % Option for showing ExpPanel defined by 'expPanelFun' parameter + ShowExpPanel matlab.lang.OnOffSwitchState = 'on' + % Option for viewing PTB window as a single screen + SingleScreen matlab.lang.OnOffSwitchState = 'off' + end + + properties (SetAccess = private) + % The Signals experiment object + Experiment exp.test.Signals + % The parameter editor GUI + ParamEditor eui.ParamEditor + % Handle to figure for live-plotting signals + LivePlotFig matlab.ui.Figure + % ExpPanel object + ExpPanel eui.ExpPanel + end + + properties (Dependent) + % True when the Experiment object is looping + IsRunning + end + + properties (Access = private) + % The path of the last selected experiment function + LastDir + % The currently chosen Signals experiment definition + ExpDef + % Dummy communicator for ExpPanel events. Our experiment object may + % notify the ExpPanel through dummy events. + DummyRemote srv.StimulusControl + % The parent figure for the GUI + Parent matlab.ui.Figure + % A list of saved parameters sets + ParameterSets bui.Selector + % Verticle container for the ParamEditor + ParamPanel + % Current parameter set label + ParamProfileLabel + % Handles for the ExpPanel and ParamEditor events + Listeners + % The container for the running experiment panel + ExpPanelBox + % The timer for refreshing the ExpPanel + RefreshTimer + MainGrid % main 'uix.GridFlex' object + ExpGrid % top 'uix.Grid' object; child of 'MainGrid' + ExpTopBox % 'uix.HBox' object, containing UI elements to run the expDef; child of 'ExpGrid' + SelectExpDef % handle to 'Select Signals Exp Def' push-button + OptionsButton % handle to 'Options' push-button + StartButton % handle to 'Start' push-button + end + + events + % Triggers the ExpPanel to update + UpdatePanel + end + + + methods + + function TF = get.IsRunning(obj) + TF = ... + ~isempty(obj.Experiment) && ... + isvalid(obj.Experiment) && ... + obj.Experiment.IsLooping; + end + + function obj = SignalsTest(expdef, rig) + %EUI.SIGNALSTEST A GUI for testing Signals Experiments + % A GUI for parameterizing and testing exp.SignalsExp Experiments. + % Opens a stimulus window and optionally can live-plot Signals + % updates or display updates in an ExpPanel. Experiments may be + % paused by pressing the key. + % + % Inputs (optional): + % expdef (char|function_handle): The experiment definition + % function to run. May be a handle, char function name or a + % full path. If empty the user is prompted to select a file. + % rig (struct): A hardware structure to containing configuration + % settings for the test experiment. If empty the mouse is used + % as the primary input device. + % + % Example: + % PsychDebugWindowConfiguration + % e = eui.SignalsTest(@advancedChoiceWorld) + % e.startStopExp(e.ExpRef) % Start experiment + % data = e.startStopExp % Stop experiment and return block struct + % + InitializeMatlabOpenGL + % Check paths file exists + assert(exist('+dat/paths', 'file') == 2, ... + 'signals:test:copyPaths',... + 'No paths file found. A template can be found in %s.', ... + fullfile(fileparts(which('addRigboxPaths')), 'docs', 'setup')) + + obj.LastDir = getOr(dat.paths, 'expDefinitions'); + if nargin > 0 % called with experiment function to run + if ischar(expdef) + % Check function exists + assert(exist(expdef, 'file') == 2, ... + 'rigbox:eui:SignalsTest:fileNotFound',... + 'File function ''%s.m'' not found.', expdef) + % Ensure we get the absolute path of the expdef + [mpath, expdefname] = fileparts(expdef); + if isempty(mpath), mpath = fileparts(which(expdef)); end + obj.ExpDef = fullfile(mpath, [expdefname '.m']); + else + obj.ExpDef = expdef; + expdefname = func2str(expdef); + % If we can't resolve the function name, use generic title + if isempty(expdefname), expdefname = 'Signals Test'; end + end + else + % Prompt for experiment definition + [expdefname, mpath] = uigetfile(... + '*.m', 'Select the experiment definition function', obj.LastDir); + if expdefname == 0, return, end % Return on cancel + obj.LastDir = mpath; + obj.ExpDef = fullfile(mpath, expdefname); + [~, expdefname] = fileparts(obj.ExpDef); % Remove extension + end + + obj.buildUI % Build the GUI + obj.Parent.Name = expdefname; % Set title + + if nargin < 2 % Make a rig object + % Configure a stimulus window + obj.Hardware.stimWindow = hw.debugWindow(false); + obj.Hardware.stimWindow.BackgroundColour = 255/2; + obj.Hardware.stimWindow.OpenBounds = [0,0,960,400]; + + if obj.SingleScreen % view PTB window as single-screen + center = [0 0 0]; + viewingAngle = 0; + dimsCM = [20 20]; + pxBounds = [0 0 400 400]; + screen = vis.screen(center, viewingAngle, dimsCM, pxBounds); + else + screenDimsCm = [20 25]; + pxW = 960/3; % 3 screens % 1280 + pxH = 400; % 600 + screen(1) = vis.screen([0 0 9.5], -90, screenDimsCm, [0 0 pxW pxH]); % left screen + screen(2) = vis.screen([0 0 10], 0 ,... + screenDimsCm, [pxW 0 2*pxW pxH]); % ahead screen + screen(3) = vis.screen([0 0 9.5], 90,... + screenDimsCm, [2*pxW 0 3*pxW pxH]); % right screen + end + obj.Hardware.screens = screen; + + % obj.Hardware.mouseInput = hw.CursorPosition; + obj.Hardware.mouseInput.readAbsolutePosition = @obj.getMouse; + obj.Hardware.mouseInput.MillimetresFactor = .1; + obj.Hardware.mouseInput.EncoderResolution = 1; + obj.Hardware.mouseInput.ZeroOffset = 0; + obj.Hardware.mouseInput.zero = @nop; + + obj.Hardware.daqController = hw.DaqController; + obj.Hardware.name = hostname; + obj.Hardware.clock = hw.ptb.Clock; + + InitializePsychSound + d = PsychPortAudio('GetDevices'); + d = d([d.NrOutputChannels] == 2); + [~,I] = min([d.LowOutputLatency]); + obj.Hardware.audioDevices = d(I); + obj.Hardware.audioDevices.DeviceName = 'default'; + else + obj.Hardware = rig; + end + tc = matlab.mock.TestCase.forInteractiveUse; + [obj.DummyRemote, behaviour] = tc.createMock(?srv.StimulusControl); + when(withAnyInputs(behaviour.quitExperiment), ... + matlab.mock.actions.Invoke(@(~,TF)obj.startStopExp(TF))); + % Keep TestCase around until cleanup + addlistener(obj, 'ObjectBeingDestroyed', @(~,~)delete(tc)); + cb = @(~,e) iff(strcmp(e.Name,'update'), @()obj.log('Experiment update: %s', e.Data{2}), @nop); + addlistener(obj.DummyRemote, 'ExpUpdate', cb); + obj.DummyRemote.Name = obj.Hardware.name; + obj.Ref = dat.constructExpRef('test', now, 1); + + obj.loadParameters('') + end + + + function paramProfileChanged(obj, src, ~) + % callback for user GUI-selected parameter profile + if isa(src, 'eui.ParamEditor') % if a change was made to a single parameter + return + end + profile = cell2mat(src.Option(src.SelectedIdx)); + obj.loadParameters(profile); + end + + function setOptions(obj, ~, ~) + % SETOPTIONS callback for 'Options' button + % Sets various parameters related to monitering the experiment. + % + % Options: + % Plot Signals (off): Plot all events, input and output Signals + % against time in a separate figure. Clicking on each subplot + % will cycle through the plotting styles. + % Show experiment panel (on): Instantiate an eui.SignalsExpPanel + % for monitoring the experiment updates. The ExpPanelFun + % parameter defines a custom ExpPanel function to display. NB: + % Unlike in MC, the comments box is hidden. + % View PTB window as single screen (off): When true, the default + % setting of the window simulates a 4:3 aspect ratio screen. + % Post new parameters on edit (off): When true, whenever a + % parameter is edited while the experiment is running, the + % parameter Signals immediately update. + % + % See also SIG.TEST.TIMEPLOT, EUI.SIGNALSEXPPANEL + + [~,~,w] = distribute(pick(groot, 'ScreenSize')); + % getnicedialoglocation_for_newid([300 250], 'pixels') + dh = dialog('Position', [w/2, 100, 300 250], 'Name', ... + 'Exp Test Options', 'WindowStyle', 'normal'); + dCheckBox = uix.VBox('Parent', dh, 'Padding', 10); + livePlotCheck = uicontrol('Parent', dCheckBox, 'Style', 'checkbox',... + 'TooltipString', 'Plot events signals as they update', ... + 'String', 'Plot Signals', 'Value', logical(obj.LivePlot)); + expPanelCheck = uicontrol('Parent', dCheckBox, 'Style', 'checkbox',... + 'TooltipString', 'Display an experiment panel', ... + 'String', 'Show experiment panel', 'Value', logical(obj.ShowExpPanel)); + SingleScreenCheck = uicontrol('Parent', dCheckBox, 'Style', 'checkbox',... + 'String', 'View PTB window as single screen', ... + 'TooltipString', 'Simuluate a single screen monitor', ... + 'Value', logical(obj.SingleScreen), 'Enable', 'off'); + parslist = obj.Listeners(arrayfun(@(o)isa(o.Source{1}, 'eui.ParamEditor'), obj.Listeners)); + updateParsOnEdit = uicontrol('Parent', dCheckBox, 'Style', 'checkbox', ... + 'String', 'Post new parameters on edit', ... + 'TooltipString', 'Update parameter signals each time a field is changed', ... + 'Value', ~isempty(parslist) && parslist(1).Enabled); + CloseHBox = uix.HBox('Parent', dCheckBox, 'Padding', 10); + uicontrol('Parent', CloseHBox, 'String', 'Save and Close', ... + 'Callback', @(~,~) processOptions); + + function processOptions + % callback function for the 'Save and Close' button defined above + % TODO use setters instead + + % Configure live plot + obj.LivePlot = livePlotCheck.Value; + figValid = ~isempty(obj.LivePlotFig) && isvalid(obj.LivePlotFig); + if obj.LivePlot && obj.IsRunning && figValid == false + % If the experiment is running and we want to show figure... + plot(obj) % ... create new plot + elseif ~obj.LivePlot && figValid + % If the figure is open and we chose not to plot... + close(obj.LivePlotFig) % ... close the figure + end + + % Configure the ExpPanel + obj.ShowExpPanel = expPanelCheck.Value; + if ~obj.ShowExpPanel % Hide the panel + obj.ExpPanelBox.Visible = false; + obj.ExpPanelBox.Parent.set('Widths', [-1, 0]); + else % If an experiment is running, show panel, otherwise done by init + if obj.IsRunning + obj.ExpPanelBox.Visible = true; + obj.ExpPanelBox.Parent.set('Widths', [-1, 400]); + end + end + + % Configure default screen settings + if obj.SingleScreen ~= SingleScreenCheck.Value + % TODO Make changes to screens field + end + obj.SingleScreen = SingleScreenCheck.Value; + + % Configure Parameter update callback + if isempty(parslist) + % Regardless of setting, create a listener for eui.ParamEditor + % Changed event + runOnValid = @(fn) iff(~isempty(obj.Experiment), fn, @nop); + callbk = @(PE,~) ... + runOnValid(@()obj.Experiment.updateParams(PE.Parameters.Struct)); + log = @()obj.log('Updating parameters'); + parslist = [addlistener(obj.ParamEditor, 'Changed', callbk); + addlistener(obj.ParamEditor, 'Changed', @(~,~)runOnValid(log))]; + obj.Listeners = [obj.Listeners; parslist]; + end + [parslist.Enabled] = deal(updateParsOnEdit.Value); + delete(dh) + end + + end + + function startStopExp(obj, varargin) + % STARTSTOPEXP Callback for 'Start/Stop' button + % Stops experiment if running, and starts experiment if not + % running. Inputs passed to exp.SignalsExp/run or + % exp.SignalsExp/quit depending on the state. + % + % Input: + % expRef | immediately : When IsRunning == false, the experiment + % reference string. Otherwise, a flag for whether to abort the + % experiment. + % + % See also EXP.SIGNALSEXP/RUN, EXP.SIGNALSEXP/QUIT + + if obj.IsRunning + % Stop experiment + type = iff(~isempty(varargin) && varargin{1}, 'Aborting', 'Ending'); + obj.log('%s experiment', type); + obj.Experiment.quit(varargin{:}); + % if obj.LivePlot && ~isempty(obj.LivePlotFig) && isvalid(obj.LivePlotFig) + % obj.LivePlotFig.DeleteFcn(); % Clear plot listeners + % end + obj.stopTimer + btnCallback = @(~,~)obj.startStopExp(obj.Ref); + obj.StartButton.set('String', 'Start', 'Callback', btnCallback); + else % start experiment + % FIXME Log via event handlers + % obj.Experiment.updateParameters %(?) + obj.init + btnCallback = @(~,~)obj.startStopExp(); + obj.StartButton.set('String', 'Stop', 'Callback', btnCallback); + obj.log('Starting ''%s'' experiment. Press <%s> to pause', ... + obj.Parent.Name, KbName(obj.Experiment.PauseKey)); + oldWarn = warning('off', 'Rigbox:exp:SignalsExp:experimenDoesNotExist'); + mess = onCleanup(@()warning(oldWarn)); + try + obj.Experiment.run(varargin{:}); + catch ex + % Experiment stopped with an exception + % Notify panel and stop timer + evt = srv.ExpEvent('exception', obj.Ref, ex.message); + notify(obj.DummyRemote, 'ExpStopped', evt); + btnCallback = @(~,~)obj.startStopExp(obj.Ref); + obj.StartButton.set('String', 'Start', 'Callback', btnCallback); + obj.stopTimer + obj.log('Exception during experiment'); + rethrow(ex) + end + end + + end + + function log(obj, varargin) + % LOG Displayes timestamped information about occurrences in mc + % The log is stored in the LoggingDisplay property. + % log(formatSpec, A1,... An) + % + % See also FPRINTF + message = sprintf(varargin{:}); + timestamp = datestr(now, 'dd-mm-yyyy HH:MM:SS'); + str = sprintf('[%s] %s', timestamp, message); + current = get(obj.LoggingDisplay, 'String'); + set(obj.LoggingDisplay, 'String', [current; str], 'Value', numel(current) + 1); + end + + function clearLog(obj) + % clears 'LoggingDisplay' + obj.LoggingDisplay.String = {}; + end + + function cleanup(obj) + if obj.IsRunning + % FIXME Currently when the window is closed the experiment object + % is quit and deleted during the main loop's call to drawnow. + % After deletion the function continues throwing an error about + % access to a deleted object + obj.Experiment.quit(true); + end + obj.Hardware.stimWindow.close() + if ~isempty(obj.RefreshTimer) + obj.stopTimer() + delete(obj.RefreshTimer); + obj.RefreshTimer = []; + end + if obj.LivePlot && ~isempty(obj.LivePlotFig) && isvalid(obj.LivePlotFig) + obj.LivePlotFig.DeleteFcn(); % Clear plot listeners + end + obj.Listeners = []; + end + + function delete(obj) + % makes sure to delete 'ScreenH' PTB Screen and 'LivePlot' figure + fprintf('delete called on SignalsTest\n'); + cleanup(obj) + delete(obj.Experiment); + if ~isempty(obj.LivePlotFig) && isvalid(obj.LivePlotFig) + delete(obj.LivePlotFig) + end + delete(obj.Hardware.stimWindow) + end + + end + + methods (Access = protected) + + function buildUI(obj) + % Create Exp Test Panel figure and all UI elements: + % Layout arrangement: 'Parent' -> 'MainGrid' -> + % + % See also loadParameters + + [~,~,w,h] = distribute(pick(groot, 'ScreenSize')); + + % create main figure + obj.Parent = figure('Name', 'ExpTestPanel', 'NumberTitle', 'off',... + 'Toolbar', 'None', 'Menubar', 'None', 'Position', [w/2-350,... + h/2-475, 950, 700], 'DeleteFcn', @(~,~) obj.cleanup); + + % GUI layout toolbox functions to set-up ui elements within main figure + panel = uix.HBox('Parent', obj.Parent, 'Padding', 5); + obj.MainGrid = uix.GridFlex('Parent', panel, 'Spacing', 10,... + 'Padding', 5); + obj.ExpPanelBox = uix.VBox('Parent', panel, 'Padding', 5, 'Visible', 0); + panel.set('Widths', [-1, 0]) + obj.ExpGrid = uix.Grid('Parent', obj.MainGrid, 'Spacing', 5,... + 'Padding', 5); + obj.ExpTopBox = uix.HBox('Parent', obj.ExpGrid, 'Spacing', 5,... + 'Padding', 5); + + obj.SelectExpDef = uicontrol('Parent', obj.ExpTopBox,... + 'Style', 'pushbutton', 'String', 'Select Signals Exp Def',... + 'Callback', @(~,~)obj.setExpDef()); + obj.OptionsButton = uicontrol('Parent', obj.ExpTopBox,... + 'Style', 'pushbutton', 'String', 'Options',... + 'Callback', @(src,event) obj.setOptions(src,event)); + obj.StartButton = uicontrol('Parent', obj.ExpTopBox,... + 'Style', 'pushbutton', 'String', 'Start',... + 'Callback', @(~,~)obj.startStopExp(obj.Ref)); + + % Parameters Panel + param = uix.Panel('Parent', obj.MainGrid,... + 'Title', 'Parameters', 'Padding', 5); + obj.ParamPanel = uiextras.VBox('Parent', param, 'Padding', 5); % Make verticle container for parameters + + hbox = uiextras.HBox('Parent', obj.ParamPanel); % Make container for 'sets' dropdown boxes + bui.label('Current set:', hbox); % Add 'Current set' label + obj.ParamProfileLabel = bui.label('none', hbox, 'FontWeight', 'bold'); % Current parameter label + hbox.Sizes = [60 400]; % Set size of Current set labels + hbox = uiextras.HBox('Parent', obj.ParamPanel, 'Spacing', 2); % Make new HBox for saved sets + bui.label('Saved sets:', hbox); % Add 'Saved sets' label + obj.ParameterSets = bui.Selector(hbox, ... + [{''}; fieldnames(dat.loadParamProfiles('custom'))]); + % obj.ParameterSets.addlistener('SelectionChanged', obj.paramProfileChanged(src, event)); + uicontrol('Parent', hbox,... % Make 'Load' button + 'Style', 'pushbutton',... + 'String', 'Load',... + 'Callback', @(~,~) obj.loadParameters(obj.ParameterSets.Selected)); % Pass selected param profile to loadParamProfile() when pressed + uicontrol('Parent', hbox,... % Make 'Save' button + 'Style', 'pushbutton',... + 'String', 'Save...',... + 'Callback', @(~,~)obj.saveParamProfile,... + 'Enable', 'on'); + uicontrol('Parent', hbox,... % Make 'Delete' button + 'Style', 'pushbutton',... + 'String', 'Delete...',... + 'Callback', @(~,~)obj.delParamProfile,... + 'Enable', 'on'); + hbox.Sizes = [60 200 60 60 60]; % Set horizontal sizes for Sets dropdowns and buttons + obj.ParamPanel.Sizes = [22 22]; % Set vertical size by changing obj.ParamPanel VBox size + + % Logging Display + obj.LoggingDisplay = uicontrol('Parent', obj.MainGrid,... + 'Style', 'listbox', 'Enable', 'inactive', 'String', {},... + 'Tag', 'Logging Display'); + c = uicontextmenu(obj.Parent); + obj.LoggingDisplay.UIContextMenu = c; + uimenu(c, 'Label', 'Clear Logging Display',... + 'MenuSelectedFcn', @(~,~) obj.clearLog); + + % Set proportions + obj.MainGrid.set('Heights', [-1 -9 -3]); + + % Add a timer for updating the panel. NB: Although we could call + % obj.ExpPanel.update() directly, events throw errors as warnings + % providing us with more information for debugging! + obj.RefreshTimer = timer(... + 'Name', 'ExpPanel update', ... + 'Period', 0.1, ... + 'ExecutionMode', 'fixedSpacing',... + 'TimerFcn', @(~,~)obj.notify('UpdatePanel')); + end + + function init(obj) + % INIT Initialize experiment object + % Instantiate an experiment object and configure the live plot and + % ExpPanel + + % Set up experiment + if ~obj.Hardware.stimWindow.IsOpen + obj.Hardware.stimWindow.PxDepth = Screen('PixelSize', 0); + obj.Hardware.stimWindow.open(); + end + p = obj.ParamEditor.Parameters.Struct; + p.defFunction = obj.ExpDef; + delete(obj.Experiment) % delete previous experiment + obj.Experiment = exp.test.Signals(p, obj.Hardware, true); % create new SignalsExp object + + % Switch off keypresses + obj.Experiment.QuitKey = []; + + % Add in our dummy communicator + obj.Experiment.Communicator = obj.DummyRemote; + + if obj.LivePlot + plot(obj) + end + + if obj.ShowExpPanel + % If there's a previous panel, delete it + if ~isempty(obj.ExpPanel) + delete(obj.ExpPanel) + end + % FIXME Call directly and remove logEntry flag + obj.ExpPanel = eui.ExpPanel.live(obj.ExpPanelBox, obj.Ref, obj.DummyRemote, p, 0); + hidePanel = @(~,~)fun.apply({ % TODO Turn off param too + @()set(obj.ExpPanelBox, 'Visible', false); + @()set(obj.ExpPanelBox.Parent, 'Widths', [-1, 0])}); + obj.Listeners = [obj.Listeners + event.listener(obj, 'UpdatePanel', @(~,~)obj.ExpPanel.update()) + event.listener(obj.ExpPanel, 'ObjectBeingDestroyed', hidePanel) % FIXME No need to keep around + event.listener(obj.ExpPanel, 'ObjectBeingDestroyed', @(~,~)obj.stopTimer)]; + if strcmp(obj.ExpPanelBox.Visible, 'off') + obj.ExpPanelBox.Visible = true; + set(get(obj.ExpPanelBox, 'Parent'), 'Widths', [-1, 400]) + end + start(obj.RefreshTimer); + end + % TODO Set as callback? + % Update the parameter set label to indicate used for this experiment + % parLabel = sprintf('from last experiment of %s (%s)', subject, expRef); + % set(obj.ParamProfileLabel, 'String', parLabel, 'ForegroundColor', [0 0 0]); + end + + function stopTimer(obj) + % STOPTIMER Convenience function only stops timer when running + if ~isempty(obj.RefreshTimer) && strcmp(obj.RefreshTimer.Running, 'on') + stop(obj.RefreshTimer); + end + end + + function plot(obj) + % PLOT Set up figure for online plotting of Signals events + % If the current plotting figure is no longer valid a new one is + % created. + % + % See also SIG.TEST.TIMEPLOT + if isempty(obj.LivePlotFig) || ~isvalid(obj.LivePlotFig) + obj.LivePlotFig = figure('Name', 'LivePlot', 'NumberTitle', 'off', ... + 'Color', 'w', 'Units', 'normalized'); + obj.LivePlotFig.OuterPosition = [0.6 0 0.4 1]; + else + obj.LivePlotFig.DeleteFcn(); % Delete previous listeners + end + sig.test.timeplot(obj.Experiment.Time, obj.Experiment.Events, ... + 'parent', obj.LivePlotFig, 'mode', 0, 'tWin', 60); + end + + function x = getMouse(obj) + % GETMOUSE Return mouse x co-ordinate over stimulus window only + % TODO Make into hw class + persistent last lastInBounds + if isempty(last); last = 0; end + if isempty(lastInBounds); lastInBounds = 0; end + bounds = obj.Hardware.stimWindow.OpenBounds; + [x,y] = GetMouse(); + withinBounds = ... + x >= bounds(1) && ... + x <= bounds(1) + bounds(3) && ... + y >= bounds(2) && ... + y <= bounds(2) + bounds(4); + + dx = (x - last); + last = x; + if withinBounds + x = lastInBounds + dx; + lastInBounds = x; + else + x = lastInBounds; + end + end + + function setExpDef(obj) + % gets and sets signals expDef + [mfile, mpath] = uigetfile('*.m', 'Select Exp Def', obj.LastDir); + if ~mfile, return; end + obj.ExpDef = fullfile(mpath, mfile); + obj.LastDir = mpath; + obj.Parent.Name = mfile(1:end-2); + obj.loadParameters(''); + end + + function loadParameters(obj, profile) + % loads parameters + % + % Inputs: + % 'profile': the parameters' profile (i.e. a parameter set) + + % Red 'Loading...' while new set loads + set(obj.ParamProfileLabel, 'String', 'loading...', 'ForegroundColor', [1 0 0]); + + % switch-case for how to load parameters for either: 1) default Exp + % Def parameters; 3) Saved parameter set on server + switch lower(profile) + case '' + paramStruct = exp.inferParameters(obj.ExpDef); + label = 'defaults'; + otherwise + saved = dat.loadParamProfiles('custom'); + paramStruct = saved.(profile); + label = profile; + end + + if isfield(paramStruct, 'services') % remove 'services' field + paramStruct = rmfield(paramStruct, 'services'); + end + paramStruct = rmfield(paramStruct, 'defFunction'); + + pars = exp.Parameters(paramStruct); % build parameters + % Now parameters are loaded, pass to ParamEditor for display, etc. + if isempty(obj.ParamEditor) + obj.ParamEditor = eui.ParamEditor(pars, obj.ParamPanel); + else + obj.ParamEditor.buildUI(pars); + end + obj.ParamEditor.addlistener('Changed', @(src,event) obj.paramProfileChanged(src, event)); + set(obj.ParamProfileLabel, 'String', label, 'ForegroundColor', [0 0 0]); + end + + function saveParamProfile(obj) + % Called by 'Save...' button press, save a new parameter profile + selProfile = obj.ParameterSets.Selected; % Find which set is currently selected + % This statement is for autofilling the save as input dialog; default + % value is currently selected profile name, however if a special case + % profile is selected there is no default value + def = iff(selProfile(1) ~= '<', selProfile, ''); + ipt = inputdlg('Enter a name for the parameters profile', 'Name', 1, {def}); + if isempty(ipt) + return + else % Get the name they entered into the dialog + name = ipt{1}; + end + doSave = true; + validName = matlab.lang.makeValidName(name); + if ~strcmp(name, validName) % If the name they entered is non-alphanumeric... + q = sprintf('''%s'' is not valid (names must be alphanumeric with no spaces)\n', name); + q = [q sprintf('Do you want to use ''%s'' instead?', validName)]; + doSave = strcmp(questdlg(q, 'Name', 'Yes', 'No', 'No'), 'Yes'); % Do they still want to save with suggested name? + end + if doSave % Going ahead with save + p = obj.ParamEditor.Parameters.Struct; + % Restore defFunction parameter + p.defFunction = obj.ExpDef; + dat.saveParamProfile('custom', validName, p); % Save + %add the profile to the control options + profiles = obj.ParameterSets.Option; + if ~any(strcmp(obj.ParameterSets.Option, validName)) + obj.ParameterSets.Option = [profiles; validName]; % Add to list + end + obj.ParameterSets.Selected = validName; % Make currently selected + %set label for loaded profile + set(obj.ParamProfileLabel, 'String', validName, 'ForegroundColor', [0 0 0]); + obj.log('Saved parameters as ''%s''', validName); + end + end + + function delParamProfile(obj) + % Called when 'Delete...' button is pressed next to saved sets + profile = obj.ParameterSets.Selected; % Get param profile that was selected from the dropdown? + assert(profile(1) ~= '<', 'Special case profile %s cannot be deleted', profile); % If '' or '' is selected give error + q = sprintf('Are you sure you want to delete parameters profile ''%s''', profile); + doDelete = strcmp(questdlg(q, 'Delete', 'Yes', 'No', 'No'), 'Yes'); % Find out whether they confirmed delete + if doDelete % They pressed 'Yes' + dat.delParamProfile('custom', profile); + %remove the profile from the control options + profiles = obj.ParameterSets.Option; % Get parameter profile + obj.ParameterSets.Option = profiles(~strcmp(profiles, profile)); % Set new list without deleted profile + % log the parameters as being deleted + obj.log('Deleted parameter set ''%s''', profile); + end + end + + end + +end diff --git a/+eui/SqueakExpPanel.m b/+eui/SqueakExpPanel.m deleted file mode 100644 index 29487985..00000000 --- a/+eui/SqueakExpPanel.m +++ /dev/null @@ -1,194 +0,0 @@ -classdef SqueakExpPanel < eui.ExpPanel - %EUI.SQUEAKEXPPANEL Basic UI control for monitoring an experiment - % TODO - % - % Part of Rigbox - - % 2015-03 CB created - - properties - SignalUpdates = struct('name', cell(500,1), 'value', cell(500,1), 'timestamp', cell(500,1)) - NumSignalUpdates = 0 - LabelsMap - RecentColour = [0 1 0] - end - - - methods - function obj = SqueakExpPanel(parent, ref, params, logEntry) - obj = obj@eui.ExpPanel(parent, ref, params, logEntry); - obj.LabelsMap = containers.Map(); - end - - function update(obj) - update@eui.ExpPanel(obj); - processUpdates(obj); % update labels with latest signal values - labelsMapVals = values(obj.LabelsMap)'; - labels = deal([labelsMapVals{:}]); - if ~isempty(labels) % colour decay by recency on labels - dt = cellfun(@(t)etime(clock,t),... - ensureCell(get(labels, 'UserData'))); - c = num2cell(exp(-dt/1.5)*obj.RecentColour, 2); - set(labels, {'ForegroundColor'}, c); - end - end - end - - methods %(Access = protected) - function newTrial(obj, num, condition) - end - - function trialCompleted(obj, num, data) - end - - function event(obj, name, t) - %called when an experiment event occurs - phaseChange = false; - if strEndsWith(name, 'Started') - if strcmp(name, 'experimentStarted') - obj.Root.TitleColor = [0 0.8 0.05]; % green title area - else - %phase has started, add it to active phases - phase = name; - phase(strfind(name, 'Started'):end) = []; - obj.ActivePhases = [obj.ActivePhases; phase]; - phaseChange = true; - end - elseif strEndsWith(name, 'Ended') - if strcmp(name, 'experimentEnded') - obj.Root.TitleColor = [0.98 0.65 0.22]; %amber title area - obj.ActivePhases = {}; - phaseChange = true; - else - %phase has ended, remove it from active phases - phase = name; - phase(strfind(name, 'Ended'):end) = []; - obj.ActivePhases(strcmp(obj.ActivePhases, phase)) = []; - phaseChange = true; - end - % else - % disp(name); - end - if phaseChange % only update if there was a change for efficiency - %update status with list of running phases - phasesStr = ['[' strJoin(obj.ActivePhases, ',') ']']; - set(obj.StatusLabel, 'String', sprintf('Running %s', phasesStr)); - end - end - - function processUpdates(obj) - updates = obj.SignalUpdates(1:obj.NumSignalUpdates); - obj.NumSignalUpdates = 0; - % fprintf('processing %i signal updates\n', length(updates)); - for ui = 1:length(updates) - signame = updates(ui).name; - switch signame - case {'inputs.wheel', 'pars'} - otherwise - if ~isKey(obj.LabelsMap, signame) - obj.LabelsMap(signame) = obj.addInfoField(signame, ''); - end -% time = datenum(updates(ui).timestamp); - % str = ['[' datestr(time,'HH:MM:SS') '] ' toStr(updates(ui).value)]; - str = toStr(updates(ui).value); - set(obj.LabelsMap(signame), 'String', str, 'UserData', clock,... - 'ForegroundColor', obj.RecentColour); - end - end - end - - function expUpdate(obj, rig, evt) - if strcmp(evt.Name, 'signals') - type = 'signals'; - else - type = evt.Data{1}; - end - switch type - case 'signals' %queue signal updates - updates = evt.Data; - newNUpdates = obj.NumSignalUpdates + length(updates); - if newNUpdates > length(obj.SignalUpdates) - %grow message queue to accommodate - obj.SignalUpdates(2*newNUpdates).value = []; - end - obj.SignalUpdates(obj.NumSignalUpdates+1:newNUpdates) = updates; - obj.NumSignalUpdates = newNUpdates; - case 'newTrial' - cond = evt.Data{2}; %condition data for the new trial - trialCount = obj.Block.numCompletedTrials; - %add the trial condition to a new trial in the block - obj.mergeTrialData(trialCount + 1, struct('condition', cond)); - obj.newTrial(trialCount + 1, cond); - case 'trialData' - %a trial just completed - data = evt.Data{2}; %the final data from that trial - nTrials = obj.Block.numCompletedTrials + 1; - obj.Block.numCompletedTrials = nTrials; %inc trial number in block - %merge the new data with the rest of the trial data in the block - obj.mergeTrialData(nTrials, data); - obj.trialCompleted(nTrials, data); - set(obj.TrialCountLabel, 'String', sprintf('%i', nTrials)); - case 'event' - % disp(evt.Data); - obj.event(evt.Data{2}, evt.Data{3}); - end - end - - function build(obj, parent) - obj.Root = uiextras.BoxPanel('Parent', parent,... - 'Title', obj.Ref,... %default title is the experiment reference - 'TitleColor', [0.98 0.65 0.22],...%amber title area - 'Padding', 5,... - 'CloseRequestFcn', @obj.closeRequest,... - 'DeleteFcn', @(~,~) obj.cleanup()); - - obj.MainVBox = uiextras.VBox('Parent', obj.Root, 'Spacing', 5); - - obj.InfoGrid = uiextras.Grid('Parent', obj.MainVBox); -% obj.InfoGrid.ColumnSizes = [150, -1]; - %panel for subclasses to add their own controls to - obj.CustomPanel = uiextras.VBox('Parent', obj.MainVBox); - - bui.label('Comments', obj.MainVBox); - - obj.CommentsBox = uicontrol('Parent', obj.MainVBox,... - 'Style', 'edit',... %text editor - 'String', obj.LogEntry.comments,... - 'Max', 2,... %make it multiline - 'HorizontalAlignment', 'left',... %make it align to the left - 'BackgroundColor', [1 1 1],...%background to white - 'Callback', @obj.commentsChanged); %update comment in log - - buttonpanel = uiextras.HBox('Parent', obj.MainVBox); - %info grid size will be updated as fields are added, the other - %default panels get reasonable space, and the custom panel gets - %whatever's left - obj.MainVBox.Sizes = [0 -1 15 80 24]; - - %add the default set of info fields to the grid - obj.StatusLabel = obj.addInfoField('Status', 'Pending'); - obj.DurationLabel = obj.addInfoField('Elapsed', '-:--'); - - if isfield(obj.Parameters.Struct, 'conditionId') - obj.ConditionLabel = obj.addInfoField('Condition', 'N/A'); - end - - %buttons to stop experiment running if and when it is, by default - %hidden - obj.StopButtons = [... - uicontrol('Parent', buttonpanel,... - 'Style', 'pushbutton',... - 'String', 'End'),... - uicontrol('Parent', buttonpanel,... - 'Style', 'pushbutton',... - 'String', 'Abort')]; - set(obj.StopButtons, 'Enable', 'off', 'Visible', 'off'); - uicontrol('Parent', buttonpanel,... - 'Style', 'pushbutton',... - 'String', 'Parameters...',... - 'Callback', @(~, ~) obj.viewParams()); - end - end - -end - diff --git a/+exp/+test/Signals.m b/+exp/+test/Signals.m new file mode 100644 index 00000000..641ff8ce --- /dev/null +++ b/+exp/+test/Signals.m @@ -0,0 +1,82 @@ +classdef Signals < exp.SignalsExp + %EXP.TEST.SIGNALSEXP Subclass for playing with Signals Experiments + % This class overloads a couple of superclass methods for running an + % experiment definition in a test enviroment. + % + % See also EUI.SIGNALSTEST + % + % Part of Rigbox + + % 2012-11 CB created + + methods + + function updateParams(obj, paramStruct) + % UPDATEPARAMS Updates parameters after initialization + % Updates the parameter signals with a new parameter set. + % + % Input: + % paramStruct : A parameter structure + % + + % get global parameters & conditional parameters structs + fprintf('Updating parameters\n'); + [~, globalStruct, allCondStruct] = toConditionServer(... + exp.Parameters(paramStruct)); + if isfield(globalStruct, 'defFunction') + globalStruct = rmfield(globalStruct, 'defFunction'); + end + obj.GlobalPars.post(globalStruct); + obj.ConditionalPars.post(allCondStruct); + end + + function post(obj, id, msg) + % POST Directly trigger remote rig events, simulating Web Sockets + % If the Communicator is a srv.StimulusControl object, its events + % are notified as if the events have been received from a Web + % socket. + % + % See also SRV.STIMULUSCONTROL + com = obj.Communicator; + if isa(com, 'srv.StimulusControl') + % Notify listeners of event to simulate remote message received + switch id + case 'signals' + evt = srv.ExpEvent('signals', [], msg); + notify(com, 'ExpUpdate', evt); + case 'status' + type = msg{1}; + switch type + case 'starting' + % experiment about to start + ref = msg{2}; + notify(com, 'ExpStarting', srv.ExpEvent('starting', ref)); + case 'update' + ref = msg{2}; args = msg(3:end); + if strcmp(args{1}, 'event') && strcmp(args{2}, 'experimentInit') + notify(com, 'ExpStarted', srv.ExpEvent('started', ref)); + elseif strcmp(args{1}, 'event') && strcmp(args{2}, 'experimentEnded') + % message usually sent by expServer + notify(com, 'ExpStopped', srv.ExpEvent('completed', ref)); + end + notify(com, 'ExpUpdate', srv.ExpEvent('update', ref, args)); + end + otherwise + % Do nothing upon Alyx request + end + else + post@exp.SignalsExp(obj, id, msg) + end + end + + end + + methods (Access = protected) + + function saveData(~) + % Do nothing + end + + end + +end \ No newline at end of file diff --git a/+exp/Contents.m b/+exp/Contents.m new file mode 100644 index 00000000..d398d7f0 --- /dev/null +++ b/+exp/Contents.m @@ -0,0 +1,53 @@ +% +EXP Classes and functions for the Rigbox Experiment framework +% The Experiment framework is for setting up and running +% stimulus-delivering experiments. The framework allows parameterizing +% individual experiments at a single-trial level. Visual and auditory +% stimuli can be controlled by experiment phases or by the Signals +% framework. Phases changes are managed by an event-handling system. +% +% Files +% %%% Experiment Classes +% Experiment - Base class for stimuli-delivering experiments +% LIARExperiment - Linear Input and Reward experiment +% SignalsExp - Trial-based Signals Experiments +% +% %%% Event Handlers +% EventHandler - Performs actions following an event +% EventInfo - Experimental event info base class +% TrialEventInfo - Provides information about a trial event +% ThresholdEventInfo - Provides information about a threshold reached +% ResponseEventInfo - Provides information about a subject's response +% +% %%% Event Actions +% Action - Base-class for actions used with an EventHandler +% StartPhase - Instruction to start a particular experiment phase +% EndPhase - Instruction to end a particular experiment phase +% StartTrial - Instruction to start a new trial in an experiment +% EndTrial - Instruction to end a new trial in an experiment +% DeliverReward - Delivers reward in an experiment +% LoadSound - Loads specified samples ready for playing +% PlaySound - Plays the currently loaded sound +% StartServices - Starts experiment services +% StopServices - Stops experiment services +% TriggerLaser - Triggers laser pulse in an experiment +% RegisterNoGoResponse - Register time threshold response +% RegisterThresholdResponse - Register the appropriate response +% StartResponseFeedback - Start appropriate feedback phase for response +% +% %%% Parameters +% Parameters - A store & methods for managing experiment parameters +% ConditionServer - Interface for provision of trial parameters +% PresetConditionServer - Provides preset trials from an array +% inferParameters - Infers the parameters required for Signals experiments +% promptForParams - +% trialConditions - Returns trial parameter Signals +% +% %%% Time-Samplers +% TimeSampler - Interface for generating times from some distribution +% FixedTime - Always generates a fixed time +% UniformInterval - A time sampled uniformly from an interval +% ExponentialInterval - A time sampled with a flat hazard function +% +% %%% Other +% configureSignalsExperiment - +% SignalsTest - diff --git a/+exp/Experiment.m b/+exp/Experiment.m index 5f411ac9..29d675b0 100644 --- a/+exp/Experiment.m +++ b/+exp/Experiment.m @@ -9,67 +9,69 @@ % 2012-11 CB created properties - %An array of event handlers. Each should specify the name of the - %event that activates it, callback functions to be executed when - %activated and an optional delay between the event and activation. - %They should be objects of class EventHandler. + % An array of event handlers. Each should specify the name of the + % event that activates it, callback functions to be executed when + % activated and an optional delay between the event and activation. + % They should be objects of class EventHandler. EventHandlers = exp.EventHandler.empty; - %Timekeeper used by the experiment. Clocks return the current time. See - %the Clock class definition for more information. + % Timekeeper used by the experiment. Clocks return the current time. See + % the Clock class definition for more information. Clock = hw.ptb.Clock; - %A stimulus window for rendering visual stimuli during the experiment. - %Must be of Window class. + % A stimulus window for rendering visual stimuli during the experiment. + % Must be of Window class. StimWindow; - %Handles conversion between graphics and visual field coordinates of - %the stimulus window. Must be an object of ViewingModel class. + % Handles conversion between graphics and visual field coordinates of + % the stimulus window. Must be an object of ViewingModel class. StimViewingModel; - %Key for terminating an experiment whilst running. Shoud be a - %Psychtoolbox keyscan code (see PTB KbName function). + % Key for terminating an experiment whilst running. Shoud be a + % Psychtoolbox keyscan code (see PTB KbName function). QuitKey = KbName('esc') - PauseKey = KbName('esc') %Key for pausing an experiment + % Key for pausing an experiment + PauseKey = KbName('esc') - %Possible phases of the experiment. Cell array of the names (strings) of the phases. + % Possible phases of the experiment. Cell array of the names (strings) of the phases. % not currently used % Phases = {}; - %Display debugging information + % Display debugging information DisplayDebugInfo = false; - %Provides the conditions (parameters etc) for each trial. Must be an - %object of class exp.ConditionServer + % Provides the conditions (parameters etc) for each trial. Must be an + % object of class exp.ConditionServer ConditionServer; - LoopDuration = []; - - %String description of the type of experiment, to be saved into the - %block data field 'expType'. + % String description of the type of experiment, to be saved into the + % block data field 'expType'. Type = ''; - %Reference for the rig that this experiment is being run on, to be - %saved into the block data field 'rigName'. + % Reference for the rig that this experiment is being run on, to be + % saved into the block data field 'rigName'. RigName Communicator = io.DummyCommunicator Audio - %Delay (secs) before starting main experiment phase after experiment - %init phase has completed + % Delay (secs) before starting main experiment phase after experiment + % init phase has completed PreDelay = 0 - %Delay (secs) before beginning experiment cleanup phase after - %main experiment phase has completed (assuming an immediate abort - %wasn't requested). + % Delay (secs) before beginning experiment cleanup phase after + % main experiment phase has completed (assuming an immediate abort + % wasn't requested). PostDelay = 0 - IsPaused = false %flag indicating whether the experiment is paused + % Flag indicating whether the experiment is paused. + % FIXME Protect access to IsPaused property + % @body This should be set only via the pause and resume methods + IsPaused = false - %AlyxToken from client + % AlyxToken from client AlyxInstance end @@ -292,7 +294,7 @@ function addEventHandler(obj, handler, varargin) end %post comms notification with event name and time - if isempty(obj.AlyxInstance) + if isempty(obj.AlyxInstance) || ~obj.AlyxInstance.IsLoggedIn post(obj, 'AlyxRequest', obj.Data.expRef); %request token from client pause(0.2) end diff --git a/+exp/Model.m b/+exp/Model.m deleted file mode 100644 index 204f1365..00000000 --- a/+exp/Model.m +++ /dev/null @@ -1,28 +0,0 @@ -classdef Model < handle - %SEXPERIMENT Summary of this class goes here - % Detailed explanation goes here - - properties - Time % time signal - Input % input signals - Output % output signals - Params % parameter signals - Audio % auditory stream signals - Visual % visual stimuli elements - Events % event signals - UI % user interface signals - end - - methods - function this = Model(signet) - this.Time = signet.origin('t'); - this.Input = sig.Registry; - this.Output = sig.Registry; - this.Audio = audstream.Registry(96e3); - this.Visual = sig.Registry; - this.UI = sig.Registry; - end - end - -end - diff --git a/+exp/README.md b/+exp/README.md new file mode 100644 index 00000000..642783a7 --- /dev/null +++ b/+exp/README.md @@ -0,0 +1,53 @@ +## +Exp: +The +exp package contains classes and functions for the Rigbox Experiment framework. +The Experiment framework is for setting up and running stimulus-delivering experiments. The framework allows parameterizing individual experiments at a single-trial level. Visual and auditory stimuli can be controlled by experiment phases or by the Signals framework. Phases changes are managed by an event-handling system. + +## Contents: + +Below is a summery of files contained: + +### Experiment Classes +- `Experiment.m` - Base class for stimuli-delivering experiments +- `LIARExperiment.m` - Linear Input and Reward experiment +- `SignalsExp.m` - Trial-based Signals Experiments + +### Event Handlers +- `EventHandler.m` - Performs actions following an event +- `EventInfo.m` - Experimental event info base class +- `TrialEventInfo.m` - Provides information about a trial event +- `ThresholdEventInfo.m` - Provides information about a threshold reached +- `ResponseEventInfo.m` - Provides information about a subject's response + +### Event Actions +- `Action.m` - Base-class for actions used with an EventHandler +- `StartPhase.m` - Instruction to start a particular experiment phase +- `EndPhase.m` - Instruction to end a particular experiment phase +- `StartTrial.m` - Instruction to start a new trial in an experiment +- `EndTrial.m` - Instruction to end a new trial in an experiment +- `DeliverReward.m` - Delivers reward in an experiment +- `LoadSound.m` - Loads specified samples ready for playing +- `PlaySound.m` - Plays the currently loaded sound +- `StartServices.m` - Starts experiment services +- `StopServices.m` - Stops experiment services +- `TriggerLaser.m` - Triggers laser pulse in an experiment +- `RegisterNoGoResponse.m` - Register time threshold response +- `RegisterThresholdResponse.m` - Register the appropriate response +- `StartResponseFeedback.m` - Start appropriate feedback phase for response + +### Parameters +- `Parameters.m` - A store & methods for managing experiment parameters +- `ConditionServer.m` - Interface for provision of trial parameters +- `PresetConditionServer.m` - Provides preset trials from an array +- `inferParameters.m` - Infers the parameters required for Signals experiments +- `promptForParams.m` - +- `trialConditions.m` - Returns trial parameter Signals + +### Time-Samplers +- `TimeSampler.m` - Interface for generating times from some distribution +- `FixedTime.m` - Always generates a fixed time +- `UniformInterval.m` - A time sampled uniformly from an interval +- `ExponentialInterval.m` - A time sampled with a flat hazard function +- `configureSignalsExperiment.m` - + +### Subpackages +- `+test/` - Functions for testing and plotting Signals via the Command. diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index d6883ff7..6bda9553 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -1,74 +1,78 @@ classdef SignalsExp < handle - %EXP.SIGNALSEXP Base class for stimuli-delivering experiments - % The class defines a framework for event- and state-based experiments. - % Visual and auditory stimuli can be controlled by experiment phases. - % Phases changes are managed by an event-handling system. + %EXP.SIGNALSEXP Trial-based Signals Experiments + % TODO Document. The class defines a framework for running Signals + % experiment definition functions and provides trial event Signals + % along with trial conditions that change each trial. % % Part of Rigbox % 2012-11 CB created properties - %An array of event handlers. Each should specify the name of the - %event that activates it, callback functions to be executed when - %activated and an optional delay between the event and activation. - %They should be objects of class EventHandler. + % An array of event handlers. Each should specify the name of the + % event that activates it, callback functions to be executed when + % activated and an optional delay between the event and activation. + % They should be objects of class EventHandler. EventHandlers = exp.EventHandler.empty - %Timekeeper used by the experiment. Clocks return the current time. See - %the Clock class definition for more information. + % Timekeeper used by the experiment. Clocks return the current time. See + % the Clock class definition for more information. Clock = hw.ptb.Clock - %Key for terminating an experiment whilst running. Shoud be a - %Psychtoolbox keyscan code (see PTB KbName function). + % Key for terminating an experiment whilst running. Shoud be a + % Psychtoolbox keyscan code (see PTB KbName function). QuitKey = KbName('q') - PauseKey = KbName('esc') %Key for pausing an experiment + % Key for pausing an experiment + PauseKey = KbName('esc') - %String description of the type of experiment, to be saved into the - %block data field 'expType'. + % String description of the type of experiment, to be saved into the + % block data field 'expType'. Type = '' - %Reference for the rig that this experiment is being run on, to be - %saved into the block data field 'rigName'. + % Reference for the rig that this experiment is being run on, to be + % saved into the block data field 'rigName'. RigName - %Communcator object for sending signals updates to mc. Set by - %expServer + % Communcator object for sending signals updates to mc. Set by + % expServer Communicator = io.DummyCommunicator - %Delay (secs) before starting main experiment phase after experiment - %init phase has completed + % Delay (secs) before starting main experiment phase after experiment + % init phase has completed PreDelay = 0 - %Delay (secs) before beginning experiment cleanup phase after - %main experiment phase has completed (assuming an immediate abort - %wasn't requested). + % Delay (secs) before beginning experiment cleanup phase after + % main experiment phase has completed (assuming an immediate abort + % wasn't requested). PostDelay = 0 - %Flag indicating whether the experiment is paused + % Flag indicating whether the experiment is paused IsPaused = false - %Holds the wheel object, 'mouseInput' from the rig object. See also - %USERIG, HW.DAQROTARYENCODER + % Holds the wheel object, 'mouseInput' from the rig object. See also + % USERIG, HW.DAQROTARYENCODER Wheel - %Holds the object for interating with the lick detector. See also - %HW.DAQEDGECOUNTER + % Holds the object for interating with the lick detector. See also + % HW.DAQEDGECOUNTER LickDetector - %Holds the object for interating with the DAQ outputs (reward valve, - %etc.) See also HW.DAQCONTROLLER + % Holds the object for interating with the DAQ outputs (reward valve, + % etc.) See also HW.DAQCONTROLLER DaqController - %Get the handle to the PTB window opened by expServer + % Get the handle to the PTB window opened by expServer StimWindowPtr + % The layer textureId names mapped to their numerical GL texture ids TextureById + % A map of stimulus element layers whose keys are the entry names in + % the Visual StructRef object LayersByStim - %Occulus viewing model + % Occulus viewing model Occ Time @@ -83,23 +87,25 @@ Audio % = aud.AudioRegistry - %Holds the parameters structure for this experiment + % Holds the parameters structure for this experiment Params ParamsLog - %The bounds for the photodiode square + % The bounds for the photodiode square SyncBounds - %Sync colour cycle (usually [0, 255]) - cycles through these each - %time the screen flips. + % Sync colour cycle (usually [0, 255]) - cycles through these each + % time the screen flips. SyncColourCycle - %Index into SyncColourCycle for next sync colour + % Index into SyncColourCycle for next sync colour NextSyncIdx - %Alyx instance from client. See also SAVEDATA + % Alyx instance from client. See also SAVEDATA AlyxInstance = [] + + Debug matlab.lang.OnOffSwitchState = 'off' end properties (SetAccess = protected) @@ -113,13 +119,16 @@ %(i.e. strings) ActivePhases = {} - Listeners - - Net - SignalUpdates = struct('name', cell(500,1), 'value', cell(500,1), 'timestamp', cell(500,1)) NumSignalUpdates = 0 + % Flag indicating whether to continue in experiment loop + IsLooping = false + + GlobalPars + ConditionalPars + + ExpStop end properties (Access = protected) @@ -127,15 +136,24 @@ %are awaiting activation pending completion of their delay period. Pending - IsLooping = false %flag indicating whether to continue in experiment loop - AsyncFlipping = false StimWindowInvalid = false + + Listeners + + Net + + PauseTime end methods - function obj = SignalsExp(paramStruct, rig) + function obj = SignalsExp(paramStruct, rig, debug) + % TODO Move all rig related stuff out of constructor to useRig method. + % @body This will require a change to audstream.Registry: should work + % in a similar way to the visual stucture whereby the names of the + % devices are looked up at runtime. + if nargin > 2; obj.Debug = debug; end % Set debug mode clock = rig.clock; clockFun = @clock.now; obj.TextureById = containers.Map('KeyType', 'char', 'ValueType', 'uint32'); @@ -147,10 +165,12 @@ obj.Events = sig.Registry(clockFun); %% configure signals net = sig.Net; + net.Debug = obj.Debug; obj.Net = net; obj.Time = net.origin('t'); obj.Events.expStart = net.origin('expStart'); obj.Events.newTrial = net.origin('newTrial'); + % TODO Generalize inputs obj.Inputs.wheel = net.origin('wheel'); obj.Inputs.wheelMM = obj.Inputs.wheel.map(@... (x)obj.Wheel.MillimetresFactor*(x-obj.Wheel.ZeroOffset)).skipRepeats(); @@ -164,12 +184,13 @@ % start the first trial after expStart advanceTrial = net.origin('advanceTrial'); % configure parameters signal - globalPars = net.origin('globalPars'); - allCondPars = net.origin('condPars'); + obj.GlobalPars = net.origin('globalPars'); + obj.ConditionalPars = net.origin('condPars'); [obj.Params, hasNext, obj.Events.repeatNum] = exp.trialConditions(... - globalPars, allCondPars, advanceTrial); + obj.GlobalPars, obj.ConditionalPars, advanceTrial); obj.Events.trialNum = obj.Events.newTrial.scan(@plus, 0); % track trial number lastTrialOver = then(~hasNext, true); + obj.Events.expStop = lastTrialOver; % run experiment definition if ischar(paramStruct.defFunction) expDefFun = fileFunction(paramStruct.defFunction); @@ -178,15 +199,15 @@ expDefFun = paramStruct.defFunction; obj.Data.expDef = func2str(expDefFun); end - fprintf('takes %i args\n', nargout(expDefFun)); expDefFun(obj.Time, obj.Events, obj.Params, obj.Visual, obj.Inputs,... obj.Outputs, obj.Audio); % if user defined 'expStop' in their exp def, allow 'expStop' to also % take value at 'lastTrialOver', else just set to 'lastTrialOver' - if isfield(obj.Events, 'expStop') + obj.ExpStop = obj.Events.expStop; + if ~isequal(obj.Events.expStop, lastTrialOver) + obj.ExpStop = obj.Events.expStop; obj.Events.expStop = merge(obj.Events.expStop, lastTrialOver); - else - obj.Events.expStop = lastTrialOver; + entryAdded(obj.Events, 'expStop', obj.Events.expStop); end % listeners obj.Listeners = [ @@ -195,11 +216,13 @@ advanceTrial.map(true).keepWhen(hasNext).into(obj.Events.newTrial) %newTrial if more obj.Events.expStop.onValue(@(~)quit(obj))]; % initialise the parameter signals - globalPars.post(rmfield(globalStruct, 'defFunction')); - allCondPars.post(allCondStruct); + try + obj.GlobalPars.post(rmfield(globalStruct, 'defFunction')) + obj.ConditionalPars.post(allCondStruct) + catch ex + rethrow(obj.addErrorCause(ex)) + end %% data struct - -% obj.Params = obj.Params.map(@(v)v, [], @(n,s)sig.Logger([n '[L]'],s)); %initialise stim window frame times array, large enough for ~2 hours obj.Data.stimWindowUpdateTimes = zeros(60*60*60*2, 1); obj.Data.stimWindowRenderTimes = zeros(60*60*60*2, 1); @@ -219,7 +242,8 @@ function useRig(obj, rig) if isfield(rig, 'screens') obj.Occ.screens = rig.screens; else - warning('squeak:hw', 'No screen configuration specified. Visual locations will be wrong.'); + warning('Rigbox:exp:SignalsExp:NoScreenConfig', ... + 'No screen configuration specified. Visual locations will be wrong.'); end obj.DaqController = rig.daqController; obj.Wheel = rig.mouseInput; @@ -333,9 +357,13 @@ function addEventHandler(obj, handler, varargin) % location to save the data into. If REF is an empty, i.e. [], the % data won't be saved. - if ~isempty(ref) - %ensure experiment ref exists - assert(dat.expExists(ref), 'Experiment ref ''%s'' does not exist', ref); + % Ensure experiment ref exists + if ~isempty(ref) && ~dat.expExists(ref) + % If in debug mode, throw warning, otherwise throw as error + % TODO Propogate debug behaviour to exp.Experiment + id = 'Rigbox:exp:SignalsExp:experimenDoesNotExist'; + msg = 'Experiment ref ''%s'' does not exist'; + iff(obj.Debug, @() warning(id,msg,ref), @() error(id,msg,ref)) end %do initialisation @@ -349,7 +377,12 @@ function addEventHandler(obj, handler, varargin) %set pending handler to begin the experiment 'PreDelay' secs from now start = exp.EventHandler('experimentInit', exp.StartPhase('experiment')); - start.addCallback(@(varargin) obj.Events.expStart.post(ref)); + + % Add callback to update Time is necessary + start.addCallback(... + @(~,t)iff(obj.Time.Node.CurrValue, [], @()obj.Time.post(t))); + % Add callback to update expStart + start.addCallback(@(varargin)obj.Events.expStart.post(ref)); obj.Pending = dueHandlerInfo(obj, start, initInfo, obj.Clock.now + obj.PreDelay); %refresh the stimulus window @@ -360,7 +393,7 @@ function addEventHandler(obj, handler, varargin) mainLoop(obj); %post comms notification with event name and time - if isempty(obj.AlyxInstance) + if isempty(obj.AlyxInstance) || ~obj.AlyxInstance.IsLoggedIn post(obj, 'AlyxRequest', obj.Data.expRef); %request token from client pause(0.2) end @@ -379,15 +412,17 @@ function addEventHandler(obj, handler, varargin) saveData(obj); %save the data end catch ex + obj.IsLooping = false; %mark that an exception occured in the block data, then save obj.Data.endStatus = 'exception'; obj.Data.exceptionMessage = ex.message; + obj.cleanup() % TODO Make cleanup more robust to error states if ~isempty(ref) saveData(obj); %save the data end ensureWindowReady(obj); % complete any outstanding refresh %rethrow the exception - rethrow(ex) + rethrow(obj.addErrorCause(ex)) end end @@ -411,15 +446,12 @@ function log(obj, field, value) function quit(obj, immediately) % if the experiment was stopped via 'mc' or 'q' key if isempty(obj.Events.expStop.Node.CurrValue) - % re-assign 'expStop' as an origin signal and post to it - obj.Events.expStop = obj.Net.origin('expStop'); - obj.Events.expStop.post(true); - end - %stop delay timers. todo: need to use a less global tag - tmrs = timerfind('Tag', 'sig.delay'); - if ~isempty(tmrs) - stop(tmrs); - delete(tmrs); + stopNode = obj.ExpStop.Node; + if isempty(stopNode.CurrValue) + % sneak in and update node value + affectedIdxs = submit(obj.Net.Id, stopNode.Id, true); + applyNodes(obj.Net.Id, affectedIdxs); + end end % set any pending handlers inactive @@ -468,6 +500,22 @@ function stopLooping(varargin) end end + function pause(obj) + % In the future this will be handled by exp.Experiment + if ~obj.IsPaused + obj.PauseTime = obj.Clock.now; + obj.abortPendingHandlers() + obj.IsPaused = true; + end + end + + function resume(obj) + breakLength = obj.Clock.now - obj.PauseTime; + newTimes = num2cell([obj.Net.Schedule.when] + breakLength); + [obj.Net.Schedule.when] = deal(newTimes{:}); + obj.IsPaused = false; + end + function ensureWindowReady(obj) % complete any outstanding asynchronous flip if obj.AsyncFlipping @@ -517,44 +565,40 @@ function loadVisual(obj, name) obj.Listeners = [obj.Listeners layersSig.onValue(fun.partial(@obj.newLayerValues, name))]; newLayerValues(obj, name, layersSig.Node.CurrValue); - -% %% load textures -% layerData = obj.LayersByStim(name); -% Screen('BeginOpenGL', win); -% try -% for ii = 1:numel(layerData) -% id = layerData(ii).textureId; -% if ~obj.TextureById.isKey(id) -% obj.TextureById(id) = ... -% vis.loadLayerTextures(layerData(ii)); -% end -% end -% catch glEx -% Screen('EndOpenGL', win); -% rethrow(glEx); -% end -% Screen('EndOpenGL', win); end function newLayerValues(obj, name, val) -% fprintf('new layer value for %s\n', name); -% show = [val.show] + % NEWLAYERVALUES Callback for layer updates for window invalidation + % When a visual element's layers change, store the new values and + % check whether stim window needs redrawing. The following two + % conditions invalidate the stim window: + % 1. Any of the layers have show == true + % 2. Show has changed from true to false for any layer + % + % Inputs: + % name (char) : The name of the stimulus (entry name in + % obj.Visual StructRef) + % val (struct) : A struct array of layers with new values + % + % See also LOADVISUAL, VIS.DRAW, VIS.EMPTYLAYER if isKey(obj.LayersByStim, name) + % If layer(s) already loaded, check if any show == true previously prev = obj.LayersByStim(name); prevshow = any([prev.show]); - else + else % Otherwise it clearly wasn't previously shown prevshow = false; end - obj.LayersByStim(name) = val; - + obj.LayersByStim(name) = val; % Store new layer(s) value by element name + % Determine whether new value requires screen redraw if any([val.show]) || prevshow obj.StimWindowInvalid = true; end - end function delete(obj) - disp('delete exp.SqueakExp'); + if obj.Debug + disp('delete exp.SignalsExp'); + end obj.Net.delete(); end end @@ -665,6 +709,12 @@ function mainLoop(obj) t = obj.Clock.now; % begin the loop while obj.IsLooping + %% Check whether we're paused + while obj.IsPaused + drawnow + pause(0.25); + checkInput(obj); + end %% create a list of handlers that have become due dueIdx = find([obj.Pending.dueTime] <= now(obj.Clock)); ndue = length(dueIdx); @@ -687,7 +737,7 @@ function mainLoop(obj) %% signalling % tic - wx = readAbsolutePosition(obj.Wheel); + wx = obj.Wheel.readAbsolutePosition(); post(obj.Inputs.wheel, wx); if ~isempty(obj.LickDetector) % read and log the current lick count @@ -771,11 +821,15 @@ function checkInput(obj) pause(obj); end else -% key = keysPressed(find(keysPressed~=obj.QuitKey&... -% keysPressed~=obj.PauseKey,1,'first')); + % Post key presses to inputs.keyboard signal key = KbName(keysPressed); - if ~isempty(key) - post(obj.Inputs.keyboard, key(1)); + if ~obj.IsPaused && ~isempty(key) + if ischar(key) % Post single key press + post(obj.Inputs.keyboard, key); + else % Post each key press in order + [~, I] = sort(keysPressed(keysPressed > 0)); + cellfun(@(k)obj.Inputs.keyboard.post(k), key(I)); + end end end end @@ -870,66 +924,77 @@ function activateEventHandler(obj, handler, eventInfo, dueTime) end end + function ex = addErrorCause(obj, ex) + sigExId = cellfun(@(e) isa(e,'sig.Exception'), ex.cause); + if any(sigExId) && obj.Net.Debug + nodeid = ex.cause{sigExId}.Node; + expdef = iff(ischar(obj.Data.expDef), ... + obj.Data.expDef, @()which(func2str(obj.Data.expDef))); + ex = ex.addCause(MException(... + 'Rigbox:exp:SignalsExp:expDefError', ... + 'Error in %s (line %d)\n%s', ... + expdef, obj.Net.NodeLine(nodeid), obj.Net.NodeName(nodeid))); + end + end + function saveData(obj) % save the data to the appropriate locations indicated by expRef savepaths = dat.expFilePath(obj.Data.expRef, 'block'); superSave(savepaths, struct('block', obj.Data)); - [subject, ~, ~] = dat.parseExpRef(obj.Data.expRef); + subject = dat.parseExpRef(obj.Data.expRef); - % if this is a 'ChoiceWorld' experiment, let's save out for - % relevant data for basic behavioural analysis and register them to - % Alyx - if contains(lower(obj.Data.expDef), 'choiceworld') ... - && ~strcmp(subject, 'default') && isfield(obj.Data, 'events') ... + % Save out for relevant data for basic behavioural analysis and + % register them to Alyx. + if ~strcmp(subject, 'default') && isfield(obj.Data, 'events') ... && ~strcmp(obj.Data.endStatus,'aborted') try fullpath = alf.block2ALF(obj.Data); obj.AlyxInstance.registerFile(fullpath); catch ex + % If Alyx URL not set, simply return + if isempty(getOr(dat.paths, 'databaseURL')); return; end + % Otherwise throw warning and continue registration process warning(ex.identifier, 'Failed to register alf files: %s.', ex.message); end end if isempty(obj.AlyxInstance) - warning('No Alyx token set'); + warning('Rigbox:exp:SignalsExp:noTokenSet', 'No Alyx token set'); else try subject = dat.parseExpRef(obj.Data.expRef); if strcmp(subject, 'default'); return; end % Register saved files obj.AlyxInstance.registerFile(savepaths{end}); -% obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... -% {subject, expDate, seq}, 'Block', []); % Save the session end time url = obj.AlyxInstance.SessionURL; - if ~isempty(url) - numCorrect = []; - if isfield(obj.Data, 'events') - numTrials = length(obj.Data.events.endTrialValues); - if isfield(obj.Data.events, 'feedbackValues') - numCorrect = sum(obj.Data.events.feedbackValues == 1); - end - else - numTrials = 0; - numCorrect = 0; + if isempty(url) + % Infer from date session and retrieve using expFilePath + url = getOr(obj.AlyxInstance.getSessions(obj.Data.expRef), 'url'); + assert(~isempty(url), 'Failed to determine session url') + end + numCorrect = []; + if isfield(obj.Data, 'events') + numTrials = length(obj.Data.events.endTrialValues); + if isfield(obj.Data.events, 'feedbackValues') + numCorrect = sum(obj.Data.events.feedbackValues == 1); end - % Update Alyx session with end time, trial counts and water tye - sessionData = struct('end_time', obj.AlyxInstance.datestr(now)); - if ~isempty(numTrials); sessionData.n_trials = numTrials; end - if ~isempty(numCorrect); sessionData.n_correct_trials = numCorrect; end - obj.AlyxInstance.postData(url, sessionData, 'patch'); else - % Retrieve session from endpoint - % subsessions = obj.AlyxInstance.getData(... - % sprintf('sessions?type=Experiment&subject=%s&number=%i', subject, seq)); + numTrials = 0; + numCorrect = 0; end + % Update Alyx session with end time, trial counts and water tye + sessionData = struct('end_time', obj.AlyxInstance.datestr(now)); + if ~isempty(numTrials); sessionData.n_trials = numTrials; end + if ~isempty(numCorrect); sessionData.n_correct_trials = numCorrect; end + obj.AlyxInstance.postData(url, sessionData, 'patch'); catch ex warning(ex.identifier, 'Failed to register files to Alyx: %s', ex.message); end % Post water to Alyx try valve_controller = obj.DaqController.SignalGenerators(strcmp(obj.DaqController.ChannelNames,'rewardValve')); - type = iff(isprop(valve_controller, 'WaterType'), valve_controller.WaterType, 'Water'); + type = pick(valve_controller, 'WaterType', 'def', 'Water'); if isfield(obj.Data.outputs, 'rewardValues') amount = sum(obj.Data.outputs.rewardValues)*0.001; else diff --git a/+exp/inferParameters.m b/+exp/inferParameters.m index 275c964f..032108bd 100644 --- a/+exp/inferParameters.m +++ b/+exp/inferParameters.m @@ -1,5 +1,5 @@ function parsStruct = inferParameters(expdef) -%EXP.INFERPARAMETERS Infers the parameters required for experiment +%EXP.INFERPARAMETERS Infers the parameters required for Signals experiments % Detailed explanation goes here % create some signals just to pass to the definition function and track diff --git a/+exp/runTrials.m b/+exp/runTrials.m deleted file mode 100644 index 6e1e0c09..00000000 --- a/+exp/runTrials.m +++ /dev/null @@ -1,109 +0,0 @@ -function [log, audio, timestamps] = runTrials(expdef, parsData) -%RUNTRIALS Summary of this function goes here -% Detailed explanation goes here - -if isnumeric(parsData) - % parsData is number of trials, so turn number of trials into struct - % array with no fields but 'parsData' elements - globalStruct = struct; - allCondStruct = rmfield(struct('dummy', cell(1, parsData)), 'dummy'); -else - if ~isa(parsData, 'exp.Parameters') - parsData = exp.Parameters(parsData); - end - [~, globalStruct, allCondStruct] = parsData.toConditionServer(); -end - -globalPars = sig.Signal('globalPars'); -allCondPars = sig.Signal('condPars'); - -t = sig.Signal('t'); -evts = sig.Registry; -stim = Bag; -audio = aud.AudioRegistry; -inputs = sig.Registry; -inputs.wheel = t.do(@GetMouse); -inputs.keys = t.do(@KbQueueCheck).skipRepeats(); -inputs.wheel.Name = 'wheel'; - -evts.expStart = sig.Signal('expStart'); - -advanceTrial = evts.expStart.then(true); -advanceTrial.Name = 'advanceTrial'; -[pars, hasNext, evts.repeat] = exp.presetCondServer(globalPars, allCondPars, advanceTrial); -pars.Name = 'pars'; -evts.newTrial = sig.Signal('newTrial'); - -%% execute the definition function -defargs = {t, evts, pars, stim, audio, inputs}; -expdef(defargs{1:nargin(expdef)}); - -%% construct stuff from definition events -evts.trialNum = evts.newTrial.sumOverTime(); % track trial number -evts.expStop = delay(then(~hasNext, true), 1); -advanceAtEnd = evts.endTrial.into(advanceTrial); -newTrialIfMore = hasNext.then(true).into(evts.newTrial); - -%% start the experiment -% some listeners -disptrial = evts.trialNum.printOnValue('trial %s started\n'); -disprepeat = evts.repeat.printOnValue('repeat %s\n'); -parslist = pars.targetAzimuth.printOnValue('azi=%s\n'); -fblist = evts.feedback.printOnValue('feedback= %s\n'); -listeners = [advanceAtEnd newTrialIfMore disptrial disprepeat parslist fblist]; - -% cleanup -expoverlist = evts.expStop.onValue(@cleanup); -expabortedlist = inputs.keys.then(true).onValue(@cleanup); -listeners = [listeners expoverlist expabortedlist]; -releaseKeyboard = onCleanup(@KbQueueRelease); -cleanupListeners = onCleanup(@()delete(listeners)); - -% keyboard listener -KbQueueCreate(); -KbQueueStart(); - -% post parameter data -globalPars.post(globalStruct); -allCondPars.post(allCondStruct); -inputs.wheel.post(0); - -% make it start -clockZeroTime = GetSecs; -expStartDateTime = now; -evts.expStart.post(true); - -timestamps = zeros(1, 320000); -ts = 0; - -running = true; - -while running - t.post(GetSecs); - ts = ts + 1; - timestamps(ts) = GetSecs; - drawnow; % this also allows timers etc to run -end - -expStopDateTime = now; -%% assemble the log -log = struct; -log.startDateTime = expStartDateTime; -log.startDateTime = datestr(log.startDateTime); -%events -log.events = logs(evts, clockZeroTime); -%inputs -log.inputs = logs(inputs, clockZeroTime); -%audio -log.audio = logs(audio, clockZeroTime); -log.endDateTime = expStopDateTime; -log.endDateTime = datestr(log.endDateTime); -timestamps(ts+1:end) = []; - - function cleanup(~) - disp('experiment over'); - running = false; - end - -end - diff --git a/+exp/trialConditions.m b/+exp/trialConditions.m index 8e1c7a73..7b7688f7 100644 --- a/+exp/trialConditions.m +++ b/+exp/trialConditions.m @@ -1,28 +1,62 @@ function [pars, hasNext, repeatNum] = trialConditions(globalPars,... - allCondPars, advanceTrial) -%exp.trialConditions Summary of this function goes here -% Detailed explanation goes here + allCondPars, advanceTrial, reset) +%EXP.TRIALCONDITIONS Returns trial parameter Signals +% An implementation of the behaviour of the exp.ConditionServer class in +% Signals; returns a subscriptable trial parameters signal that updates +% based on the input signals. +% +% Inputs: +% globalPars (sig.Signal): holds a struct of parameters intended to be +% independent of advanceTrial. +% allCondPars (sig.Signal): holds a non-scalar struct of parameters +% which are to be indexed based on advanceTrial. +% advanceTrial (sig.Signal): when true will cause pars to update to the +% next set of trial parameters until all have been selected. +% reset (sig.Signal|numerical): the seed for the conditional parameter +% indexer, i.e. the value to count from when advanceTrial updates. +% Default = 0. +% +% Outputs: +% pars (sig.Signal): holds a subscriptable scalar struct of all +% parameters for a given trial; the combined values of globalPars and +% allCondPars. +% hasNext (sig.Signal): updates to true so long as not all trial +% conditions have been used. +% repeatNum (sig.Signal): holds the number of times in a row the +% current value of pars as occured. Counts up so long as advanceTrial +% is false. +% +% Example: +% % Use with exp.Parameters class +% [globalPars, allCondPars, advanceTrial] = sig.test.create(); +% [~, globalStruct, allCondStruct] = toConditionServer(... +% exp.Parameters(exp.choiceWorldParams)); +% p = trialConditions(globalPars, allCondPars, advanceTrial) +% post(globalPars, globalStruct); post(allCondPars, allCondStruct) +% advanceTrial.post(true) +% +% See also EXP.CONDITIONSERVER, EXP.PARAMETERS % a new 1 (or true) in nextTrial means move on to the next condition, % whereas a 0 (or false) means repeat this condition +if nargin < 4 + reset = 0; +end -nConds = allCondPars.map(@numel); +nConds = allCondPars.map(@numel); % The total number of conditions +nextCondNum = advanceTrial.scan(@plus, reset); % This counter can go over nConds +hasNext = nextCondNum <= nConds; % This ensures pars can't go past nConds -nextCondNum = advanceTrial.scan(@plus, 0); % this counter can go over nConds -hasNext = nextCondNum <= nConds; -% this counter cant go past nConds % todo: current hack using identity to delay advanceTrial relative to hasNext repeatLastTrial = advanceTrial.identity().keepWhen(hasNext); -condIdx = repeatLastTrial.scan(@plus, 0); +condIdx = repeatLastTrial.scan(@plus, reset); % This counter can't go past nConds condIdx = condIdx.keepWhen(condIdx > 0); condIdx.Name = 'condIdx'; repeatNum = repeatLastTrial.scan(@sig.scan.lastTrue, 0) + 1; repeatNum.Name = 'repeatNum'; -condPar = allCondPars(condIdx); - +condPar = allCondPars(condIdx); % Index our conditions struct array +% pars updates whenever either conditional or global parameters are +% updated. Global and consitional parameters are merged into one struct pars = globalPars.merge(condPar).scan(@mergeStruct, struct).subscriptable(); pars.Name = 'pars'; - -end - diff --git a/+hw/CursorPosition.m b/+hw/CursorPosition.m index bf20e590..a249900c 100644 --- a/+hw/CursorPosition.m +++ b/+hw/CursorPosition.m @@ -1,19 +1,26 @@ classdef CursorPosition < hw.PositionSensor %HW.CURSORPOSITION Tracks mouse cursor position along a direction % Returns the current mouse cursor position as projected - % along ProjectionDir + % along ProjectionDir % % Part of Rigbox % 2012-10 CB created properties - ProjectionDir = 0 %direction in radians along which position is tracked - Window = [] %window to find mouse cursor position in + % Direction in radians along which position is tracked + ProjectionDir = 0 + % Window to find mouse cursor position in + Window hw.Window = hw.ptb.Window.empty + % + FixedPosition matlab.lang.OnOffSwitchState = 'on' + % + GetMouseFcn = @GetMouse end properties (Access = protected) LastAbsPosition = 0 + LastInBounds = 0 end methods %(Access = protected) @@ -30,8 +37,6 @@ % ptbScreen = max(Screen('Screens')); % screenBounds = Screen('Rect', ptbScreen); % end -% ptbScreen = max(Screen('Screens')); -% screenBounds = Screen('Rect', ptbScreen); % [centreX, centreY] = RectCenter(screenBounds); % cursor reset/offset position - somewhere to leave enough space for @@ -53,6 +58,44 @@ x = obj.LastAbsPosition + dx; obj.LastAbsPosition = x; end + + function [x, time] = getMouse(obj) + % GETMOUSE Return mouse x co-ordinate over stimulus window only + % TODO Make into hw class + + % read the current cursor position + [mx, my] = GetMouse(); + time = obj.Clock.now; + + if ~isempty(obj.Window) + bounds = obj.Window.OpenBounds; + withinBounds = ... + mx >= bounds(1) && ... + mx <= bounds(1) + bounds(3) && ... + my >= bounds(2) && ... + my <= bounds(2) + bounds(4); + [offsetX, offsetY] = RectCenter(bounds); + else + withinBounds = true; + % cursor reset/offset position - somewhere to leave enough space for + % movements of the cursor before it is set back there + [offsetX, offsetY] = deal(300); + end + + % set cursor to offset position + if obj.FixedPosition, SetMouse(offsetX, offsetY); end + + % work out projection + x = mx*cos(obj.ProjectionDir) + my*sin(obj.ProjectionDir); + dx = (x - obj.LastAbsPosition); + obj.LastAbsPosition = x; + if withinBounds + x = obj.LastInBounds + dx; + obj.LastInBounds = x; + else + x = obj.LastInBounds; + end + end end end \ No newline at end of file diff --git a/+hw/DataLogging.m b/+hw/DataLogging.m index ba5946ad..2b35dfbb 100644 --- a/+hw/DataLogging.m +++ b/+hw/DataLogging.m @@ -69,5 +69,4 @@ function logSamples(obj, values, times) end end -end - +end \ No newline at end of file diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 2bb758bf..2f522974 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -71,13 +71,13 @@ DaqVendor = 'ni' % Device ID can be found with daq.getDevices() DaqIds = 'Dev1' - % rate at which daq aquires data in Hz, see Rate + % Rate at which daq aquires data in Hz, see Rate DaqSampleRate = 1000 - % determines the number of data samples to be processed each time, + % Determines the number of data samples to be processed each time, % see Timeline.process(), constructor and % NotifyWhenDataAvailableExceeds DaqSamplesPerNotify - % array of output classes, defining any signals you desire to be + % Array of output classes, defining any signals you desire to be % sent from the daq. See Also HW.TLOUTPUT, HW.TLOUTPUTCLOCK Outputs = hw.TLOutputChrono % All configured inputs. @@ -87,25 +87,25 @@ 'measurement', 'Voltage',... 'terminalConfig', 'SingleEnded',... 'axesScale', 1) % multiplicative vertical scaling for when live plotting the input - % array of inputs to record while tl is running + % Array of inputs to record while tl is running UseInputs = {'chrono'} - % currently pauses for at least 2 secs as 'hack' before stopping + % Currently pauses for at least 2 secs as 'hack' before stopping % main DAQ session to allow StopDelay = 2 - % expected experiment time so data structure is initialised to + % Expected experiment time so data structure is initialised to % sensible size (in secs) MaxExpectedDuration = 2*60*60 - % default data type for the acquired data array (i.e. + % Default data type for the acquired data array (i.e. % Data.rawDAQData) AquiredDataType = 'double' % If true, timeline is started by default (otherwise can be toggled % with the t key in expServer) UseTimeline matlab.lang.OnOffSwitchState = 'off' - % if true the data are plotted as the data are aquired + % If true the data are plotted as the data are aquired LivePlot matlab.lang.OnOffSwitchState = 'off' - % figure position in normalized units, default is full screen + % Figure position in normalized units, default is full screen FigureScale = [0 0 1 1] - % if true the data buffer is written to disk as they're aquired NB: + % If true the data buffer is written to disk as they're aquired NB: % in the future this will happen by default WriteBufferToDisk matlab.lang.OnOffSwitchState = 'off' end @@ -120,17 +120,17 @@ end properties (Transient, Access = protected) - % holds the listener for 'DataAvailable', see DataAvailable and + % Holds the listener for 'DataAvailable', see DataAvailable and % Timeline.process() Listener - % the last timestamp returned from the daq during the DataAvailable + % The last timestamp returned from the daq during the DataAvailable % event. Used to check sampling continuity, see tl.process() LastTimestamp - % the expRef string. See tl.start() + % The expRef string. See tl.start() Ref - % a struct contraining the Alyx token, user and url for ile - % registration. See tl.start() - AlyxInstance + % An Alyx object instance used for file registration. See + % tl.start() + AlyxInstance Alyx % A structure containing timeline data Data % A figure handle for plotting the aquired data as it's processed diff --git a/+srv/BasicUDPService.m b/+srv/BasicUDPService.m index 91779d86..72c93715 100644 --- a/+srv/BasicUDPService.m +++ b/+srv/BasicUDPService.m @@ -25,6 +25,8 @@ % @(srv, evt)processMessage(srv, evt)); % Add a listener to do % something when a message is received. % + % NB: Requires the Instrument Control Toolbox + % % See also SRV.PRIMITIVEUDPSERVICE, UDP. % % Part of Rigbox diff --git a/+srv/RemoteTLService.m b/+srv/RemoteTLService.m index 69014fc9..c1d8d17b 100644 --- a/+srv/RemoteTLService.m +++ b/+srv/RemoteTLService.m @@ -25,6 +25,8 @@ % @(srv, evt)processMessage(srv, evt)); % Add a listener to do % something when a message is received. % + % NB: Requires the Instrument Control Toolbox + % % See also SRV.PRIMITIVEUDPSERVICE, UDP. % % Part of Rigbox diff --git a/+srv/StimulusControl.m b/+srv/StimulusControl.m index 5e43489c..75524119 100644 --- a/+srv/StimulusControl.m +++ b/+srv/StimulusControl.m @@ -178,7 +178,7 @@ function delete(obj) end end - methods %(Access = protected) %TODO Check everything works as protected + methods (Access = protected) function b = connected(obj) b = ~isempty(obj.Socket) && obj.Socket.isOpen(); end @@ -288,7 +288,7 @@ function send(obj, id, data) methods (Static) function errorOnFail(r) if iscell(r) && strcmp(r{1}, 'fail') - error(r{3}); + error(r{3:end}) end end end diff --git a/+srv/expServer.m b/+srv/expServer.m index b41ef838..5f70620f 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -43,6 +43,8 @@ function expServer(useTimelineOverride, bgColour) timelineToggleKey = KbName('t'); toggleBackground = KbName('b'); rewardId = 1; +% Function for constructing a full ID for warnings and errors +fullID = @(id) strjoin([{'Rigbox:srv:expServer'}, ensureCell(id)],':'); %% Initialisation % Pull latest changes from remote @@ -56,7 +58,7 @@ function expServer(useTimelineOverride, bgColour) required = {'stimWindow', 'timeline', 'daqController'}; present = isfield(iff(isempty(rig), struct, rig), required); if ~all(present) - error('rigbox:srv:expServer:missingHardware', ['Rig''s ''hardware.mat'''... + error(fullID('missingHardware'), ['Rig''s ''hardware.mat'''... ' file not set up correctly. The following objects are missing:\n\r%s'],... strjoin(required(~present), '\n')) end @@ -84,6 +86,7 @@ function expServer(useTimelineOverride, bgColour) @() delete(listener),... @() rig.stimWindow.close(),... @() aud.close(rig.audio),... + @() rig.scale.cleanup() })); % OpenGL @@ -235,7 +238,7 @@ function handleMessage(id, data, host) end else log('Failed because experiment ref ''%s'' does not exist', expRef); - communicator.send(id, {'fail', expRef,... + communicator.send(id, {'fail', expRef, fullID('expRefNotFound') ... sprintf('Experiment ref ''%s'' does not exist', expRef)}); end case 'quit' @@ -337,7 +340,6 @@ function calibrateWaterDelivery() ul = [calibration.volumeMicroLitres]; log('Delivered volumes ranged from %.1ful to %.1ful', min(ul), max(ul)); - % rigData = load(fullfile(pick(dat.paths, 'rigConfig'), 'hardware.mat')); rigHwFile = fullfile(pick(dat.paths, 'rigConfig'), 'hardware.mat'); save(rigHwFile, 'daqController', '-append'); diff --git a/+srv/stimulusControllers.m b/+srv/stimulusControllers.m index 0ac89a83..deea65b9 100644 --- a/+srv/stimulusControllers.m +++ b/+srv/stimulusControllers.m @@ -1,6 +1,26 @@ function sc = stimulusControllers %SRV.STIMULUSCONTROLLERS Load all configured remote stimulus controllers -% TODO. See also SRV.STIMULUSCONTROL. +% Loads the remote rigs available to mc. The configured controllers are +% expected to be an array of srv.StimulusControl objects named +% 'stimulusControllers', loaded from a file called 'remote.mat' in the +% paths globalConfig directory. The list is returned ordered +% alphabetically by the Name property. +% +% Output: +% An array of srv.StimulusControl objects +% +% Examples: +% % Save a couple of configurations for loading with this function +% stimulusControllers = [ +% srv.StimulusControl.create('BigRig', 'ws://desktop-187'), +% srv.StimulusControl.create('TestRig')]; +% configDir = getOr(dat.paths, 'globalConfig'); +% save(fullfile(configDir, 'remote.mat'), 'stimulusControllers') +% +% % Load the stimulus controllers from file +% sc = srv.stimulusControllers; +% +% See also SRV.STIMULUSCONTROL, EUI.MCONTROL % % Part of Rigbox diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..2eb662c6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,31 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. Feel free to paste in the error that was printed to the MATLAB command window. + +**To Reproduce** +Steps to reproduce the behavior. Be as specific as possible. Does the error always occur? Are there similar steps that don't produce the error, or is it specific to a particular experiment? + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If GUI related, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - Code version (e.g. `2.3.1`) - you can find this in the `CHANGELOG.md` file, or provide the Git commit info by pasting the output of running `git log -1` in Git Bash (`git.runCmd('git log -1')` from MATLAB) + - MATLAB version - run `ver` in the MATLAB command prompt and paste the output here. + +**Additional context** +Try running the Rigbox tests and pasting the report here. +``` +cd tests % cd into tests folder in Rigbox root directory +runall(1); +``` diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..11fc491e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/config.yml b/.github/config.yml index 3cec0564..88f4a325 100644 --- a/.github/config.yml +++ b/.github/config.yml @@ -6,4 +6,4 @@ todo: caseSensitive: false label: true reopenClosed: true - exclude: null + exclude: '.*.html$' diff --git a/CHANGELOG.md b/CHANGELOG.md index f963dd47..eecaa7fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,32 @@ Starting after Rigbox 2.2.0, this file contains a curated, chronologically ordered list of notable changes made to the master branch. Each bullet point in the list is followed by the accompanying commit hash, and the date of the commit. This changelog is based on [keep a changelog](https://keepachangelog.com) -## [Most Recent Commits](https://github.com/cortex-lab/Rigbox/commits/master) 2.4.0 +## [Most Recent Commits](https://github.com/cortex-lab/Rigbox/commits/master) 2.5.0 + +- new alyx instance now requested robustly when not logged in during SignalsExp +- expStop event now logged has missing value when last trial over, even when expStop defined in def fun +- tests for SignalsExp class +- alyx warnings not thrown when database url not defined `b023187`, `cbad678` +- new SignalsExp test GUI has numerous bugfixes and allows you to view exp panel `ad52845` 2019-11-14 +- exp.trialConditions allows trial reset, similar to ConditionServer `62eb9ac` 2019-11-14 +- fix for catastrophic crash when stimulus layers contain empty values +- time signal now always updates before expStart event `cab5a2f` 2019-11-01 +- ability to hide event updates in the ExpPanel `fef6ac2` 2019-11-14 +- updates to documentation including folder READMEs and Contents files `07cb30e`, `d2b2189`, `3f3f869` +- added utils for changing scale port and audio device `139d770` 2019-11-22 +- bugfix for failing to save signals on error `d6d9289` 2019-11-24 +- cleaned up older code `b89a0c1`, `d6b23c1` +- scale port cleaned up upon errors `f19cec4` 2019-11-27 +- added flags to addRigboxPaths `10dc661` 2020-01-17 +- improvements to experiment panels including ability to hide info fields `169fbb4` 2019-11-27 +- added guide to creating custom ExpPanels `90294dd` 2019-12-18 +- correct behaviour when listening to already running experiments `32a2a17` 2019-12-18 +- added support for remote error ids in srv.StimulusControl `9d31eea` 2019-11-27 +- added tests for eui.ExpPanel `572463c` 2020-01-28 +- added tests for *paramProfile functions + no error when saving into new repo `72b04fa` 2020-01-30 +- added FormatLabels flag to eui.SignalsExpPanel `c5794a8` 2020-02-03 + +## [2.4.1] - patch to readme linking to most up-to-date documentation `4ff1a21` 2019-09-16 - updates to `+git` package and its tests `5841dd6` 2019-09-24 @@ -18,9 +43,10 @@ Starting after Rigbox 2.2.0, this file contains a curated, chronologically order - better organization of expServer `f32a0fe` 2019-10-02 - bug fix for rounding negative numbers in AlyxPanel `31641f1` 2019-10-17 - stricter and more accurate tolerance in AlyxPanel_test `31641f1` 2019-10-17 +- added tests for dat.mpepMessageParse and tl.bindMpepServer `bd15b95` 2019-10-21 +- HOTFIX to error when plotting supressed in Window calibrate `7d6b601` 2019-11-15 - updates to alyx-matlab submodule 2019-11-02 - ## [2.3.1](https://github.com/cortex-lab/Rigbox/releases/tag/2.3.1) - patch in alyx-matlab submodule 2019-07-25 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be1e84aa..26077f47 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -When contributing to this repository, please first discuss the change you wish to make via creation of a [github issue](https://github.com/cortex-lab/Rigbox/issues) (preferred), or email with the [project maintainers](#project-maintainers). The purpose of this document is NOT meant to overwhelm potential contributors; rather, it is to establish a protcol for the project maintainers to follow when they are reviewing new code. Contributors should not feel like they must adhere to every point in this document without feedback or advice before submitting a pull request: contributors should feel at ease submitting pull requests and using this document as a reference, and it is the project maintainers' roles to work with contributors to ensure new code adheres to the guidelines defined in this document, including the [Code of Conduct](#code-of-conduct). +When contributing to this repository, please first discuss the change you wish to make via creation of a [github issue](https://github.com/cortex-lab/Rigbox/issues) (preferred), or email with the [project maintainers](#project-maintainers). Contributors should feel at ease submitting pull requests and using this document as a reference, and it is the project maintainers' roles to work with contributors to ensure new code adheres to the guidelines defined in this document, including the [Code of Conduct](#code-of-conduct). -For contributing new code to this repository, we roughly follow a [gitflow workflow](https://nvie.com/posts/a-successful-git-branching-model). We also support [forking workflows](https://www.atlassian.com/git/tutorials/comparing-workflows/forking-workflow) for contributors who wish to fork this repository and maintain their own local versions. +For contributing new code to this repository, we roughly follow a [git feature branch workflow](https://nvie.com/posts/a-successful-git-branching-model). We also support [forking workflows](https://www.atlassian.com/git/tutorials/comparing-workflows/forking-workflow) for contributors who wish to fork this repository and maintain their own local versions. Rigbox relies on the [signals](https://github.com/cortex-lab/signals) and [alyx-matlab](https://github.com/cortex-lab/alyx-matlab) repositories as submodules for designing and running behavioral tasks, and communicating with an [Alyx](https://github.com/cortex-lab/alyx) database, respectively. For contributors who are unfamiliar with repositories with submodules, please read this helpful [blog post.](https://github.blog/2016-02-01-working-with-submodules/) @@ -8,7 +8,7 @@ Rigbox relies on the [signals](https://github.com/cortex-lab/signals) and [alyx- Following the gitflow workflow, Rigbox and its main submodules (signals, alyx-matlab, and wheelAnalysis) each have two main branches: the `dev` branch is where new features are deployed, and the `master` branch contains the stable build that most users will work with. Contributors should create a new "feature" branch for any changes/additions they wish to make, and then create a pull request for this feature branch. If making a change to a submodule, a pull request should be sent to that submodule's repository. (e.g. if a user is making a change to a file within the signals repository, a pull request should be made to the [signals repository](https://github.com/cortex-lab/signals/pulls), not to the Rigbox repository.) **All pull requests should be made to the `dev` branch of the appropriate repository.** All pull requests will be reviewed by the project maintainers. Below are procedural guidelines to follow for contributing via a pull request: -1. Ensure any new file follows [MATLAB documentation guidelines](https://www.mathworks.com/help/matlab/matlab_prog/add-help-for-your-program.html) and is accompanied by a test file that adequately covers all expected use cases. This test file should be placed in the appropriate repository's `tests` folder, and follow the naming convention of `_test`. If the contributor is not adding a new file but instead changing/adding to an exisiting file that already has an accompanying test file, a test that accompanies the contributor's code should be added to the existing test file. See the [Rigbox/tests folder](https://github.com/cortex-lab/Rigbox/tree/dev/tests) for examples. +1. Ensure any new file follows [MATLAB documentation guidelines](https://www.mathworks.com/help/matlab/matlab_prog/add-help-for-your-program.html) and is accompanied by a test file that adequately covers all expected use cases. This test file should be placed in the appropriate repository's `tests` folder, and follow the naming convention of `_test`. If the contributor is not adding a new file but instead changing/adding to an exisiting file that already has an accompanying test file, a test that accompanies the contributor's code should be added to the existing test file. See the [Rigbox/tests folder](https://github.com/cortex-lab/Rigbox/blob/master/tests) for examples. *Note: For users unfamiliar with creating unit tests in MATLAB, [MATLAB's Testing Frameworks documentation](https://uk.mathworks.com/help/matlab/matlab-unit-test-framework.html?s_tid=CRUX_lftnav) has examples for writing [script-based](https://uk.mathworks.com/help/matlab/matlab_prog/write-script-based-unit-tests.html), [function-based](https://uk.mathworks.com/help/matlab/matlab_prog/write-simple-test-case-with-functions.html), and [class-based](https://uk.mathworks.com/help/matlab/matlab_prog/write-simple-test-case-using-classes.html) unit tests. The project maintainers are also happy to provide more specific test-related information and help contributors write tests.* @@ -22,12 +22,12 @@ Following the gitflow workflow, Rigbox and its main submodules (signals, alyx-ma 4. Create a pull request to merge the contributed branch into the `dev` branch. The submodule dependencies should be first checked and updated, if necessary. The branch will then be merged upon approval by at least one authorized reviewer. We have a continuous integration server that checks that runs tests and checks for changes in code coverage. -5. The project maintainers will typically squash the feature branch down to the commmit where it branched off from the `dev` branch, rebase the squashed branch onto `dev`, and then merge the rebased branch into `dev` (See [here](https://blog.carbonfive.com/2017/08/28/always-squash-and-rebase-your-git-commits) for more info on why to adopt the "squash, rebase, merge" workflow). When the project maintainers have merged the contributor's feature branch into the `dev` branch, the changes should be added to the [changelog](https://github.com/cortex-lab/Rigbox/blob/dev/CHANGELOG.md). When the `dev` branch has accumulated sufficient changes for it to be considered a new major version, and all changes have been deployed for at least a week, the project maintainers will open a pull request to merge `dev` into the `master` branch. The project maintainers should ensure that the version numbers in any relevant files and the `README` are up-to-date. The versioning specification numbering used is [SemVer](http://semver.org/). Previous versions are archived in [releases](https://github.com/cortex-lab/Rigbox/releases). Once the dev branch has accumulated sufficient changes for it to be considered a new major version, and all changes have been deployed for at least a week, the project maintainers will open a pull request to merge dev into the master branch. +5. The project maintainers will typically squash the feature branch down to the commmit where it branched off from the `dev` branch, rebase the squashed branch onto `dev`, and then merge the rebased branch into `dev` (See [here](https://blog.carbonfive.com/2017/08/28/always-squash-and-rebase-your-git-commits) for more info on why to adopt the "squash, rebase, merge" workflow). When the project maintainers have merged the contributor's feature branch into the `dev` branch, the changes should be added to the [changelog](https://github.com/cortex-lab/Rigbox/blob/master/CHANGELOG.md). When the `dev` branch has accumulated sufficient changes for it to be considered a new major version, and all changes have been deployed for at least a week, the project maintainers will open a pull request to merge `dev` into the `master` branch. The project maintainers should ensure that the version numbers in any relevant files and the `README` are up-to-date. The versioning specification numbering used is [SemVer](http://semver.org/). Previous versions are archived in [releases](https://github.com/cortex-lab/Rigbox/releases). Once the dev branch has accumulated sufficient changes for it to be considered a new major version, and all changes have been deployed for at least a week, the project maintainers will open a pull request to merge dev into the master branch. ## Style Guidelines [Richard Johnson](https://uk.mathworks.com/matlabcentral/profile/authors/22731-richard-johnson) writes, "Style guidelines are not commandments. Their goal is simply to help programmers write well." Well-written code implies code that is easy to read. Code that is easy to read is typically written in a consistent style, so we suggest making your code as consistent with the rest of the repository as possible. Some examples: -* For a particularly well-documented function, see ['sig.timeplot'](https://github.com/cortex-lab/signals/blob/dev/%2Bsig/timeplot.m). For a particularly well-documented class, see ['hw.Timeline'](https://github.com/cortex-lab/Rigbox/blob/dev/%2Bhw/Timeline.m) +* For a particularly well-documented function, see ['sig.timeplot'](https://github.com/cortex-lab/signals/blob/master/%2Bsig/%2Btest/timeplot.m). For a particularly well-documented class, see ['hw.Timeline'](https://github.com/cortex-lab/Rigbox/blob/master/%2Bhw/Timeline.m) * File header documentation is written as follows (see the above files for examples): - Functions have (in the following order): a one-line summary describing the action of the function, a longer description, details on their inputs and outputs, examples, and any additional notes. - Classes have (in the following order): a one-line summary of the class, a longer description, examples, and any additional notes. diff --git a/README.md b/README.md index 9a8a5b4e..5e0c0b2e 100644 --- a/README.md +++ b/README.md @@ -1,130 +1,119 @@ ---------- # Rigbox -![Coverage badge](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fgladius.serveo.net%2Fcoverage%2Frigbox%2Fmaster) -![Build status badge](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fgladius.serveo.net%2Fstatus%2Frigbox%2Fmaster) +![Coverage badge](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fsilent-zebra-36.tunnel.datahub.at%2Fcoverage%2Frigbox%2Fmaster) +![Build status badge](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fsilent-zebra-36.tunnel.datahub.at%2Fstatus%2Frigbox%2Fmaster) -Rigbox is a high-performance, open-source software toolbox for managing behavioral neuroscience experiments. Initially developed to probe mouse behavior for the [Steering Wheel Setup](https://www.ucl.ac.uk/cortexlab/tools/wheel), Rigbox is under active, test-driven development to encompass a variety of experimental paradigms across behavioral neuroscience. Rigbox simplifies hardware/software interfacing, synchronizes data streams from multiple sources, manages experimental data via communication with a remote database, implements a viewing model for visual stimuli, and creates a runtime environment in which an experiment's parameters can be easily monitored and manipulated. Rigbox’s object-oriented paradigm facilitates a modular approach to designing experiments. Rigbox requires two machines, one for stimulus presentation ('the stimulus computer' or 'sc') and another for controlling and monitoring the experiment ('the master computer' or 'mc'). +Rigbox is a high-performance, open-source MATLAB toolbox for managing behavioral neuroscience experiments. Initially developed to probe mouse behavior for the [Steering Wheel Setup](https://www.ucl.ac.uk/cortexlab/tools/wheel), Rigbox simplifies hardware/software interfacing and creates a runtime environment in which an experiment's parameters can be easily monitored and manipulated. -## Getting Started +Rigbox includes many features including synchronizing recordings, managing experimental data and a viewing model for visual stimuli. -The following is a brief description of how to install Rigbox on your experimental rig. Detailed, step-by-step information can be found in Rigbox's [documentation](https://github.com/cortex-lab/Rigbox/tree/master/docs). Information specific to the steering wheel task can be found on the [CortexLab website](https://www.ucl.ac.uk/cortexlab/tools/wheel). +Rigbox is mostly object-oriented and highly modular, making designing new experiments much simpler. Rigbox is currently under active, test-driven development. -### Prerequisites +## Requirements + +For running full experiments Rigbox requires two PCs: one for presenting stimuli and one for monitoring the experiment. Currently only National Instruments DAQs are supported for acquiring data from hardware devices. For testing, the toolbox can be run on a single machine. + +### Software Rigbox has the following software dependencies: * Windows Operating System (7 or later, 64-bit) * MATLAB (2017b or later) +* [Visual C++ Redistributable Packages for Visual Studio 2013](https://www.microsoft.com/en-us/download/details.aspx?id=40784) & [2015](https://www.microsoft.com/en-us/download/details.aspx?id=48145) * The following MathWorks MATLAB toolboxes (note, these can all be downloaded and installed directly within MATLAB via the "Add-Ons" button in the "Home" top toolstrip): - * Data Acquisition Toolbox - * Signal Processing Toolbox - * Instrument Control Toolbox - * Statistics and Machine Learning Toolbox + * Data Acquisition Toolbox * The following community MATLAB toolboxes: * [GUI Layout Toolbox](https://uk.mathworks.com/matlabcentral/fileexchange/47982-gui-layout-toolbox) (v2 or later) - * [Psychophsics Toolbox](http://psychtoolbox.org/download.html) (v3 or later) - * [NI-DAQmx support package](https://uk.mathworks.com/hardware-support/nidaqmx.html) + * [Psychophysics Toolbox](http://psychtoolbox.org/download.html) (v3 or later) + * [NI-DAQmx support package](https://uk.mathworks.com/hardware-support/nidaqmx.html) -Additionally, Rigbox works with a number of extra submodules (included): -* [signals](https://github.com/cortex-lab/signals) (for designing bespoke experiments) -* [alyx-matlab](https://github.com/cortex-lab/alyx-matlab) (for registering data to, and retrieving from, an Alyx database) +Additionally, Rigbox works with a number of extra submodules (included with Rigbox): +* [signals](https://github.com/cortex-lab/signals) (for designing bespoke experiments in Signals) +* [alyx-matlab](https://github.com/cortex-lab/alyx-matlab) (for registering data to, and retrieving from, an [Alyx database](https://alyx.readthedocs.io/en/latest/)) * [npy-matlab](https://github.com/kwikteam/npy-matlab) (for saving data in binary NPY format) * [wheelAnalysis](https://github.com/cortex-lab/wheelAnalysis) (for analyzing data from the steering wheel task) -### Installation via git - -0. It is highly recommended to install Rigbox via git. If not already downloaded and installed, install [git](https://git-scm.com/download/win) (and the included minGW software environment and Git Bash MinTTY terminal emulator). After installing, launch the Git Bash terminal. -1. To install Rigbox, run the following commands in the Git Bash terminal to clone the repository from GitHub to your local machine. (* *Note*: It is *not* recommended to clone directly into the MATLAB folder) -``` -git clone --recurse-submodules https://github.com/cortex-lab/Rigbox -``` -2. Open MATLAB and run `addRigboxPaths.m` then restart the program. *Note*: Do __not__ add all Rigbox folders and subfolders to the paths! -3. Set the correct paths on both computers by following the instructions in the '/docs/setup/paths_config' file. -4. On the stimulus computer, set the hardware configuration by following the instructions in the '/docs/setup/hardware_config' file. -5. To keep the submodules up to date, run the following in the Git Bash terminal (within the Rigbox directory): -``` -git pull --recurse-submodules -``` - -### Running an experiment in MATLAB - -On the stimulus computer, run: -> srv.expServer +### Hardware -On the master computer, run: -> mc +Below are a few minimum hardware requirements for both PCs. These are more of a guide than a requirement as it depends on the type of experiments you wish to run. -This opens the MC GUI for selecting a subject, experiment, and the SC on which to run the experiment. The MC GUI also allows for editing some experimental parameters and logging into the Alyx database. To launch the experiment on the selected SC, press 'Start'. +**Processor:** Intel Core i3 7100, 3.9 GHz +**Graphics:** NVIDIA NVS 510 (for three screen support) +**Memory:** 16 GB 2133MHz DDR4 RAM, 1.2 V +**Storage:** 1TB HD, 7200rpm, 64MB Cache +**OS:** Windows 7 64-bit -## Code organization +## Installation -Below is a list of Rigbox's subdirectories and an overview of their respective contents. +Before starting, ensure the above toolboxes and packages are installed. PsychToobox can not be installed via the MATLAB AddOns browser. See [Installing PsychToobox](#Installing-PsychToolbox) for install instructions. -### +dat +It is highly recommended to install Rigbox via the [Git Bash](https://git-scm.com/download/win) terminal*. -The "data" package contains code pertaining to the organization and logging of data. It contains functions that generate and parse unique experiment reference ids, and return file paths where subject data and rig configuration information is stored. Other functions include those that manage experimental log entries and parameter profiles. A nice metaphor for this package is a lab notebook. - -### +eui - -The "user interface" package contains code pertaining to the Rigbox user interface. It contains code for constructing the mc GUI (MControl.m), and for plotting live experiment data or generating tables for viewing experiment parameters and subject logs. - -This package is exclusively used by the master computer. - -### +exp - -The "experiments" package is for the initialization and running of behavioural experiments. It contains code that define a framework for event- and state-based experiments. Actions such as visual stimulus presentation or reward delivery can be controlled by experiment phases, and experiment phases are managed by an event-handling system (e.g. ResponseEventInfo). - -The package also triggers auxiliary services (e.g. starting remote acquisition software), and loads parameters for presentation for each trail. The principle two base classes that control these experiments are 'Experiment' and its "signals package" counterpart, 'SignalsExp'. - -This package is almost exclusively used by the stimulus computer. - -### +hw +1. To install Rigbox, run the following commands in the Git Bash terminal to clone the repository from GitHub to your local machine. +``` +git clone --recurse-submodules https://github.com/cortex-lab/Rigbox +``` +2. Run the `addRigboxPaths.m` function in MATLAB (found in the Rigbox directory) then restart the program. This adds all required folders and functions to your MATLAB path. *Note*: Do __not__ manually add all Rigbox folders and subfolders to the paths!** -The "hardware" package is for configuring, and interfacing with, hardware (such as screens, DAQ devices, weighing scales and lick detectors). Within this is the "+ptb" package which contains classes for interacting with PsychToolbox. +\* Accepting all installer defaults will suffice. +** To add the paths temporarily for testing: +``` +addRigboxPaths('SavePaths', false, 'Strict', false) +``` -'devices.m' loads and initializes all the hardware for a specific experimental rig. There are also classes for unifying system and hardware clocks. +### Installing PsychToolbox -### +psy +PsychToolbox-3 is required for visual and auditory stimulus presentation. Below are some simple steps for installing PsychToolbox. For full details see [their documentation](http://psychtoolbox.org/download.html#Windows). -The "psychometrics" package contains simple functions for processing and plotting psychometric data. +1. Download and install a Subversion client. [SilkSVN](https://sliksvn.com/download/) is recommended. +2. Download the MATLAB [installer function](https://raw.githubusercontent.com/Psychtoolbox-3/Psychtoolbox-3/master/Psychtoolbox/DownloadPsychtoolbox.m) from the PsychToolbox GitHub page. +3. Call the function in MATLAB with the target install location (folder must exist) and follow the instructions: +``` +DownloadPsychtoolbox('C:\') % Install to C drive +``` -### +srv +## Getting started +After following the installation instructions you can start playing around with Rigbox and Signals. To run one of the example experiments, open MATLAB and run `eui.SignalsTest();`, then select 'advancedChoiceWorld.m'. -The "stim server" package contains the 'expServer' function as well as classes that manage communications between rig computers. +Full Rigbox documentaion can be found in [docs/html/index.html](https://github.com/cortex-lab/tree/master/docs/index.m). +To get an idea of how experiments run using the Rigbox Signal Experiment framework, have a look at the following file: [docs/html/using_test_gui.html](https://github.com/cortex-lab/signals/tree/master/docs/using_test_gui.m). To learn how to create a new Signals experiment, see the [Signals tutorials](https://github.com/cortex-lab/signals/tree/master/docs/tutorials) -The 'Service' base class allows the stimulus computer to start and stop auxiliary acquisition systems at the beginning and end of experiments. +### Running an experiment +For running experiments, edit your `+dat.paths.m` file to set paths for saving config files and experiment data. A template can be found in [docs/setup/paths_template.m](https://github.com/cortex-lab/Rigbox/blob/master/docs/setup/paths_template.m). Then configure the hardware by following the instructions in the [docs/html/hardware_config.html](https://github.com/cortex-lab/Rigbox/blob/master/docs/setup/hardware_config.m) file. This will guide you through configuring a visual viewing model, configuring audio devices and setting up hardware that requires a DAQ. -The 'StimulusControl' class is used by the master computer to manage the stimulus computer. +On the stimulus computer (SC), run: +``` +srv.expServer +``` +This opens up a new stimulus window and initializes the hardware devices -* *Note*: Lower-level communication protocol code is found in the "cortexlab/+io" package. +On the master computer (MC), run: +``` +mc +``` -### cb-tools/burgbox +This opens the MC GUI for selecting a subject, experiment, and the SC on which to run the experiment. The MC GUI also allows for editing some experimental parameters and logging into the Alyx database (optional). Rigbox comes with some experiments, namely ChoiceWorld and some Signals experiments found in the submodule's [documentation folder](https://github.com/cortex-lab/signals/tree/master/docs). Signals experiments are run by selecting '' from the experiment drop-down menu and navigating to the desired experiment definition function. To launch the experiment on the selected SC, press 'Start'. -"Burgbox" contains many simple helper functions that are used by the main packages. Within this directory are additional packages: +Information specific to the steering wheel task can be found on the [CortexLab website](https://www.ucl.ac.uk/cortexlab/tools/wheel). -* +bui --- Classes for managing graphics objects such as axes -* +aud --- Functions for interacting with PsychoPortAudio -* +file --- Functions for simplifying directory and file management, for instance returning the modified dates for specified folders or filtering an array of directories by those that exist -* +fun --- Convenience functions for working with function handles in MATLAB, e.g. functions similar cellfun that are agnostic of input type, or ones that cache function outputs -* +img --- Classes that deal with image and frame data (DEPRECATED) -* +io --- Lower-level communications classes for managing UDP and TCP/IP Web sockets -* +plt --- A few small plotting functions (DEPRECATED) -* +vis --- Functions for returning various windowed visual stimuli (i.g. gabor gratings) -* +ws --- An early Web socket package using SuperWebSocket (DEPRECATED) +## Updating the code +With Git it's very easy to keep the code up-to-date. To update Rigbox and all submodules at the same time, run the following in the Git Bash terminal (within the Rigbox directory): +``` +git pull --recurse-submodules +``` -### cortexlab +When calling `srv.expServer` and `mc`, the code is automatically updated if a new stable release is available. This behvaiour can be configured with the 'updateSchedule' field in your `+dat/paths.m` file. -The "cortexlab" directory is intended for functions and classes that are rig or CortexLab specific, for example, code that allows compatibility with other stimulus presentation packages used by CortexLab (e.g. MPEP) +## Contributing -### tests +If you experience a bug or have a feature request, please report them on the [GitHub Issues page](https://github.com/cortex-lab/Rigbox/issues). To contribute code we encourage anyone to open up a pull request into the dev branch of Rigbox or one of its submodules. Ideally you should include documentation and a test with your feature. -The "tests" directory contains code for running unit tests within Rigbox. +Please read [CONTRIBUTING.md](https://github.com/cortex-lab/Rigbox/blob/dev/CONTRIBUTING.md) for further details on how to contribute, as well as maintainer guidelines and our code of conduct. -### docs -Contains various guides for how to configure and use Rigbox. +## Authors & Accreditation -### submodules +Rigbox was started by [Chris Burgess](https://github.com/dendritic/) in 2013. It is now maintained and developed by Miles Wells (miles.wells@ucl.ac.uk), Jai Bhagat (j.bhagat@ucl.ac.uk) and a number of others at [CortexLab](https://www.ucl.ac.uk/cortexlab). See also the full list of [contributors](https://github.com/cortex-lab/Rigbox/graphs/contributors). -Additional information on the [alyx-matlab](https://github.com/cortex-lab/alyx-matlab), [npy-matlab](https://github.com/kwikteam/npy-matlab), [signals](https://github.com/cortex-lab/signals) and [wheelAnalysis](https://github.com/cortex-lab/wheelAnalysis) submodules can be found in their respective github repositories. +For further information, see [our publication](https://www.biorxiv.org/content/10.1101/672204v3). Please cite this source appropriately in publications which use Rigbox to acquire data. ## Acknowledgements @@ -132,13 +121,5 @@ Additional information on the [alyx-matlab](https://github.com/cortex-lab/alyx-m * [Psychophsics Toolbox](http://psychtoolbox.org) for code pertaining to visual stimulus presentation * [NI-DAQmx](https://uk.mathworks.com/hardware-support/nidaqmx.html) for code pertaining to inerfacing with a NI-DAQ device * [TooTallNate](https://github.com/TooTallNate/Java-WebSocket) for code pertaining to using Java Websockets - -## Contributing - -Please read [CONTRIBUTING.md](https://github.com/cortex-lab/Rigbox/blob/dev/CONTRIBUTING.md) for details on how to contribute code to this repository and our code of conduct. - -## Authors & Accreditation - -The majority of the Rigbox code was written by [Chris Burgess](https://github.com/dendritic/) in 2013. It is now maintained and developed by Miles Wells (miles.wells@ucl.ac.uk), Jai Bhagat (j.bhagat@ucl.ac.uk) and a number of others at [CortexLab](https://www.ucl.ac.uk/cortexlab). See also the full list of [contributors](https://github.com/cortex-lab/Rigbox/graphs/contributors). - -Rigbox is described in-depth in [this publication](https://www.biorxiv.org/content/10.1101/672204v1). Please cite this source appropriately in publications which use Rigbox to acquire data. +* [Andrew Janke](https://github.com/apjanke) for the `isWindowsAdmin` function +* [Timothy E. Holy](http://holylab.wustl.edu/) for the `distinguishable_colors` function \ No newline at end of file diff --git a/addRigboxPaths.m b/addRigboxPaths.m index 6b892f2e..9825517f 100644 --- a/addRigboxPaths.m +++ b/addRigboxPaths.m @@ -1,53 +1,30 @@ -function addRigboxPaths(savePaths) +function addRigboxPaths(varargin) %ADDRIGBOXPATHS Adds the required paths for using Rigbox +% addRigboxPaths([savePaths, interactive, strict]) or +% addRigboxPaths('SavePaths', true, 'Interactive', true, 'Strict', true) % -% Part of the Rigging toolbox +% Inputs (Optional): +% savePaths (logical): If true, added paths are saved between sessions +% interactive (logical): If true, user may be prompted for input +% strict (logical): Assert toolbox & system requirments are all met +% +% Part of the Rigging toolbox % % 2014-01 CB % 2017-02 MW Updated to work with 2016b -% Flag for perminantly saving paths -if nargin < 1; savePaths = true; end +%%% Input validation %%% +% Allow positional or Name-Value pairs +p = inputParser; +p.addOptional('savePaths', true) +p.addOptional('interactive', true) +p.addOptional('strict', true) +p.parse(varargin{:}); +p = p.Results; %%% MATLAB version and toolbox validation %%% -% MATLAB must be running on Windows -assert(ispc, 'Rigbox currently only works on Windows 7 or later') - -% Microsoft Visual C++ Redistributable for Visual Studio 2015 must be -% installed, check for runtime dll file in system32 folder sys32 = dir('C:\Windows\System32'); -assert(any(strcmpi('VCRuntime140.dll',{sys32.name})), 'Rigbox:setup:libraryRequired',... - ['Requires Microsoft Visual C++ Redistributable for Visual Studio 2015. ',... - 'Click here to install.'],... - 'https://www.microsoft.com/en-us/download/details.aspx?id=48145') - -% Check MATLAB 2017b is running -assert(~verLessThan('matlab', '9.3'), 'Requires MATLAB 2017b or later') - -% Check essential toolboxes are installed (common to both master and -% stimulus computers) toolboxes = ver; -requiredMissing = setdiff(... - {'Data Acquisition Toolbox', ... - 'Signal Processing Toolbox', ... - 'Instrument Control Toolbox', ... - 'Statistics and Machine Learning Toolbox'},... - {toolboxes.Name}); - -assert(isempty(requiredMissing),'Rigbox:setup:toolboxRequired',... - 'Please install the following toolboxes before proceeding: \n%s',... - strjoin(requiredMissing, '\n')) - -% Check that GUI Layout Toolbox is installed (required for the master -% computer only) -isInstalled = strcmp('GUI Layout Toolbox', {toolboxes.Name}); -if ~any(isInstalled) ||... - str2double(strrep(toolboxes(isInstalled).Version,'.', '')) < 230 - warning('Rigbox:setup:toolboxRequired',... - ['MC requires GUI Layout Toolbox v2.3 or higher to be installed. '... - 'Click here to install.'],... - 'https://uk.mathworks.com/matlabcentral/fileexchange/47982-gui-layout-toolbox') -end % Check that the Psychophisics Toolbox is installed (required for the % stimulus computer only) @@ -55,18 +32,52 @@ function addRigboxPaths(savePaths) if ~any(isInstalled) || str2double(toolboxes(isInstalled).Version(1)) < 3 warning('Rigbox:setup:toolboxRequired',... ['The stimulus computer requires Psychtoolbox v3.0 or higher to be installed. '... - 'Click here to install.'],... - 'https://github.com/Psychtoolbox-3/Psychtoolbox-3/releases') + 'Follow the steps in the README to install.'],... + 'https://github.com/cortex-lab/Rigbox/tree/master#installing-psychtoolbox') end - -% Check that the NI DAQ support package is installed (required for the -% stimulus computer only) -info = matlabshared.supportpkg.getInstalled; -if isempty(info) || ~any(contains({info.Name}, 'NI-DAQmx')) - warning('Rigbox:setup:toolboxRequired',... - ['The stimulus computer requires the National Instruments support package to be installed. '... + +if p.strict + % MATLAB must be running on Windows + assert(ispc, 'Rigbox currently only works on Windows 7 or later') + + % Microsoft Visual C++ Redistributable for Visual Studio 2015 must be + % installed, check for runtime dll file in system32 folder + assert(any(strcmpi('VCRuntime140.dll',{sys32.name})), 'Rigbox:setup:libraryRequired',... + ['Requires Microsoft Visual C++ Redistributable for Visual Studio 2015. ',... 'Click here to install.'],... - 'https://www.mathworks.com/hardware-support/nidaqmx.html') + 'https://www.microsoft.com/en-us/download/details.aspx?id=48145') + + % Check MATLAB 2017b is running + assert(~verLessThan('matlab', '9.3'), 'Requires MATLAB 2017b or later') + + % Check essential toolboxes are installed (common to both master and + % stimulus computers) + requiredMissing = setdiff({'Data Acquisition Toolbox'}, {toolboxes.Name}); + + assert(isempty(requiredMissing),'Rigbox:setup:toolboxRequired',... + 'Please install the following toolboxes before proceeding: \n%s',... + strjoin(requiredMissing, '\n')) + + % Check that GUI Layout Toolbox is installed (required for the master + % computer only) + isInstalled = strcmp('GUI Layout Toolbox', {toolboxes.Name}); + if ~any(isInstalled) ||... + str2double(erase(toolboxes(isInstalled).Version,'.')) < 230 + warning('Rigbox:setup:toolboxRequired',... + ['MC requires GUI Layout Toolbox v2.3 or higher to be installed. '... + 'Click here to install.'],... + 'https://uk.mathworks.com/matlabcentral/fileexchange/47982-gui-layout-toolbox') + end + + % Check that the NI DAQ support package is installed (required for the + % stimulus computer only) + info = matlabshared.supportpkg.getInstalled; + if isempty(info) || ~any(contains({info.Name}, 'NI-DAQmx')) + warning('Rigbox:setup:toolboxRequired',... + ['The stimulus computer requires the National Instruments support package to be installed. '... + 'Click here to install.'],... + 'https://www.mathworks.com/hardware-support/nidaqmx.html') + end end %%% Paths for adding @@ -133,7 +144,7 @@ function addRigboxPaths(savePaths) end %%% Validate that paths saved correctly %%% -if savePaths +if p.savePaths assert(savepath == 0, 'Failed to save changes to MATLAB path'); if ~cbtoolsInJavaPath fseek(fid, 0, 'eof'); @@ -148,16 +159,20 @@ function addRigboxPaths(savePaths) end %%% Attempt to move dll file for signals %%% +MSVSC2013URL = 'https://www.microsoft.com/en-us/download/details.aspx?id=40784'; fileName = fullfile(root, 'signals', 'msvcr120.dll'); fileExists = any(strcmp('msvcr120.dll',{sys32.name})); copied = false; if isWindowsAdmin % If user has admin privileges, attempt to copy dll file - if fileExists % If there's already a dll file there prompt use to make backup + if fileExists && p.interactive % If there's already a dll file there prompt use to make backup prompt = sprintf(['For signals to work propery, it is nessisary to copy ',... - 'the file \n', strrep(fileName, '\', '\\'), ' to ',... - 'C:\\Windows\\System32.\n',... + 'the file \n', strrep(fileName, '\', '\\\\'), ' to ',... + 'C:\\\\Windows\\\\System32.\n',... 'You may want to make a backup of your existing dll file before continuing.\n\n',... - 'Do you want to proceed? Y/N [Y]: ']); + 'Alternatively this file is installed with ',... + '',... + 'Visual C++ Redistributable Packages for Visual Studio 2013\n\n',... + 'Do you want to proceed with copying? Y/N [Y]: '], MSVSC2013URL); str = input(prompt,'s'); if isempty(str); str = 'y'; end if strcmpi(str, 'n'); return; end % Return without copying end @@ -166,7 +181,10 @@ function addRigboxPaths(savePaths) % Check that the file was copied if ~copied warning('Rigbox:setup:libraryRequired', ['Please copy the file ',... - '%s to C:\\Windows\\System32.'], fileName) + '%s to C:\\Windows\\System32 ',... + '\nor install ',... + 'Visual C++ Redistributable Packages for Visual Studio 2013'], ... + fileName, MSVSC2013URL) end function out = isWindowsAdmin() diff --git a/alyx-matlab b/alyx-matlab index 5c6ec29f..4c385f71 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 5c6ec29f2ca98a93a9ced2acc57d1fd0302cf40e +Subproject commit 4c385f714a09b4e1965e6ddc7e37265cec7c823b diff --git a/cb-tools/burgbox/+bui/parentFigure.m b/cb-tools/burgbox/+bui/parentFigure.m index c28b779f..00de85f1 100644 --- a/cb-tools/burgbox/+bui/parentFigure.m +++ b/cb-tools/burgbox/+bui/parentFigure.m @@ -1,7 +1,8 @@ function h = parentFigure(h) %BUI.PARENTFIGURE Parent figure of graphics object % f = BUI.PARENTFIGURE(h) Returns the parent figure, if any, of the -% graphics object with handle 'h'. +% graphics object with handle 'h'. NB: For most purposes `ancestor` will +% suffice. % % Part of Burgbox diff --git a/cb-tools/burgbox/+io/WSCommunicator.m b/cb-tools/burgbox/+io/WSCommunicator.m index 46c44838..d7e650e4 100644 --- a/cb-tools/burgbox/+io/WSCommunicator.m +++ b/cb-tools/burgbox/+io/WSCommunicator.m @@ -24,7 +24,7 @@ properties (Transient) WebSocket - EventMode = false + EventMode = 'off' end properties (Access = private, Transient) diff --git a/cb-tools/burgbox/tabulateArgs.m b/cb-tools/burgbox/tabulateArgs.m index 16bfb197..c7f6d5c4 100644 --- a/cb-tools/burgbox/tabulateArgs.m +++ b/cb-tools/burgbox/tabulateArgs.m @@ -1,14 +1,27 @@ function [varargout] = tabulateArgs(varargin) -%TABULATEARGS Turns a bunch of cell or single arguments into rows +% TABULATEARGS Turns a bunch of cell or single arguments into rows +% [A1,...,AN,singleArg] = tabulateArgs(A1,...,AN) returns the mixed-size +% inputs as a cell array with any single element inputs replicated to be +% the same number of elements as the other inputs. All non-single inputs +% must have the same number of elements. Char arrays are considered to +% be a single element. All output args thus have the same number of +% elements. If all inputs are single elements, they are returned +% unchanged (i.e. not as a cell). Also returns a flag indicating whether +% all inputs were single elements. +% +% Examples: +% [name, useFlag, singleArg] = tabulateArgs({'huxley', 'cajal'}, true) +% useFlag == {[1], [1]} % Now a cell array with equal size to `name` +% singleArg == false % numel(name) > 1 thus not all args were singles +% +% [name, useFlag, singleArg] = tabulateArgs('huxley', true) +% useFlag == 1 % Unchanged +% singleArg == true % All inputs were single elements % % Part of Burgbox % 2013-03 CB created -% Check if each argument is single, criteria are: -% 1) *not* a cell array of *any* size, AND -% 2) number of elements in whatever they are is 1, UNLESS -% 3) it is a char, in which case it counts a single item (even if len > 1) singleArg = cellfun(@(arg) ~iscell(arg) && numel(arg) == 1 || ischar(arg), varargin); allSingleArgs = all(singleArg); diff --git a/cb-tools/distribute.m b/cb-tools/distribute.m new file mode 100644 index 00000000..b2920e86 --- /dev/null +++ b/cb-tools/distribute.m @@ -0,0 +1,6 @@ +function varargout = distribute(A) +% DISTRIBUTE Assign elements of an array to each output +% Similar to how deal works +% +% See also DEAL +varargout = mapToCell(@identity, A); \ No newline at end of file diff --git a/cortexlab/+eui/NickExpPanel.m b/cortexlab/+eui/NickExpPanel.m deleted file mode 100644 index 195a5c25..00000000 --- a/cortexlab/+eui/NickExpPanel.m +++ /dev/null @@ -1,75 +0,0 @@ -classdef NickExpPanel < eui.ChoiceExpPanel - %EUI.NickExpPanel UI control for monitoring a wheel experiment, - %potentially with differing rewards, stimulus altitudes, and with no-go - %responses - % - % Part of Rigbox - - % 2014-08 NS created - - - properties - ConditionIndexLabel - end - - methods - function obj = NickExpPanel(parent, ref, params, logEntry) - obj = obj@eui.ChoiceExpPanel(parent, ref, params, logEntry); - end - - - function refresh(obj) - nTrials = obj.Block.numCompletedTrials; - obj.PsychometricAxes.clear(); - if nTrials > 0 - trials = obj.Block.trial(1:nTrials); - conds = [trials.condition]; - nonRepeatTrials = [conds.repeatNum] == 1; - - %'performance' trials are those which aren't predictable repeats - % (e.g. due to the animal getting the last incorrect) - perfTrials = nonRepeatTrials; - pc = mean([trials(perfTrials).feedbackType] > 0); - set(obj.PerformanceLabel, 'String', ... - iff(isfinite(pc), sprintf('%.1f%%', 100*pc), 'N/A')); - - psy.plot2ADCwithAlt(obj.PsychometricAxes.Handle, obj.Block); - end - end - - function newTrial(obj, num, condition) - %attempt num is red when on higher than third - attemptColour = iff(condition.repeatNum > 3, [1 0 0], [0 0 0]); - set(obj.AttemptNumLabel,... - 'String', sprintf('%i', condition.repeatNum),... - 'ForegroundColor', attemptColour); - if isfield(condition, 'conditionId') - if isnumeric(condition.conditionId) - conditionId = num2str(condition.conditionId); - else - conditionId = condition.conditionId; - end - set(obj.ConditionLabel, 'String', conditionId); - end - - conDiff = diff(condition.visCueContrast); - if isfield(condition, 'targetAltitude') - thisAlt = condition.targetAltitude; - allAlt = unique(obj.Parameters.Struct.targetAltitude); - thisAltInd = find(allAlt==thisAlt,1); - lineType = iff(thisAltInd==1, '-', ':'); - else - lineType = ':'; - end - obj.PsychometricAxes.plot(conDiff*[100 100], [-10 110], lineType,... - 'LineWidth', 3, 'Color', [0 0 0]); - end - - function build(obj, parent) - build@eui.ChoiceExpPanel(obj, parent); %call superclass method - end - - end - - -end \ No newline at end of file diff --git a/cortexlab/+exp/ChoiceWorld.m b/cortexlab/+exp/ChoiceWorld.m index 60ad59ec..f0f09eda 100644 --- a/cortexlab/+exp/ChoiceWorld.m +++ b/cortexlab/+exp/ChoiceWorld.m @@ -175,47 +175,54 @@ function drawFrame(obj) end function saveData(obj) - saveData@exp.Experiment(obj); - if ~obj.AlyxInstance.IsLoggedIn - warning('No Alyx token set'); - else - try - subject = dat.parseExpRef(obj.Data.expRef); - if strcmp(subject, 'default'); return; end - % Register saved files - savepaths = dat.expFilePath(obj.Data.expRef, 'block'); - obj.AlyxInstance.registerFile(savepaths{end}); - % Save the session end time - if ~isempty(obj.AlyxInstance.SessionURL) - numTrials = obj.Data.numCompletedTrials; - if isfield(obj.Data, 'trial')&&isfield(obj.Data.trial, 'feedbackType') - numCorrect = sum([obj.Data.trial.feedbackType] == 1); - else - numCorrect = 0; - end - sessionData = struct('end_time', obj.AlyxInstance.datestr(now), ... - 'n_trials', numTrials, 'n_correct_trials', numCorrect); - obj.AlyxInstance.postData(obj.AlyxInstance.SessionURL, sessionData, 'patch'); - else - % Infer from date session and retrieve using expFilePath - end - catch ex - warning(ex.identifier, 'Failed to register files to Alyx: %s', ex.message); - end - try - if ~isfield(obj.Data,'rewardDeliveredSizes') || ... - strcmp(obj.Data.endStatus, 'aborted') - return % No completed trials - end - amount = sum(obj.Data.rewardDeliveredSizes(:,1)); % Take first element (second being laser) - if ~any(amount); return; end % Return if no water was given - controller = obj.RewardController.SignalGenerators(strcmp(obj.RewardController.ChannelNames,'rewardValve')); - type = iff(isprop(controller, 'WaterType'), controller.WaterType, 'Water'); - obj.AlyxInstance.postWater(subject, amount*0.001, now, type, obj.AlyxInstance.SessionURL); - catch ex - warning(ex.identifier, 'Failed to post water to Alyx: %s', ex.message); - end + saveData@exp.Experiment(obj); + + % If Alyx URL not set or default subject, simply return + subject = dat.parseExpRef(obj.Data.expRef); + if isempty(getOr(dat.paths, 'databaseURL')) || strcmp(subject, 'default') + return + end + + if ~obj.AlyxInstance.IsLoggedIn + warning('Rigbox:exp:SignalsExp:noTokenSet', 'No Alyx token set'); + try + % Register saved files + savepaths = dat.expFilePath(obj.Data.expRef, 'block'); + obj.AlyxInstance.registerFile(savepaths{end}); + + % Save the session end time + if ~isempty(obj.AlyxInstance.SessionURL) + % Infer from date session and retrieve using expFilePath + url = getOr(obj.AlyxInstance.getSessions(obj.Data.expRef), 'url'); + assert(~isempty(url), 'Failed to determine session url') + obj.AlyxInstance.SessionURL = url; + end + numTrials = obj.Data.numCompletedTrials; + if isfield(obj.Data, 'trial') && isfield(obj.Data.trial, 'feedbackType') + numCorrect = sum([obj.Data.trial.feedbackType] == 1); + else + numCorrect = 0; + end + sessionData = struct('end_time', obj.AlyxInstance.datestr(now), ... + 'n_trials', numTrials, 'n_correct_trials', numCorrect); + obj.AlyxInstance.postData(obj.AlyxInstance.SessionURL, sessionData, 'patch'); + catch ex + warning(ex.identifier, 'Failed to register files to Alyx: %s', ex.message); end + try + if ~isfield(obj.Data,'rewardDeliveredSizes') || ... + strcmp(obj.Data.endStatus, 'aborted') + return % No completed trials + end + amount = sum(obj.Data.rewardDeliveredSizes(:,1)); % Take first element (second being laser) + if ~any(amount); return; end % Return if no water was given + controller = obj.RewardController.SignalGenerators(strcmp(obj.RewardController.ChannelNames,'rewardValve')); + type = iff(isprop(controller, 'WaterType'), controller.WaterType, 'Water'); + obj.AlyxInstance.postWater(subject, amount*0.001, now, type, obj.AlyxInstance.SessionURL); + catch ex + warning(ex.identifier, 'Failed to post water to Alyx: %s', ex.message); + end + end end end diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index ec75a962..709f367d 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -39,7 +39,7 @@ % Check that paths are set up assert(~isempty(which('dat.paths')), ... - 'rigbox:git:update:copyPaths',... + 'Rigbox:git:update:copyPaths',... ['Error: ''dat.paths'' file not found. Please ensure that a '... '''dat.paths'' file exists for your setup. A template can be found at '... '''docs/setup/paths_template''.']) diff --git a/cortexlab/+hw/findDevice.m b/cortexlab/+hw/findDevice.m new file mode 100644 index 00000000..0deba510 --- /dev/null +++ b/cortexlab/+hw/findDevice.m @@ -0,0 +1,30 @@ +function d = findDevice() +InitializePsychSound +audDevs = PsychPortAudio('GetDevices'); +output = [audDevs.NrOutputChannels] > 0; + +outputDevs = audDevs(output); +[~,I] = sort([outputDevs.LowOutputLatency]); +outputDevs = outputDevs(I); + +duration = 2; + +KbQueueCreate + +for i = 1:length(outputDevs) + d = outputDevs(i); + h = []; + try + h = aud.open(d.DeviceIndex, d.NrOutputChannels, d.DefaultSampleRate); + aud.load(h, rand(d.NrOutputChannels, d.DefaultSampleRate*duration)); + fprintf('Playing noise through device %i (%s)\n', d.DeviceIndex, d.DeviceName); + aud.play(h); + [~, keys] = KbWait; + key = KbName(keys); + if any(strcmp(key, 'space')) + break + end + catch + end + if ~isempty(h); aud.close(h); end +end \ No newline at end of file diff --git a/cortexlab/+hw/setScalePort.m b/cortexlab/+hw/setScalePort.m new file mode 100644 index 00000000..d0f39dc6 --- /dev/null +++ b/cortexlab/+hw/setScalePort.m @@ -0,0 +1,24 @@ +function scale = setScalePort(port, rigname) +% CHANGESCALEPORT Set the port of the scale in the hardware object +% Sets the COM port of the scale and saves it into the hardware file. +% +% Inputs: +% port (char|numerical) : which port to set the hardware scale to +% rigname (char) : the host name of the rig whose scale port to change +% +% Output: +% scale (hw.WeighingScale) : the edited scale object +% +% Examples: +% scale = setScalePort('COM3') +% setScalePort(3, 'ZREDONE') +% +% See also HW.DEVICES +if nargin < 2 + rigname = upper(hostname); +end + +hwPath = fullfile(getOr(dat.paths,'globalConfig'),rigname,'hardware.mat'); +load(hwPath, 'scale') +scale.ComPort = iff(upper(port(1)) == 'C', upper(port), ['COM',num2str(port)]); +save(hwPath, 'scale', '-append') \ No newline at end of file diff --git a/cortexlab/+psy/plot2ADCwithAlt.m b/cortexlab/+psy/plot2ADCwithAlt.m deleted file mode 100644 index 1d1f9ece..00000000 --- a/cortexlab/+psy/plot2ADCwithAlt.m +++ /dev/null @@ -1,145 +0,0 @@ -function plot2ADCwithAlt(ax, block) - -numCompletedTrials = block.numCompletedTrials; -contrast = zeros(1,numCompletedTrials); -resp = zeros(1,numCompletedTrials); -repeatNum = zeros(1,numCompletedTrials); - -conds = [block.trial.condition]; -resp = [block.trial.responseMadeID]; -repeatNum = [conds.repeatNum]; -contrast = psy.visualContrasts(block.trial); -if size(contrast, 1) > 1 - contrast = diff(contrast, [], 1); -else - contrast = sign([conds.cueOrientation]).*contrast; -end - -if isfield(conds, 'targetAltitude') - useAlt = true; - lowAltTrials = [conds.targetAltitude]==min([conds.targetAltitude]); - highAltTrials = [conds.targetAltitude]>min([conds.targetAltitude]); - -else - useAlt = false; -end - -% for t = 1:block.numCompletedTrials -% -% if block.trial(t).condition.visCueContrast(1)>0 -% contrast(t) = -block.trial(t).condition.visCueContrast(1); -% elseif block.trial(t).condition.visCueContrast(2)>0 -% contrast(t) = block.trial(t).condition.visCueContrast(2); -% else -% contrast(t) = 0; -% end -% -% if isfield(conds, 'cueOrientation') -% contrast(t) = -sign(block.trial(t).condition.cueOrientation)*contrast(t); -% end -% -% % resp(t) = block.trial(t).responseMadeID; -% repeatNum(t) = block.trial(t).condition.repeatNum; -% -% end - -respTypes = unique(resp(resp>0)); -numRespTypes = numel(respTypes); - -cVals = unique(contrast); - -if useAlt - % count zero contrast trials for both high and low - lowAltTrials(contrast==0) = true; - highAltTrials(contrast==0) = true; -end - -psychoM = zeros(numRespTypes,length(cVals)); -psychoMCI = zeros(numRespTypes,length(cVals)); -numTrials = zeros(1,length(cVals)); -numChooseR = zeros(numRespTypes, length(cVals)); -for r = 1:numRespTypes - for c = 1:length(cVals) - - if ~useAlt - incl = repeatNum==1&contrast==cVals(c); - numTrials(c) = sum(incl); - numChooseR(r,c) = sum(resp==respTypes(r)&incl); - - psychoM(r, c) = numChooseR(r,c)/numTrials(c); - psychoMCI(r, c) = 1.96*sqrt(psychoM(r, c)*(1-psychoM(r, c))/numTrials(c)); - - else - incl = repeatNum==1&contrast==cVals(c)&lowAltTrials; - numTrials(1,c,1) = sum(incl); - numChooseR(r,c,1) = sum(resp==respTypes(r)&incl); - - psychoM(r, c,1) = numChooseR(r,c,1)/numTrials(1,c,1); - psychoMCI(r, c,1) = 1.96*sqrt(psychoM(r, c,1)*(1-psychoM(r, c,1))/numTrials(1,c,1)); - - incl = repeatNum==1&contrast==cVals(c)&highAltTrials; - numTrials(1,c,2) = sum(incl); - numChooseR(r,c,2) = sum(resp==respTypes(r)&incl); - - psychoM(r, c,2) = numChooseR(r,c,2)/numTrials(1,c,2); - psychoMCI(r, c,2) = 1.96*sqrt(psychoM(r, c,2)*(1-psychoM(r, c,2))/numTrials(1,c,2)); - end - end -end - -% colors = [0 1 1 -% 1 0 0 -% 0 1 0];%hsv(numRespTypes); -% hsv(3) - -colors(1,:) = [0 0.5 1]; -colors(2,:) = [1 0.5 0]; -colors(3,:) = [0.2 0.2 0.2]; - -for r = 1:numRespTypes - - xdata = 100*cVals; - - if ~useAlt - ydata = 100*psychoM(r,:); - errBars = 100*psychoMCI(r,:); - - plot(ax, xdata, ydata, '-o', 'Color', colors(r,:), 'LineWidth', 2.0); - plot(ax, xdata, ydata+errBars, ':', 'Color', colors(r,:), 'LineWidth', 1.0); - plot(ax, xdata, ydata-errBars, ':', 'Color', colors(r,:), 'LineWidth', 1.0); - - else - ydata = 100*psychoM(r,:,1); - errBars = 100*psychoMCI(r,:,1); - plot(ax, xdata, ydata, '-o', 'Color', colors(r,:), 'LineWidth', 2.0); -% plot(ax, xdata, ydata+errBars, ':', 'Color', colors(r,:), 'LineWidth', 1.0); -% plot(ax, xdata, ydata-errBars, ':', 'Color', colors(r,:), 'LineWidth', 1.0); - - ydata = 100*psychoM(r,:,2); - errBars = 100*psychoMCI(r,:,2); - plot(ax, xdata, ydata, ':o', 'Color', colors(r,:), 'LineWidth', 2.0); -% plot(ax, xdata, ydata+errBars, ':', 'Color', colors(r,:)/2, 'LineWidth', 1.0); -% plot(ax, xdata, ydata-errBars, ':', 'Color', colors(r,:)/2, 'LineWidth', 1.0); - end - - % set all NaN values to 0 so the fill function can proceed just - % skipping over those points. - ydata(isnan(ydata)) = 0; - errBars(isnan(errBars)) = 0; - - %TODO:update to use plt.hshade -% fillhandle = fill([xdata xdata(end:-1:1)],... -% [ydata+errBars ydata(end:-1:1)-errBars(end:-1:1)], colors(r,:),... -% 'Parent', ax); -% set(fillhandle, 'FaceAlpha', 0.15, 'EdgeAlpha', 0); - %,... - - -% hold on; - - -end -ylim(ax, [-1 101]); -if numel(xdata) > 1 - xlim(ax, xdata([1 end])*1.1); -end diff --git a/cortexlab/+psy/plot2AUFC.m b/cortexlab/+psy/plot2AUFC.m index 820e51fb..fd5912c3 100644 --- a/cortexlab/+psy/plot2AUFC.m +++ b/cortexlab/+psy/plot2AUFC.m @@ -69,7 +69,7 @@ function plot2AUFC(ax, block) else contrast = diff(contrast, [], 1); cVals = unique(contrast); - colors = iff(numRespTypes>2,[0 1 1; 0 1 0; 1 0 0], [0 1 1; 1 0 0]); + colors = iff(numRespTypes>2,[0 1 1; 0 1 0; 1 0 1], [0 1 1; 1 0 1]); psychoM = zeros(numRespTypes,length(cVals)); psychoMCI = zeros(numRespTypes,length(cVals)); numTrials = zeros(1,length(cVals)); diff --git a/cortexlab/+tl/bindMpepServer.m b/cortexlab/+tl/bindMpepServer.m index 5e371841..8b24d81e 100644 --- a/cortexlab/+tl/bindMpepServer.m +++ b/cortexlab/+tl/bindMpepServer.m @@ -86,8 +86,10 @@ function processMpep(listener, msg) tls.AlyxInstance = ai; case 'expstart' % create a file path & experiment ref based on experiment info - try - % start Timeline + try % start Timeline + assert(~tlObj.IsRunning, ... + 'Rigbox:tl:bindMpepServer:timelineAlreadyRunning', ... + 'Timeline already started') tlObj.start(info.expRef, tls.AlyxInstance); % re-record the UDP event in Timeline since it wasn't started % when we tried earlier. Treat it as having arrived at time zero. @@ -173,4 +175,3 @@ function log(varargin) end end - diff --git a/cortexlab/README.md b/cortexlab/README.md deleted file mode 100644 index 39af52c0..00000000 --- a/cortexlab/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# README # - -This README would normally document whatever steps are necessary to get your application up and running. - -### What is this repository for? ### - -* Quick summary -* Version -* [Learn Markdown](https://bitbucket.org/tutorials/markdowndemo) - -### How do I get set up? ### - -* Summary of set up -* Configuration -* Dependencies -* Database configuration -* How to run tests -* Deployment instructions - -### Contribution guidelines ### - -* Writing tests -* Code review -* Other guidelines - -### Who do I talk to? ### - -* Repo owner or admin -* Other community or team contact \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index f280874d..2a60cf85 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,15 +1,80 @@ ## Documentation: -This 'docs' folder contains files that are useful for learning how to use and set up Rigbox. +This 'docs' folder contains files that are useful for learning how to use and set up Rigbox. To view the docs in HTML open `docs/html/index.html` in the MATLAB browser: +```matlab +root = fileparts(which('addRigboxPaths')); +url = ['file:///', fullfile(root, 'docs', 'html', 'index.html')]; +web(url) +``` -## Contents: +### Contents: For setting up a new rig, reading the files in the following order is recommended: - `setup/paths_config.m` - How to set the locations of experiment data and configuration files. - `setup/hardware_config.m` - How to configure hardware on the stimulus computer. - `setup/websocket_config.m` - Setting up communication between the stimulus computer and MC. - `using_using_dat_package.m` - How to query data locations and log experiments. +- `../signals/docs/tutorials/using_test_gui.m` - How to use the Signals Experiment test GUI. - `../signals/docs/tutorials/SignalsPrimer.m` - How to create experiments in signals. - `using_parameters.m` - How to create and edit experiment parameters. - `using_timeline.m` - Using Timeline for time alignment. - `using_services.m` - Setting up auxiliary services. -- `../alyx-matlab/docs/AlyxMatlabPrimer.m` - How to interact with an Alyx database. \ No newline at end of file +- `using_ExpPanel.m` - Setting up a custom Experiment Panel for a Signals expDef +- `../alyx-matlab/docs/AlyxMatlabPrimer.m` - How to interact with an Alyx database. + +## Code organization: +Below is a list of Rigbox's subdirectories and an overview of their respective contents. For more details, see the REAME.md and Contents.m files for each package folder. + +### +dat +The 'data' package contains code pertaining to the organization and logging of data. It contains functions that generate and parse unique experiment reference ids, and return file paths where subject data and rig configuration information is stored. Other functions include those that manage experimental log entries and parameter profiles. A nice metaphor for this package is a lab notebook. + +### +eui +The 'experiment user interface' package contains code pertaining to the Rigbox user interface. It contains code for constructing the mc GUI (MControl.m), and for plotting live experiment data or generating tables for viewing experiment parameters and subject logs. + +This package is exclusively used by the master computer. + +### +exp +The 'experiment' package is for the initialization and running of behavioural experiments. It contains code that define a framework for event- and state-based experiments. Actions such as visual stimulus presentation or reward delivery can be controlled by experiment phases, and experiment phases are managed by an event-handling system (e.g. ResponseEventInfo). + +The package also triggers auxiliary services (e.g. starting remote acquisition software), and loads parameters for presentation for each trial. The principle two base classes that control these experiments are 'Experiment' and its 'signals package' counterpart, 'SignalsExp'. + +### +hw +The 'hardware' package is for configuring, and interfacing with, hardware (such as screens, DAQ devices, weighing scales and lick detectors). Within this is the '+ptb' package which contains classes for interacting with PsychToolbox. + +'devices.m' loads and initializes all the hardware for a specific experimental rig. There are also classes for unifying system and hardware clocks. + +### +psy +The 'psychometrics' package contains simple functions for processing and plotting psychometric data. + +### +srv +The 'stim server' package contains the 'expServer' function as well as classes that manage communications between rig computers. + +The 'Service' base class allows the stimulus computer to start and stop auxiliary acquisition systems at the beginning and end of experiments. + +The 'StimulusControl' class is used by the master computer to manage the stimulus computer. + +*Note*: Lower-level communication protocol code is found in the 'cortexlab/+io' package. + +### cb-tools/burgbox +'Burgbox' contains many simple helper functions that are used by the main packages. Within this directory are additional packages: + +* +bui --- Classes for managing graphics objects such as axes +* +aud --- Functions for interacting with PsychoPortAudio +* +file --- Functions for simplifying directory and file management, for instance returning the modified dates for specified folders or filtering an array of directories by those that exist +* +fun --- Convenience functions for working with function handles in MATLAB, e.g. functions similar cellfun that are agnostic of input type, or ones that cache function outputs +* +img --- Classes that deal with image and frame data (DEPRECATED) +* +io --- Lower-level communications classes for managing UDP and TCP/IP Web sockets +* +plt --- A few small plotting functions (DEPRECATED) +* +vis --- Functions for returning various windowed visual stimuli (i.g. gabor gratings) +* +ws --- An early Web socket package using SuperWebSocket (DEPRECATED) + +### cortexlab +The 'cortexlab' directory is intended for functions and classes that are rig or CortexLab specific, for example, code that allows compatibility with other stimulus presentation packages used by CortexLab (e.g. MPEP) + +### tests +The 'tests' directory contains code for running unit tests within Rigbox. + +### docs +Contains various guides for how to configure and use Rigbox. + +### submodules +Additional information on the [alyx-matlab](https://github.com/cortex-lab/alyx-matlab), [npy-matlab](https://github.com/kwikteam/npy-matlab), [signals](https://github.com/cortex-lab/signals) and [wheelAnalysis](https://github.com/cortex-lab/wheelAnalysis) submodules can be found in their respective github repositories. diff --git a/docs/setup/html/hardware_config.html b/docs/html/Burgess_setup.html similarity index 53% rename from docs/setup/html/hardware_config.html rename to docs/html/Burgess_setup.html index 0f8575d8..00113580 100644 --- a/docs/setup/html/hardware_config.html +++ b/docs/html/Burgess_setup.html @@ -6,7 +6,7 @@ hardware_config

Contents

Configuring hardware devices

When running SRV.EXPSERVER the hardware settings are loaded from a MAT file and initialized before an experiment. The MC computer may also have a hardware file, though this isn't essential. The below script is a guide for setting up a new hardware file, with examples mostly pertaining to replicating the Burgess steering wheel task(1). Not all uncommented lines will run without error, particularly when a specific hardware configuration is required. Always read the preceeding text before running each line.

It is recommended that you copy this file and keep it as a way of versioning your hardware configurations. In this way the script can easily be re-run when after any unintended changes are made to your hardware file. If you do this, make sure you save a copy of the calibration strutures or re-run the calibrations.

Note that the variable names saved to the hardware file must be the same as those below in order for various Rigbox functions to recognize them, namely these variables:

  • stimWindow - The hw.Window object for PsychToolbox parameters
  • stimViewingModel - A viewing model used by legacy experiments
  • mouseInput - The rotary encoder device
  • lickDetector - A lick detector device
  • timeline - The Timeline object
  • daqController - NI DAQ output settings for use during an experiment
  • scale - A weighing scale device for use by the MC computer
  • screens - Parameters for the Signals viewing model
  • audioDevices - Struct of parameters for each audio device
% Many of these classes for are found in the HW package:
-doc hw
-

Retrieving hardware file path

The location of the configuration file is set in DAT.PATHS. If running this on the stimulus computer you can use the following syntax:

hardware = fullfile(getOr(dat.paths, 'rigConfig'), 'hardware.mat');
-
-% For more info on setting the paths and using the DAT package:
-rigbox = getOr(dat.paths, 'rigbox'); % Location of Rigbox code
-open(fullfile(rigbox, 'docs', 'setup', 'paths_config.m'))
-open(fullfile(rigbox, 'docs', 'using_dat_package.m'))
-

Configuring the stimulus window

The +hw Window class is the main class for configuring the visual stimulus window. It contains the attributes and methods for interacting with the lower level functions that interact with the graphics drivers. Currently the only concrete implementation is support for the Psychophysics Toolbox, the HW.PTB.WINDOW class.

doc hw.ptb.Window
-stimWindow = hw.ptb.Window;
-
-% Most of the properties directly mirror PsychToolbox parameters, therefore
-% it's recommended to check their documentation for clarification:
-%
-help Screen
-Screen OpenWindow? % Most properties are used as inputs to this function
-
-% Look at these for a deeper understanding of PTB:
-help PsychDemos
-help PsychBasic
-
-% Below are some of the more important properties:
-%
-

ScreenNum

The Windows screen index to display the stimulus on. If Windows detects just one monitor (even if you have more plugged into the graphics card), set this to 0 (meaning all screens). Otherwise if you want just the primary display (the one with the menu bar), set it to 1; secondary to 2, etc.

stimWindow.ScreenNum = 0; % Use the single, main screen
-

SyncBounds

The area over which you can place a photodiode to record stimiulus update times. A 4-element vector with [topLeftX topLeftY bottomRightX bottomRightY] results in a square in that location that flips between the values in SyncColourCycle each time the window updates (see 'Screen Flip?'). By default the sync square alternates between black and white. These filps can be acquired by Timeline in order to record the times at which stimuli actually appeared on the monitor. (See 'Timeline' section below)

% Leave this empty if you don't need to record the screen update times.  By
-% convention pixel [0, 0] is defined as the top left-most pixel of the
-% monitor. For a screen of 1024 px height create a 100 px^2 sync patch in
-% the bottom left corner of the screen:
-stimWindow.SyncBounds = [0 924 100 1024];
-% The simplist way to set this is with the POSITIONSYNCREGION method.
-% Let's put a 100 px^2 sync square in the top right of the window:
-stimWindow.positionSyncRegion('NorthEast', 100, 100)
-

SyncColourCycle

A vector of luminance values or nx3 matrix RGB values to cycle through each time the window updates. Starts at the first index / row. Cycle between black and white on each flip:

stimWindow.SyncColourCycle = [0; 255];
-

PxDepth

Sets the depth (in bits) of each pixel; default is 32 bits. You can usually simply set it based on what the system uses:

Screen PixelSize? % More info here
-stimWindow.PxDepth = Screen('PixelSize', stimWindow.ScreenNum);
-

OpenBounds

The size and position of the window. When left empty the screen will cover the entire screen. For debugging it is useful to set the bounds:

res = Screen('Resolution', stimWindow.ScreenNum);
-% Set to 800x600 window 50 px from the top left:
-stimWindow.OpenBounds = [50,50,850,650];
-

DaqSyncEchoPort

The DaqSyncEchoPort is the channel on which to output a pulse each time the stimulus window is re-drawn. This can be useful for convolving the photodiode signal during analysis, particularly when the photodiode trace is noisy. It can also be a way of confirming whether a photodiode is detecting all of the sync square changes. Note: Sync pulses are not yet supported in Signals, only in legacy experiments.

% If this is left empty no sync pulse is set up.
-% Ensure the DaqVendor and DaqDev properties are set correctly.
-daq.getVendors % Query availiable vendors
-daq.getDevices % Query availiable devices and their IDs
-DaqSyncEchoPort = 'port1/line0'; % Output fulse on first digital output chan
-

BackgroundColour

The clut index (scalar, [r g b] triplet or [r g b a] quadruple) defining background colour of the stimulus window during legacy experiments. These should be integers between 0-255. If empty the default is usually middle grey:

stimWindow.BackgroundColour = 127*[1 1 1];
-
-% Note that for Signals experiment, the background colour can currently
-% only be at when calling SRV.EXPSERVER before an experiment, e.g.
-srv.expServer([], [0 127 127]) % Run the experiment with no red gun
-

MonitorId

A handy place to store the make or model of monitor used at that rig. As a copy of the hardware is saved each experiment this may be useful for when looking back at old experiments in the future:

stimWindow.MonitorId = 'LG LP097QX1'; % The screens used in Burgess et al.
-

PtbSyncTests

A logical indicting whether or not to test synchronization to retrace upon open. When true it tests whether buffer flips are properly synchronized to the vertical retrace signal of your display. If these tests fail PTB throws a warning but continues as normal. Synchronization failiures indicate that there tareing or flickering may occur during stimulus presentation. More info on this may be found here:

web('http://psychtoolbox.org/docs/SyncTrouble')
-% When blank the global setting is used:
-not(Screen('Preference', 'SkipSyncTests')) % Default true; run tests
-stimWindow.PtbSyncTests = true;
-

PtbVerbosity

A number from 0 to 5 indicating the level of verbosity during the experiment. If empty the global preference is used.

Screen('Preference', 'Verbosity') % Global verbosity setting
-% Below are the levels:
-% 0 - Disable all output - Same as using the SuppressAllWarnings flag.
-% 1 - Only output critical errors.
-% 2 - Output warnings as well.
-% 3 - Output startup information and a bit of additional information. This
-%     is the default.
-% 4 - Be pretty verbose about information and hints to optimize your code
-%     and system.
-% 5 - Levels 5 and higher enable very verbose debugging output, mostly
-%     useful for debugging PTB itself, not generally useful for end-users.
-stimWindow.PtbVerbosity = 2;
-

ColourRange, White, Black, etc.

These properties are set by the object iteself after running OPEN, based on the colour depth of the screen. For more info see these docs:

help WhiteIndex
-help BlackIndex
-

- Performing gamma calibration from command window

Calibration

This stores the gamma correction tables (See Below) The simplist way to to run the calibration is through SRV.EXPSEERVER once the rest of the hardware is configures, however it can also be done via the command window, assuming you have an NI DAQ installed:

lightIn = 'ai0'; % The input channel of the photodiode used to measure screen
+  

Burgess steering wheel

Below are some reasonable default hardware configurations for the Burgess steering wheel task

Contents

Retrieving hardware file path

The location of the configuration file is set in DAT.PATHS. If running this on the stimulus computer you can use the following syntax:

hardware = fullfile(getOr(dat.paths, 'rigConfig'), 'hardware.mat');
+

Configuring the stimulus window

The +hw Window class is the main class for configuring the visual stimulus window. It contains the attributes and methods for interacting with the lower level functions that interact with the graphics drivers. Currently the only concrete implementation is support for the Psychophysics Toolbox, the HW.PTB.WINDOW class.

stimWindow = hw.ptb.Window;
+

ScreenNum

The Windows screen index to display the stimulus on. If Windows detects just one monitor (even if you have more plugged into the graphics card), set this to 0 (meaning all screens). Otherwise if you want just the primary display (the one with the menu bar), set it to 1; secondary to 2, etc.

stimWindow.ScreenNum = 0; % Use all screen
+

SyncBounds:

The area over which you can place a photodiode to record stimiulus update times. The simplist way to set this is with the POSITIONSYNCREGION method. Let's put a 100 px^2 sync square in the top right of the window:

stimWindow.positionSyncRegion('NorthEast', 100, 100)
+

PxDepth

Sets the depth (in bits) of each pixel; default is 32 bits. You can usually simply set it based on what the system uses:

stimWindow.PxDepth = Screen('PixelSize', stimWindow.ScreenNum);
+

OpenBounds

The size and position of the window. When left empty the screen will cover the entire screen:

stimWindow.OpenBounds = [];
+

MonitorId

A handy place to store the make or model of monitor used at that rig. As a copy of the hardware is saved each experiment this may be useful for when looking back at old experiments in the future:

stimWindow.MonitorId = 'LG LP097QX1'; % The screens used in Burgess et al.
+

- Performing gamma calibration from command window

Calibration

This stores the gamma correction tables (See Below) The simplist way to to run the calibration is through SRV.EXPSEERVER once the rest of the hardware is configures, however it can also be done via the command window, assuming you have an NI DAQ installed:

lightIn = 'ai0'; % The input channel of the photodiode used to measure screen
 clockIn = 'ai1'; % The clocking pulse input channel
 clockOut = 'port1/line0 (PFI4)'; % The clocking pulse output channel
 % Connect the photodiode to `lightIn` and user a jumper to bridge a
@@ -144,95 +84,7 @@
 
 
 save(hardware, 'stimWindow', '-append') % Save the stimWindow to file
-

Using the Window object

Let's check the Window object is set up correctly and explore some of the methods...

- Setting the background colour

stimWindow.open() % Open the window
-stimWindow.BackgroundColour = stimWindow.Green; % Change the background
-stimWindow.flip(); % Whoa!
-

- Displaying a Gabor patch

Make a texture and draw it to the screen with MAKETEXTURE and DRAWTEXTURE Let's make a Gabor patch as an example:

sz = 1000; % size of texture matrix
-[xx, yy] = deal(linspace(-sz/2,sz/2,sz)');
-phi = 2*pi*rand; % randomised cosine phase
-sigma = 100; % size of Gaussian window
-thetaCos = 90; % grating orientation
-lambda = 100; % spatial frequency
-targetImg = vis.gabor(xx, yy, sigma, sigma, lambda, 0, thetaCos, phi);
-blankImg = repmat(stimWindow.Gray, [size(targetImg), 1]);
-targetImg = repmat(targetImg, [1 1 3]); % replicate three colour channels
-targetImg = round(blankImg.*(1 + targetImg));
-targetImg = min(max(targetImg, 0), 255); % Rescale values to 0-255
-
-% Convert the Gabor image to an OpenGL texture and load into buffer.
-% For more info: Screen MakeTexture?, Screen PreloadTextures?
-tex = stimWindow.makeTexture(round(targetImg));
-% Draw the texture into window (More info: Screen DrawTexture?)
-stimWindow.drawTexture(tex)
-% Flip the buffer:
-stimWindow.flip;
-

- Clearing the window

To clear the window, the use CLEAR method:

stimWindow.clear % Re-draw background colour
-stimWindow.flip; % Flip to screen
-

- Drawing text to the screen

Drawing text to the screen can be done with the DRAWTEXT method:

[x, y] = deal('center'); % Render the text to the center
-[nx, ny] = stimWindow.drawText('Hello World', x, y, stimWindow.Red);
-stimWindow.flip;
-
-% The nx and ny outputs may be used again as inputs to add to the text:
-[nx, ny] = stimWindow.drawText('Hello World', x, y, stimWindow.Red);
-stimWindow.drawText('! What''s up?', nx, ny, stimWindow.Red);
-stimWindow.flip;
-

- Closing a window

Finally lets clear and close the window:

stimWindow.clear
-stimWindow.close
-

Viewing models

The viewing model classes allow one to configure the relationship between physical dimentions and pixel space. The viewing model classes contain methods for converting between visual degrees, pixels and physical dimentions. Note: The viewing model classes are currently only implemented in legacy experiments such as ChoiceWorld. See below section for configuring the viewing model in Signals.

There are currently two viewing model classes to choose from...

- Basic screen viewing model

The basic viewing model class deals with single screens positioned straight in front of an observer (^):| ___ | ^

doc hw.BasicScreenViewingModel
-
-% Let's set this up:
-stimViewingModel = hw.BasicScreenViewingModel;
-% There are three parameters to set:
-% A position vector [x,y,z] of the subject in metres, with respect to
-% the (centre of the) top left pixel of the screen. x and y are aligned
-% with the standard graphics axes (i.e. x to the right, y going down),
-% while z extends out from the screen perpendicular to the plane of the
-% display).
-stimViewingModel.SubjectPos = [0, 0, 0.5]; % Observer centered at 50cm from screen
-
-% Number of pixels across the screen. Also see the function
-% USEGRAPHICSPIXELWIDTH to deduce this directly from the graphics hardware:
-stimViewingModel.useGraphicsPixelWidth(stimWindow.ScreenNum)
-stimViewingModel.ScreenWidthPixels % e.g. 1900 px
-
-% The physical width of the screen, in metres. Pixels are assumed to have a
-% 1:1 aspect ratio.
-stimViewingModel.ScreenWidthMetres = 0.4750;
-
-save(hardware, 'stimViewingModel', '-append')
-

-- Using the model

The object contains useful methods for converting between visual and graphics space:

% Visual field coordinates of a specified pixel.  The presumed
-% 'straight-ahead' view pixel should map to the centre of the visual field
-% (zero polar and visual angles)
-x = 0; y = 100; % Convert this pixel coordinate to visual angle
-[polarAngle, visualAngle] = stimViewingModel.viewAtPixel(x, y)
-% Polar angle is just the angle from central fixation pixel to specified
-% (and increases anticlockwise from horizon->right).
-
-% We can get the screen pixel of a given visual field locus. This may be
-% useful e.g. for placing stimuli at a certain point in the subject's visual
-% field. Let's convert
-% back to pixel space:
-[x, y] = stimViewingModel.pixelAtView(polarAngle, visualAngle) % ~[0, 100]
-
-% Visual angle between two pixel points.  This is useful if you want to
-% measure graphics dimensions in visual angles:
-[x2, y2] = deal(0); % Compare above to centre pixel
-rad = stimViewingModel.visualAngleBetweenPixels(x, y, x2, y2)
-% Radians to degrees:
-deg = rad2deg(rad)
-
-% Return the 'visual' pixel density (px per rad) at a point.  This is
-% useful for choosing spatial frequency of stimuli at a certain point on
-% the screen:
-pxPerRad = stimViewingModel.visualPixelDensity(x, y)
-% Screen distance in pixels, d, as a function of visual angle, t:
-% d(t) = zPx*tan(t)
-% Derivative w.r.t. t yields pixel density at a given visual angle:
-% d'(t) = zPx*sec(t)^2
-

- Pseudo-Circular screen viewing model

doc hw.PseudoCircularScreenViewingModel
-
-stimViewingModel = hw.PseudoCircularScreenViewingModel
-

- Signals viewing model

Signals currently only supports a single viewing odel. For now the function VIS.SCREEN is used to configure this. Below is an example of configuring the viewing model for the Burgess wheel task, where there are three small screens located at right-angles to one another:

help vis.screen
+

- Signals viewing model

Signals currently only supports a single viewing odel. For now the function VIS.SCREEN is used to configure this. Below is an example of configuring the viewing model for the Burgess wheel task, where there are three small screens located at right-angles to one another:

help vis.screen
 % Below is a schematic of the screen configuration (top-down view).
 % ^ represents the observer:
 %   _____
@@ -257,7 +109,7 @@
 screens(3) = vis.screen(centerPt(3,:), angle(3), screenDimsCm, [2*pxW  0 3*pxW pxH]); % right screen
 
 save(hardware, 'screens', '-append');
-

Hardware inputs

In this example we will add two inputs, a DAQ rotatary encoder and a beam lick detector.

- DAQ rotary encoder

Create a input for the Burgess LEGO wheel using the HW.DAQROTARYENCODER class:

doc hw.DaqRotaryEncoder % More details for this class
+

Hardware inputs

In this example we will add two inputs, a DAQ rotatary encoder and a beam lick detector.

- DAQ rotary encoder

Create a input for the Burgess LEGO wheel using the HW.DAQROTARYENCODER class:

doc hw.DaqRotaryEncoder % More details for this class
 mouseInput = hw.DaqRotaryEncoder;
 
 % To deteremine what devices you have installed and their IDs:
@@ -280,7 +132,7 @@
 mouseInput.EncoderResolution = 1024
 % Diameter of the wheel in mm
 mouseInput.WheelDiameter = 62
-

- Lick detector

A beam lick detector may be configured to work with an edge counter channel. We can use the HW.DAQEDGECOUNTER class for this:

lickDetector = hw.DaqEdgeCounter;
+

- Lick detector

A beam lick detector may be configured to work with an edge counter channel. We can use the HW.DAQEDGECOUNTER class for this:

lickDetector = hw.DaqEdgeCounter;
 
 % This is actually a subclass of the HW.DAQROTARYENCODER class, and
 % therefore has a few irrelevant properties such as WheelDiameter.  These
@@ -294,7 +146,7 @@
 
 % Save these two into our hardware file
 save(hardware, 'stimWindow', 'lickDetector', '-append')
-

Hardware outputs

HW.DAQCONTROLLER

doc hw.DaqController
+

Hardware outputs

HW.DAQCONTROLLER

doc hw.DaqController
 daqController = hw.DaqController;
 
 % This class deals with creating DAQ sessions, assigning output
@@ -324,7 +176,7 @@
 
 % Save your hardware file
 save(hardware, 'daqController', '-append');
-

Timeline

Timeline unifies various hardware and software times using a DAQ device.

doc hw.Timeline
+

Timeline

Timeline unifies various hardware and software times using a DAQ device.

doc hw.Timeline
 
 % Let's create a new object and configure some channels
 timeline = hw.Timeline
@@ -369,7 +221,7 @@
 % For more information on configuring and using Timeline, see
 % USING_TIMELINE:
 open(fullfile(getOr(dat.paths,'rigbox'), 'docs', 'using_timeline.m'))
-

Weigh scale

MC allows you to log weights through the GUI by interfacing with a digital scale connected via a COM port. This is the only object of use in the MC computer's hardware file.

scale = hw.WeighingScale
+

Weigh scale

MC allows you to log weights through the GUI by interfacing with a digital scale connected via a COM port. This is the only object of use in the MC computer's hardware file.

scale = hw.WeighingScale
 
 % The Name field should be set to the name or product code of the scale you
 % connect.
@@ -391,7 +243,7 @@
 
 %Save your hardware.mat file
 save(hardware, 'scale', '-append')
-

- Using the scale

The methods are rather self-explanatory. To use the scale the port must first be opened using the INIT method:

scale.init()
+

- Using the scale

The methods are rather self-explanatory. To use the scale the port must first be opened using the INIT method:

scale.init()
 
 % To tare (zero) the scale, use the TARE method:
 scale.tare()
@@ -406,7 +258,7 @@
 
 % To clean up you can simply clear the object from the workspace:
 clear scale lh
-

Audio devices

InitializePsychSound
+

Audio devices

InitializePsychSound
 devs = PsychPortAudio('GetDevices')
 % Sanitize the names
 names = matlab.lang.makeValidName({devs.DeviceName}, 'ReplacementStyle', 'delete');
@@ -415,7 +267,7 @@
 audioDevices = devs;
 
 save(hardware, 'audioDevices', '-append')
-

Loading your hardware file

To load your rig hardware objects for testing at a rig, you can use HW.DEVICES:

rig = hw.devices;
+

Loading your hardware file

To load your rig hardware objects for testing at a rig, you can use HW.DEVICES:

rig = hw.devices;
 
 % To load the hardware file or a different rig, you can input the rig name.
 % Note HW.DEVICES initializes some of the hardware by default, including
@@ -424,7 +276,7 @@
 rigName = 'ZREDONE';
 initialize = false;
 rig = hw.devices(rigName, initialize);
-

FAQ

I tried loading an old hardware file but the variables are not objects.

This was probably accompanied with an error such as: * % Warning: Variable 'rewardController' originally saved as a hw.DaqRewardValve cannot be instantiated as an object and will be read in as a uint32. *

% This usually means that there has been a substantial change in the code
+

FAQ

I tried loading an old hardware file but the variables are not objects.

This was probably accompanied with an error such as: * % Warning: Variable 'rewardController' originally saved as a hw.DaqRewardValve cannot be instantiated as an object and will be read in as a uint32. *

% This usually means that there has been a substantial change in the code
 % since the object was last saved and MATLAB can no longer load it into the
 % workspace.  One solution is to revert your code to a release dated around
 % the time of the hardware file's modified date:
@@ -433,10 +285,10 @@
 
 % Once you have the previous parameters, create a new object with the
 % current code version, assign the parameters and resave.
-

I'm missing the time of the first flip only, why?

Perhaps the first flip is always too dark a colour. Try reversing the order stimWindow.SyncColourCycle:

scc = stimWindow.SyncColourCycle;
+

I'm missing the time of the first flip only, why?

Perhaps the first flip is always too dark a colour. Try reversing the order stimWindow.SyncColourCycle:

scc = stimWindow.SyncColourCycle;
 scc = iff(size(scc,1) > size(scc,2), @() flipud(scc), @() fliplr(scc));
 stimWindow.SyncColourCycle = scc;
-

The PsychToolbox window covers the wrong monitors when I run the experiment server

Make sure Mosaic is still running (sometimes if the computer loses a monitor input the graphics card disables Mosaic). One indication of this is that the task bar should stretch across all three of the stimulus screens. Also check that the stimWindow.ScreenNum is correct in the hardware.mat file. When set to 0, PsychToolbox uses all screens available to Windows; 1 means Windows’ primary screen (see the Display Settings); 2 means Windows’ secondary screen, etc.

I get a ‘PTB synchronization error’ when I run the experiment server.

This happens from time-to-time. When a PsychToolbox window is opened it runs some synchronization to retrace tests, checking whether buffer flips are properly synchronized to the vertical retrace signal of your display. Synchronization failiures indicate that there tareing or flickering may occur during stimulus presentation. More info on this may be found here :

web('http://psychtoolbox.org/docs/SyncTrouble')
+

The PsychToolbox window covers the wrong monitors when I run the experiment server

Make sure Mosaic is still running (sometimes if the computer loses a monitor input the graphics card disables Mosaic). One indication of this is that the task bar should stretch across all three of the stimulus screens. Also check that the stimWindow.ScreenNum is correct in the hardware.mat file. When set to 0, PsychToolbox uses all screens available to Windows; 1 means Windows’ primary screen (see the Display Settings); 2 means Windows’ secondary screen, etc.

I get a ‘PTB synchronization error’ when I run the experiment server.

This happens from time-to-time. When a PsychToolbox window is opened it runs some synchronization to retrace tests, checking whether buffer flips are properly synchronized to the vertical retrace signal of your display. Synchronization failiures indicate that there tareing or flickering may occur during stimulus presentation. More info on this may be found here :

web('http://psychtoolbox.org/docs/SyncTrouble')
 % The problem may be exacerbated if you're running other programs that
 % interfere with the graphics, such as remote window viewers (VNC, Remote
 % Desktpo, etc.), or if you are running multiple monitors that do not have
@@ -444,59 +296,29 @@
 % If you know what you're doing and are confident that things are working,
 % you can skip the tests by setting the following property:
 stimWindow.PtbSyncTests = false;
-

Error using hw.DaqRotaryEncoder/readAbsolutePosition (line 143)

NI Error -88709 ?or Error using hw.DaqRotaryEncoder/createDaqChannel (line 81): The requested subsystem 'CounterInput' does not exist on this device.

% This happens from time to time, particularly after the computer has gone
+

Error using hw.DaqRotaryEncoder/readAbsolutePosition (line 143)

NI Error -88709 ?or Error using hw.DaqRotaryEncoder/createDaqChannel (line 81): The requested subsystem 'CounterInput' does not exist on this device.

% This happens from time to time, particularly after the computer has gone
 % to sleep. Unplugging the DAQ USB cable and plugging it back in helps.
 % Restart MATLAB. If the error persists, restart the computer with the DAQ
 % unplugged.
-

The experiment server is unable to open my DAQ on ‘Dev1’

If you have multiple NI devices on this computer, set the DaqIds properties to the correct id in your hardware.mat file, i.e. daqController.DaqIds, mouseInput.DaqId, rewardController.DaqId

d = daq.getDevices % Availiable devices and their info
-

My rotary encoder has a different resolution, how do I change the hardware config?

Change the mouseInput.EncoderResolution peroperty to the value found at the end of your rotary encoder’s product number: e.g. 05.2400.1122.1024 means EncoderResolution = 1024.

Notes

(1) DOI:10.1016/j.celrep.2017.08.047

Etc.

%#ok<*NOPTS>
+

The experiment server is unable to open my DAQ on ‘Dev1’

If you have multiple NI devices on this computer, set the DaqIds properties to the correct id in your hardware.mat file, i.e. daqController.DaqIds, mouseInput.DaqId, rewardController.DaqId

d = daq.getDevices % Availiable devices and their info
+

My rotary encoder has a different resolution, how do I change the hardware config?

Change the mouseInput.EncoderResolution peroperty to the value found at the end of your rotary encoder’s product number: e.g. 05.2400.1122.1024 means EncoderResolution = 1024.

Notes

(1) DOI:10.1016/j.celrep.2017.08.047

Etc.

Author: Miles Wells v1.1.0

%#ok<*NOPTS>
 %#ok<*NASGU>
 %#ok<*ASGLU>
-
Introduction

Introduction

When running srv.expServer the hardware settings are loaded from a MAT file and initialized before an experiment. The MC computer may also have a hardware file, though this isn't essential. The below script is a guide for setting up a new hardware file, with examples mostly pertaining to replicating the Burgess steering wheel task(1). Not all uncommented lines will run without error, particularly when a specific hardware configuration is required. Always read the preceeding text before running each line.

It is recommended that you copy this file and keep it as a way of versioning your hardware configurations. In this way the script can easily be re-run when after any unintended changes are made to your hardware file. If you do this, make sure you save a copy of the calibration strutures or re-run the calibrations.

Note that the variable names saved to the hardware file must be the same as those below in order for various Rigbox functions to recognize them, namely these variables:

  • stimWindow - The hw.Window object for PsychToolbox parameters
  • stimViewingModel - A viewing model used by legacy experiments
  • mouseInput - The rotary encoder device
  • lickDetector - A lick detector device
  • timeline - The Timeline object
  • daqController - NI DAQ output settings for use during an experiment
  • scale - A weighing scale device for use by the MC computer
  • screens - Parameters for the Signals viewing model
  • audioDevices - Struct of parameters for each audio device

NB: Not all uncommented lines will run without error, particularly when a specific hardware configuration is required. Always read the preceeding text before running each line.

Contents

The +hw packages

Many of these classes for are found in the HW package. This package contains all of the code that interfaces with lower-level hardware code (e.g. PsychToolbox's OpenGL functions, the NI +daq package):

doc hw
-

Retrieving hardware file path

The location of the configuration file is set in DAT.PATHS. If running this on the stimulus computer you can use the following syntax:

hardware = fullfile(getOr(dat.paths, 'rigConfig'), 'hardware.mat');
+  

Contents

Configuring hardware devices

When running SRV.EXPSERVER the hardware settings are loaded from a MAT file and initialized before an experiment. The MC computer may also have a hardware file, though this isn't essential. The below script is a guide for setting up a new hardware file, with examples mostly pertaining to replicating the Burgess steering wheel task(1). Not all uncommented lines will run without error, particularly when a specific hardware configuration is required. Always read the preceeding text before running each line.

It is recommended that you copy this file and keep it as a way of versioning your hardware configurations. In this way the script can easily be re-run when after any unintended changes are made to your hardware file. If you do this, make sure you save a copy of the calibration strutures or re-run the calibrations.

Note that the variable names saved to the hardware file must be the same as those below in order for various Rigbox functions to recognize them, namely these variables:

  • stimWindow - The hw.Window object for PsychToolbox parameters
  • stimViewingModel - A viewing model used by legacy experiments
  • mouseInput - The rotary encoder device
  • lickDetector - A lick detector device
  • timeline - The Timeline object
  • daqController - NI DAQ output settings for use during an experiment
  • scale - A weighing scale device for use by the MC computer
  • screens - Parameters for the Signals viewing model
  • audioDevices - Struct of parameters for each audio device
% Many of these classes for are found in the HW package:
+doc hw
+

Retrieving hardware file path

The location of the configuration file is set in DAT.PATHS. If running this on the stimulus computer you can use the following syntax:

hardware = fullfile(getOr(dat.paths, 'rigConfig'), 'hardware.mat');
 
 % For more info on setting the paths and using the DAT package:
 rigbox = getOr(dat.paths, 'rigbox'); % Location of Rigbox code
 open(fullfile(rigbox, 'docs', 'setup', 'paths_config.m'))
 open(fullfile(rigbox, 'docs', 'using_dat_package.m'))
-

Configuring the stimulus window

The +hw Window class is the main class for configuring the visual stimulus window. It contains the attributes and methods for interacting with the lower level functions that interact with the graphics drivers. Currently the only concrete implementation is support for the Psychophysics Toolbox, the hw.ptb.Window class.

doc hw.ptb.Window
+

Configuring the stimulus window

The +hw Window class is the main class for configuring the visual stimulus window. It contains the attributes and methods for interacting with the lower level functions that interact with the graphics drivers. Currently the only concrete implementation is support for the Psychophysics Toolbox, the HW.PTB.WINDOW class.

doc hw.ptb.Window
 stimWindow = hw.ptb.Window;
 
 % Most of the properties directly mirror PsychToolbox parameters, therefore
@@ -177,7 +178,7 @@
 stimWindow.flip;
 

- Closing a window

Finally lets clear and close the window:

stimWindow.clear
 stimWindow.close
-

Viewing models

The viewing model classes allow one to configure the relationship between physical dimentions and pixel space. The viewing model classes contain methods for converting between visual degrees, pixels and physical dimentions. Note: The viewing model classes are currently only implemented in legacy experiments such as ChoiceWorld. See below section for configuring the viewing model in Signals.

There are currently two viewing model classes to choose from...

Basic screen viewing model

The basic viewing model class deals with single screens positioned straight in front of an observer (^):| ___ | ^

doc hw.BasicScreenViewingModel
+

Viewing models

The viewing model classes allow one to configure the relationship between physical dimentions and pixel space. The viewing model classes contain methods for converting between visual degrees, pixels and physical dimentions. Note: The viewing model classes are currently only implemented in legacy experiments such as ChoiceWorld. See below section for configuring the viewing model in Signals.

There are currently two viewing model classes to choose from...

- Basic screen viewing model

The basic viewing model class deals with single screens positioned straight in front of an observer (^):| ___ | ^

doc hw.BasicScreenViewingModel
 
 % Let's set this up:
 stimViewingModel = hw.BasicScreenViewingModel;
@@ -199,7 +200,7 @@
 stimViewingModel.ScreenWidthMetres = 0.4750;
 
 save(hardware, 'stimViewingModel', '-append')
-

Using the model

The object contains useful methods for converting between visual and graphics space:

% Visual field coordinates of a specified pixel.  The presumed
+

-- Using the model

The object contains useful methods for converting between visual and graphics space:

% Visual field coordinates of a specified pixel.  The presumed
 % 'straight-ahead' view pixel should map to the centre of the visual field
 % (zero polar and visual angles)
 x = 0; y = 100; % Convert this pixel coordinate to visual angle
@@ -228,10 +229,10 @@
 % d(t) = zPx*tan(t)
 % Derivative w.r.t. t yields pixel density at a given visual angle:
 % d'(t) = zPx*sec(t)^2
-

Pseudo-Circular screen viewing model

doc hw.PseudoCircularScreenViewingModel
+

- Pseudo-Circular screen viewing model

doc hw.PseudoCircularScreenViewingModel
 
 stimViewingModel = hw.PseudoCircularScreenViewingModel
-

Signals viewing model

Signals currently only supports a single viewing odel. For now the function VIS.SCREEN is used to configure this. Below is an example of configuring the viewing model for the Burgess wheel task, where there are three small screens located at right-angles to one another:

help vis.screen
+

- Signals viewing model

Signals currently only supports a single viewing odel. For now the function VIS.SCREEN is used to configure this. Below is an example of configuring the viewing model for the Burgess wheel task, where there are three small screens located at right-angles to one another:

help vis.screen
 % Below is a schematic of the screen configuration (top-down view).
 % ^ represents the observer:
 %   _____
@@ -256,7 +257,7 @@
 screens(3) = vis.screen(centerPt(3,:), angle(3), screenDimsCm, [2*pxW  0 3*pxW pxH]); % right screen
 
 save(hardware, 'screens', '-append');
-

Hardware inputs

In this example we will add two inputs, a DAQ rotatary encoder and a beam lick detector.

DAQ rotary encoder

Create a input for the Burgess LEGO wheel using the HW.DAQROTARYENCODER class:

doc hw.DaqRotaryEncoder % More details for this class
+

Hardware inputs

In this example we will add two inputs, a DAQ rotatary encoder and a beam lick detector.

- DAQ rotary encoder

Create a input for the Burgess LEGO wheel using the HW.DAQROTARYENCODER class:

doc hw.DaqRotaryEncoder % More details for this class
 mouseInput = hw.DaqRotaryEncoder;
 
 % To deteremine what devices you have installed and their IDs:
@@ -279,7 +280,7 @@
 mouseInput.EncoderResolution = 1024
 % Diameter of the wheel in mm
 mouseInput.WheelDiameter = 62
-

Lick detector

A beam lick detector may be configured to work with an edge counter channel. We can use the HW.DAQEDGECOUNTER class for this:

lickDetector = hw.DaqEdgeCounter;
+

- Lick detector

A beam lick detector may be configured to work with an edge counter channel. We can use the HW.DAQEDGECOUNTER class for this:

lickDetector = hw.DaqEdgeCounter;
 
 % This is actually a subclass of the HW.DAQROTARYENCODER class, and
 % therefore has a few irrelevant properties such as WheelDiameter.  These
@@ -323,7 +324,7 @@
 
 % Save your hardware file
 save(hardware, 'daqController', '-append');
-

Timeline

Timeline unifies various hardware and software times using a DAQ device. There is a separate guide for Timeline here.

doc hw.Timeline
+

Timeline

Timeline unifies various hardware and software times using a DAQ device.

doc hw.Timeline
 
 % Let's create a new object and configure some channels
 timeline = hw.Timeline
@@ -344,12 +345,18 @@
 % They may be changed by setting the above fields, e.g.
 timeline.Outputs(1).DaqChannelID = 'port1/line1';
 timeline.wiringInfo('chrono'); % New port # displayed
-

Inputs

Add the rotary encoder

timeline.addInput('rotaryEncoder', 'ctr0', 'Position');
+
+% INPUTS
+% Add the rotary encoder
+timeline.addInput('rotaryEncoder', 'ctr0', 'Position');
 % For a lick detector
 timeline.addInput('lickDetector', 'ctr1', 'EdgeCount');
 % For a photodiode (see 'Configuring the visual stimuli' above)
 timeline.addInput('photoDiode', 'ai2', 'Voltage', 'SingleEnded');
-

Outputs

Say we wanted to trigger camera aquisition at a given frame rate:

clockOut = hw.TLOutputClock;
+
+% OUTPUTS
+% Say we wanted to trigger camera aquisition at a given frame rate:
+clockOut = hw.TLOutputClock;
 clockOut.DaqChannelID = 'ctr2'; % Set channal
 clockOut.Name = 'Cam-Trigger'; % A memorable name
 clockOut.Frequency = 180; % Hz
@@ -362,7 +369,7 @@
 % For more information on configuring and using Timeline, see
 % USING_TIMELINE:
 open(fullfile(getOr(dat.paths,'rigbox'), 'docs', 'using_timeline.m'))
-

Weigh scale

MC allows you to log weights through the GUI by interfacing with a digital scale connected via a COM port. This is the only object of use in the MC computer's hardware file.

scale = hw.WeighingScale
+

Weigh scale

MC allows you to log weights through the GUI by interfacing with a digital scale connected via a COM port. This is the only object of use in the MC computer's hardware file.

scale = hw.WeighingScale
 
 % The Name field should be set to the name or product code of the scale you
 % connect.
@@ -384,7 +391,7 @@
 
 %Save your hardware.mat file
 save(hardware, 'scale', '-append')
-

Using the scale

The methods are rather self-explanatory. To use the scale the port must first be opened using the INIT method:

scale.init()
+

- Using the scale

The methods are rather self-explanatory. To use the scale the port must first be opened using the INIT method:

scale.init()
 
 % To tare (zero) the scale, use the TARE method:
 scale.tare()
@@ -399,7 +406,7 @@
 
 % To clean up you can simply clear the object from the workspace:
 clear scale lh
-

Audio devices

InitializePsychSound
+

Audio devices

InitializePsychSound
 devs = PsychPortAudio('GetDevices')
 % Sanitize the names
 names = matlab.lang.makeValidName({devs.DeviceName}, 'ReplacementStyle', 'delete');
@@ -408,7 +415,7 @@
 audioDevices = devs;
 
 save(hardware, 'audioDevices', '-append')
-

Loading your hardware file

To load your rig hardware objects for testing at a rig, you can use hw.devices:

rig = hw.devices;
+

Loading your hardware file

To load your rig hardware objects for testing at a rig, you can use HW.DEVICES:

rig = hw.devices;
 
 % To load the hardware file or a different rig, you can input the rig name.
 % Note HW.DEVICES initializes some of the hardware by default, including
@@ -417,7 +424,7 @@
 rigName = 'ZREDONE';
 initialize = false;
 rig = hw.devices(rigName, initialize);
-

FAQ

I tried loading an old hardware file but the variables are not objects.

This was probably accompanied with an error such as: * % Warning: Variable 'rewardController' originally saved as a hw.DaqRewardValve cannot be instantiated as an object and will be read in as a uint32. *

% This usually means that there has been a substantial change in the code
+

FAQ

I tried loading an old hardware file but the variables are not objects.

This was probably accompanied with an error such as: * % Warning: Variable 'rewardController' originally saved as a hw.DaqRewardValve cannot be instantiated as an object and will be read in as a uint32. *

% This usually means that there has been a substantial change in the code
 % since the object was last saved and MATLAB can no longer load it into the
 % workspace.  One solution is to revert your code to a release dated around
 % the time of the hardware file's modified date:
@@ -426,10 +433,10 @@
 
 % Once you have the previous parameters, create a new object with the
 % current code version, assign the parameters and resave.
-

I'm missing the time of the first flip only, why?

Perhaps the first flip is always too dark a colour. Try reversing the order stimWindow.SyncColourCycle:

scc = stimWindow.SyncColourCycle;
+

I'm missing the time of the first flip only, why?

Perhaps the first flip is always too dark a colour. Try reversing the order stimWindow.SyncColourCycle:

scc = stimWindow.SyncColourCycle;
 scc = iff(size(scc,1) > size(scc,2), @() flipud(scc), @() fliplr(scc));
 stimWindow.SyncColourCycle = scc;
-

The PsychToolbox window covers the wrong monitors when I run the experiment server

Make sure Mosaic is still running (sometimes if the computer loses a monitor input the graphics card disables Mosaic). One indication of this is that the task bar should stretch across all three of the stimulus screens. Also check that the stimWindow.ScreenNum is correct in the hardware.mat file. When set to 0, PsychToolbox uses all screens available to Windows; 1 means Windows’ primary screen (see the Display Settings); 2 means Windows’ secondary screen, etc.

I get a ‘PTB synchronization error’ when I run the experiment server.

This happens from time-to-time. When a PsychToolbox window is opened it runs some synchronization to retrace tests, checking whether buffer flips are properly synchronized to the vertical retrace signal of your display. Synchronization failiures indicate that there tareing or flickering may occur during stimulus presentation. More info on this may be found here :

web('http://psychtoolbox.org/docs/SyncTrouble')
+

The PsychToolbox window covers the wrong monitors when I run the experiment server

Make sure Mosaic is still running (sometimes if the computer loses a monitor input the graphics card disables Mosaic). One indication of this is that the task bar should stretch across all three of the stimulus screens. Also check that the stimWindow.ScreenNum is correct in the hardware.mat file. When set to 0, PsychToolbox uses all screens available to Windows; 1 means Windows’ primary screen (see the Display Settings); 2 means Windows’ secondary screen, etc.

I get a ‘PTB synchronization error’ when I run the experiment server.

This happens from time-to-time. When a PsychToolbox window is opened it runs some synchronization to retrace tests, checking whether buffer flips are properly synchronized to the vertical retrace signal of your display. Synchronization failiures indicate that there tareing or flickering may occur during stimulus presentation. More info on this may be found here :

web('http://psychtoolbox.org/docs/SyncTrouble')
 % The problem may be exacerbated if you're running other programs that
 % interfere with the graphics, such as remote window viewers (VNC, Remote
 % Desktpo, etc.), or if you are running multiple monitors that do not have
@@ -437,17 +444,19 @@
 % If you know what you're doing and are confident that things are working,
 % you can skip the tests by setting the following property:
 stimWindow.PtbSyncTests = false;
-

Error using hw.DaqRotaryEncoder/readAbsolutePosition (line 143)

NI Error -88709 ?or Error using hw.DaqRotaryEncoder/createDaqChannel (line 81): The requested subsystem 'CounterInput' does not exist on this device.

% This happens from time to time, particularly after the computer has gone
+

Error using hw.DaqRotaryEncoder/readAbsolutePosition (line 143)

NI Error -88709 ?or Error using hw.DaqRotaryEncoder/createDaqChannel (line 81): The requested subsystem 'CounterInput' does not exist on this device.

% This happens from time to time, particularly after the computer has gone
 % to sleep. Unplugging the DAQ USB cable and plugging it back in helps.
 % Restart MATLAB. If the error persists, restart the computer with the DAQ
 % unplugged.
-

The experiment server is unable to open my DAQ on ‘Dev1’

If you have multiple NI devices on this computer, set the DaqIds properties to the correct id in your hardware.mat file, i.e. daqController.DaqIds, mouseInput.DaqId, rewardController.DaqId

d = daq.getDevices % Availiable devices and their info
-

My rotary encoder has a different resolution, how do I change the hardware config?

Change the mouseInput.EncoderResolution peroperty to the value found at the end of your rotary encoder’s product number: e.g. 05.2400.1122.1024 means EncoderResolution = 1024.

Notes

(1) DOI:10.1016/j.celrep.2017.08.047

Etc.

Author: Miles Wells

v1.1.0

%#ok<*NOPTS,*NASGU,*ASGLU>
+

The experiment server is unable to open my DAQ on ‘Dev1’

If you have multiple NI devices on this computer, set the DaqIds properties to the correct id in your hardware.mat file, i.e. daqController.DaqIds, mouseInput.DaqId, rewardController.DaqId

d = daq.getDevices % Availiable devices and their info
+

My rotary encoder has a different resolution, how do I change the hardware config?

Change the mouseInput.EncoderResolution peroperty to the value found at the end of your rotary encoder’s product number: e.g. 05.2400.1122.1024 means EncoderResolution = 1024.

Notes

(1) DOI:10.1016/j.celrep.2017.08.047

Etc.

%#ok<*NOPTS>
+%#ok<*NASGU>
+%#ok<*ASGLU>
 
\ No newline at end of file diff --git a/docs/html/index.html b/docs/html/index.html new file mode 100644 index 00000000..bfe65fdb --- /dev/null +++ b/docs/html/index.html @@ -0,0 +1,202 @@ + + + + + Rigging Toolbox Documentation

Rigging Toolbox Documentation

Below is a list of useful topics:

@todo Further files to add to docs @body Burgess config, setting up shared paths

Contents

Code organization

Below is a list of Rigbox's subdirectories and an overview of their respective contents. For more details, see the REAME.md and Contents.m files for each package folder.

+dat

The 'data' package contains code pertaining to the organization and logging of data. It contains functions that generate and parse unique experiment reference ids, and return file paths where subject data and rig configuration information is stored. Other functions include those that manage experimental log entries and parameter profiles. A nice metaphor for this package is a lab notebook.

doc +dat
+

+eui

The 'experiment user interface' package contains code pertaining to the Rigbox user interface. It contains code for constructing the mc GUI (MControl.m), and for plotting live experiment data or generating tables for viewing experiment parameters and subject logs.

This package is exclusively used by the master computer.

doc +eui
+

+exp

The 'experiment' package is for the initialization and running of behavioural experiments. It contains code that define a framework for event- and state-based experiments. Actions such as visual stimulus presentation or reward delivery can be controlled by experiment phases, and experiment phases are managed by an event-handling system (e.g. ResponseEventInfo).

The package also triggers auxiliary services (e.g. starting remote acquisition software), and loads parameters for presentation for each trial. The principle two base classes that control these experiments are 'Experiment' and its 'signals package' counterpart, 'SignalsExp'.

helpwin +exp
+

+hw

The 'hardware' package is for configuring, and interfacing with, hardware (such as screens, DAQ devices, weighing scales and lick detectors). Within this is the '+ptb' package which contains classes for interacting with PsychToolbox.

hw.devices loads and initializes all the hardware for a specific experimental rig. There are also classes for unifying system and hardware clocks.

doc hw
+

+psy

The 'psychometrics' package contains simple functions for processing and plotting psychometric data.

doc psy
+

+srv

The 'stim server' package contains the 'expServer' function as well as classes that manage communications between rig computers.

The 'Service' base class allows the stimulus computer to start and stop auxiliary acquisition systems at the beginning and end of experiments.

The 'StimulusControl' class is used by the master computer to manage the stimulus computer.

Note: Lower-level communication protocol code is found in the 'cortexlab/+io' package.

doc +srv
+

cb-tools/burgbox

'Burgbox' contains many simple helper functions that are used by the main packages. Within this directory are additional packages:

  • +bui --- Classes for managing graphics objects such as axes
  • +aud --- Functions for interacting with PsychoPortAudio
  • +file --- Functions for simplifying directory and file management, for instance returning the modified dates for specified folders or filtering an array of directories by those that exist
  • +fun --- Convenience functions for working with function handles in MATLAB, e.g. functions similar cellfun that are agnostic of input type, or ones that cache function outputs
  • +img --- Classes that deal with image and frame data (DEPRECATED)
  • +io --- Lower-level communications classes for managing UDP and TCP/IP Web sockets
  • +plt --- A few small plotting functions (DEPRECATED)
  • +vis --- Functions for returning various windowed visual stimuli (i.g. gabor gratings)
  • +ws --- An early Web socket package using SuperWebSocket (DEPRECATED)

cortexlab

The 'cortexlab' directory is intended for functions and classes that are rig or CortexLab specific, for example, code that allows compatibility with other stimulus presentation packages used by CortexLab (e.g. MPEP)

tests

The 'tests' directory contains code for running unit tests within Rigbox.

docs

Contains various guides for how to configure and use Rigbox.

submodules

Additional information on the alyx-matlab, npy-matlab, signals and wheelAnalysis submodules can be found in their respective github repositories.

Etc.

Author: Miles Wells

v0.0.1

\ No newline at end of file diff --git a/docs/html/paths_config.html b/docs/html/paths_config.html new file mode 100644 index 00000000..88a2fdb5 --- /dev/null +++ b/docs/html/paths_config.html @@ -0,0 +1,150 @@ + + + + + Introduction

Introduction

The dat.paths function is used for configuring important paths for the computers which Rigbox runs on. These include paths to:

  1. either the shared folder(s) OR the remote server(s) on which organization-wide configuration files, subject data and experiment data is stored, and a local directory for generating redundant copies of this data.
  2. optionally, paths to a remote database (if using Alyx), and a local redundant copy of that database
  3. optionally, paths to any other directories for storing additional back-ups (e.g. for working analyses, tapes, etc...)
  4. optionally, a path to a custom config file for the local computer.

Contents

Setting up the paths

dat.paths is simply a function that returns a struct of directory paths to various things. Much of the code in Rigbox calls this function to determine where to save and load data.

Running the addRigboxPaths function should result in a copy of the paths template file being moved to the +dat folder. If running `which dat.paths` shows this isn't the case, manually copy the template (see below) and open the file. The inline comments should explain each field.

open dat.paths
+

Manually copying the paths template

The below code should copy docs\setup\paths_template.m to +dat\paths.m:

assert(exist('addRigboxPaths','file') == 2, ...
+  'Rigbox not installed.  Please run addRigboxPaths.m before continuing')
+root = fileparts(which('addRigboxPaths')); % Location of Rigbox root dir
+source = fullfile(root, 'docs', 'setup', 'paths_template.m');
+destination = fullfile(root, '+dat', 'paths.m');
+assert(copyfile(source, destination), 'Failed to copy the template file')
+

Sharing a folder in Windows

If you don’t yet have a data server, follow the steps below to set up a shared folder on the stimulus server computer:

  1. Create a folder in C:\ called ‘LocalExpCode’ and one called ‘LocalExpData’ (if it doesn’t already exist)
  2. Copy everything inside the GitHub\Rigbox\Repositories\data folder into ‘LocalExpData’ and everything inside GitHub\Rigbox\Repositories\code into ‘LocalExpCode’
  3. Right click on the ‘LocalExpData’ folder and select Properties and select the Sharing tab and click ‘Share...’
  4. Under the drop-down list select ‘Everyone’ and click ‘Add’, then ‘Share’.
  5. Now click ‘Advanced Sharing…’, make sure the ‘Share this folder’ check box is selected. Click the ‘Permissions’ button and ensure that under the ‘Permissions for Everyone’ section, the ‘Full Control’ is allowed.
  6. Repeat step 3, 4 and 5 for ‘LocalExpCode’.
  7. You should now be able to navigate to these folder from other computers on the network be going to \\<StimulusServerName>\LocalExpData (where ‘<StimulusServerName>’ is the computer name of the stimulus server)
  8. In dat.paths, change line 21 to be the following: serverName = '\\<StimulusServerName>'; % where '<StimulusServerName>' is the stimulus server’s computer name
  9. On lines 26 and 32, replace ‘data’ with ‘LocalExpData’ and on lines 37 and 41, replace the word ‘code’ with ‘LocalExpCode’.
  10. Save the paths file in Documents\MATLAB\+dat and do the same on the mc computer (the two computers must have the same paths).

Etc.

Author: Miles Wells

v0.0.1

\ No newline at end of file diff --git a/docs/html/troubleshooting.html b/docs/html/troubleshooting.html new file mode 100644 index 00000000..2ae9a329 --- /dev/null +++ b/docs/html/troubleshooting.html @@ -0,0 +1,708 @@ + + + + + Troubleshooting

Troubleshooting

Often finding the source of a problem seems daunghting when faced with a huge Rigbox error stack. Below are some tips on how to quickly get to the root of the issue and hopefully solve it.

Contents

Update the code

Check what version of the code you're using and that you're up-to-date:

git.runCmd('status'); % Tells me what branch I'm on
+git.update(0); % Update now
+
+% If you're on a development or feature branch try moving to the master
+% branch, which should be most stable.
+git.runCmd('checkout master'); git.update(0);
+

Examining the stack

Don't be frightened by a wall of red text! Simply start from the top and work out what the errors might mean and what part of code they came from. The error at the top is the one that ultimately caused the crash. Try to determine if this is a MATLAB builtin function, e.g.

Warning: Error occurred while executing the listener callback for event UpdatePanel defined for class eui.SignalsTest:
+Error using griddedInterpolant
+Interpolation requires at least two sample points in each dimension.
+
Error in interp1 (line 151)
+F = griddedInterpolant(X,V,method);
+
TODO Add better example of builtin errors
+

If you're debugging a signals experiment definition, check for the line in your experiment where this particular builtin function was called. NB: You can check whether it is specific to your experiment by running one of the example experiment definitions such as advancedChoiceWorld.m, found in signals/docs/examples. If this runs without error then you're problem may be specific to your experiment. You should see the name of your definition function and exp.SignalsExp in the stack if they are involved.

If you don't know what a function is, try checking the documentation. Consider the following:

Error using open
+Invalid number of channels
Error in audstream.fromSignal (line 16)
+  id = audstream.open(sampleRate, nChannels, devIdx);
+[...]

If you're unsure what `audstream.fromSignal` does, try typing `doc audstream`. This should tell you that the package deals with audio devices in signals. In this case the issue might be that your audio settings are incorrect. Take a look at the audio section of `docs\setup\hardware_config.m` and see if you can setup your audio devices differently.

Paths

By far the most common issue in Rigbox relates to problems with the MATLAB paths. Check the following:

  1. Do you have a paths file in the +dat package? Check the location by running `which dat.paths`. Check that a file is on the paths and that it's the correct one.
  2. Check the paths set in this file. Run `p = dat.paths` and inspect the output. Perhaps a path is set incorrectly for one of the fields. Note that custom rig paths overwrite those written in your paths file. More info found in `using_dat_package` and `paths_template`.
  3. Do you have path conflicts? Make sure MATLAB's set paths don't include other functions that have the same name as Rigbox ones. Note that any functions in ~/Documents/MATLAB take precedence over others. If you keep seeing the following warning check that you've set the paths correctly: Warning: Function system has the same name as a MATLAB builtin. We suggest you rename the function to avoid a potential name conflict. This warning can occur if the tests folder has been added to the paths by mistake. Always set the paths by running `addRigboxPaths` and never set them manually as some folders should not be visible to MATLAB.
  4. Check your working directory MATLAB prioritizes functions found in your working directory over any others in your path list so try to change into a 'safe' folder before re-running your code: pwd % display working directory cd ~/Documents/MATLAB
  5. Check your variable names Make sure your variable names don't shadow a function or package in Rigbox, for instance if in an experiment definition you create a varible called `vis`, you will no longer be able to access functions in the +vis package from within the function: vis = 23; img = vis.image(t); Error: Reference to non-existent field 'image'.

Reverting

If these errors only started occuring after updating the code, particularly if you hadn't updated in a long time, try reverting to the previous version of the code. This can help determine if the update really was the culprit and will allow you to keep using the code on outdated machines. Previous stable releases can be found on the Github page under releases. NB: For the most recent stable code always pull directly from the master branch

Posting an issue on Github

If you're completely stumped, open an issue on the Rigbox Github page (or alyx-matlab if you think it's related to the Alyx database). When creating an issue, read the bug report template carefully and be sure to provide as much information as possible.

If you tracked down the problem but found the error to be confusing or too vague, feel free to post a feature request describing how better to present the error. This is an area in need of improvment. You could also make a change yourself and submit a pull request. For more info see CONTRIBUTING.md

FAQ

Below are some frequently asked questions and suggestions for fixing them. Note there are plenty of other FAQs in the various setup scripts with more specific information.

Error and warning IDs

Below is a list of Rigbox error & warning IDs. This list is currently incomplete and there aren't yet very standard. Typically the ID has the following structure: module:package:function:error

These are here for search convenience and may soon contain more detailed troubleshooting information.

% ..:..:..:copyPaths
+% Problem:
+%  In order to load various essential configuration files, and to load and
+%  save experimental data, user specific paths must be retrieved via calls
+%  to |dat.paths|.  This error means the function is not on MATLAB's search
+%  path.
+%
+% Solution:
+%  Add your +dat\paths.m file to MATLAB's search path.  A template is
+%  present in \docs\setup\paths_template.m.  This file is automatically
+%  copied by addRigboxPaths to +dat\.  If you haven't already done so, run
+%  |addRigboxPaths| to ensure all other paths have been correctly set.
+%
+%  See also README.md for further setup information.
+%
+% IDs
+%  Rigbox:git:update:copyPaths
+%  signals:test:copyPaths
+
+% ..:..:noRemoteFile
+% Problem:
+%  % TODO Add problem & solution for noRemoteFile error
+%
+% Solution:
+%
+%
+% IDs
+%  Rigbox:mc:noRemoteFile
+
+% ..:..:..:notInTest
+% Problem:
+%  This occurs when a mock function is called when the INTEST global
+%  variable is not set.  These mock functions shadow Rigbox and builtin
+%  functions, meaning they have the same name.
+%
+% Solution:
+%  If this function was called during a test, add the following to the top
+%  of your test or in the constructor:
+%    global INTEST
+%    INTEST = true
+%  Ensure that this is cleared during the teardown:
+%    addteardown(@clear, INTEST) % If in a class
+%    mess = onCleanup(@clear, INTEST) % If in a function
+%
+%  If the mock in question is a class, set the InTest flag instead of the
+%  global variable:
+%    mock = MockDialog; % An example using MockDialog class
+%    mock.InTest = true;
+%    addteardown(@clear, MockDialog) % Clear mock class when done
+%    mess = onCleanup(@clear, MockDialog) % If in a function
+%
+%  If you are in not running tests, ensure that tests/fixtures is not in
+%  your MATLAB path and that you are in a different working directory.  It
+%  is best to remove all Rigbox paths and readd them using `addRigboxPaths`
+%
+% IDs
+%  Rigbox:tests:system:notInTest
+%  Rigbox:tests:modDate:notInTest
+%  Rigbox:tests:paths:notInTest
+%  Rigbox:tests:pnet:notInTest
+%  Rigbox:tests:modDate:missingTestFlag % TODO change name
+%  Rigbox:MockDialog:newCall:InTestFalse
+
+% ..:..:..:behaviourNotSet
+% Problem:
+%  A mock function was called while in a test, however the behaviour for
+%  this particular input has not been defined.
+%
+% Solution:
+%  If not testing a specific behavior for this function's output, simply
+%  supress the warning in your test, remembering to restore the warning
+%  state:
+%    origState = warning;
+%    addteardown(@warning, origState) % If in a class
+%    mess = onCleanup(@warning, origState) % If in a function
+%    warning('Rigbox:MockDialog:newCall:behaviourNotSet', 'off')
+%
+%  If you're specifically testing the behavior when the mock returns a
+%  particular output then check that you've set the input-output map
+%  correctly: usually this is done by first calling the mock with input
+%  identical to function under test as well as the output you want to see.
+%  Check the input is formatted correctly.  For more information see the
+%  help of the particular mock you are using.
+%
+% IDs
+%  Rigbox:tests:system:valueNotSet % TODO change name
+%  Rigbox:MockDialog:newCall:behaviourNotSet
+%
+
+% ..:..:mkdirFailed
+% Problem:
+%  MATLAB was unable to create a new folder on the system.
+%
+% Solution:
+%  In general Rigbox code only creates new folders when a new experiment is
+%  created.  The folders are usually created in the localRepository and
+%  mainRepository locations that are set in your paths file.  If either of
+%  these are remote (e.g. a server accessed via SMB) check that you can
+%  navigate to the location in Windows' File Explorer (sometimes the access
+%  credentials need setting first).  If you can, next check the permissions
+%  of these locations.  If the folders are read-only, MATLAB will not be
+%  able to create a new experiment folder there.  Either change the
+%  permissions or set a different path in |dat.paths|.  One final thing to
+%  check is that the folder names are valid: the presence of a folder that
+%  is not correctly numbered in the subject's date folder may lead to an
+%  invalid expRef.  Withtin a date folder there should only be folders name
+%  '1', '2', '3', etc.
+%
+% IDs
+%  Alyx:newExp:mkdirFailed
+%  Rigbox:dat:newExp:mkdirFailed
+%
+
+% ..:newExp:expFoldersAlreadyExist
+% Problem:
+%  The folder structure for a newly generated experiment reference is
+%  already in place.
+%
+%  Experiment references are generated based on subject name, today's date
+%  and the experiment number, which is found by looking at the folder
+%  structure of the main repository.  In a subject's experiment folder for
+%  a given date there are numbered folders.  When running a new experiment,
+%  the code takes the folder name with the largest number and adds 1.  It
+%  then checks that this numbered folder doesn't exist in the other
+%  repositories.  If it does, an error is thrown so that no previous
+%  experiment data is overwritten.
+%
+% Solution:
+%  Check the folder structure for all your repositories (namely the
+%  localRepository and mainRepository set in |dat.paths|).  It may be that
+%  there is an empty experiment folder in the localRepository but not the
+%  mainRepository, in which case you can delete it.  Alternatively, if you
+%  find a full experiment folder in the local but not the main, copy it
+%  over so that the two match.  This will avoid a duplicate expRef being
+%  created (remember, new expRefs are created based on the folder structure
+%  of the mainRepository only).
+%
+% IDs
+%  Alyx:newExp:expFoldersAlreadyExist
+%  Rigbox:dat:newExp:expFoldersAlreadyExist
+%
+
+% ..:..:expRefNotFound
+% Problem:
+%  The experiment reference string does not correspond to the folder
+%  structure in your mainRepository path.  Usually determined via a call to
+%  |dat.expExists|.
+%
+% Solution:
+%  Check that the mainRepository paths are the same on both the computer
+%  that creates the experiment (e.g. MC) and the one that loads the
+%  experiment (e.g. the one that runs |srv.expServer|).  For an experiment
+%  to exist, the subject > date > sequence folder structure should exist in
+%  the mainRepository.  To see the mainRepository location, run the
+%  following:
+%    getOr(dat.paths, 'mainRepository')
+%  For example if the output is '\\server\Subjects\' then for the expRef
+%  '2019-11-25_1_test' to exist, the following folder should exist:
+%  \\server\Subjects\test\2019-11-25\1
+%
+% IDs
+%  Rigbox:srv:expServer:expRefNotFound
+
+% ----- ! PTB - ERROR: SYNCHRONIZATION FAILURE ! ----
+% Problem:
+%   To quote PsychToolbox: One or more internal checks indicate that
+%   synchronization of Psychtoolbox to the vertical retrace (VBL) is not
+%   working on your setup.This will seriously impair proper stimulus
+%   presentation and stimulus presentation timing!
+%
+% Solution:
+%   There are many, many reasons for this error.  Here's a quick list of
+%   things to try, in order:
+%
+%   # Simply re-trying a couple of times.  Sometimes it happens
+%   sporadically.
+%   # Check the monitor(s) are on and plugged in.  If you're using
+%   multiple monitors they should be of the same make and model.  If they
+%   aren't, try with just one monitor first.
+%   # If you're using multiple screens in NVIDEA's 'Mosaic' mode, the
+%   settings may have changed: sometimes Mosiac becomes deactivated and you
+%   should set it up again.
+%   # If you're using a remote connection for that computer it may be
+%   interfering with the graphics settings.  Examples of a remote
+%   connection include VNC servers, TeamViewer and Windows Remote Desktop.
+%   Try opening the PTB Window without any of these remote services.
+%   # Update the graphics card drivers and firmware.  This often helps.
+%   # Read the PTB docs carefully and follow their suggestions.  The docs
+%   can be found at http://psychtoolbox.org/docs/SyncTrouble.
+%   # If all else fails.  You can skip these tests and check that there is
+%   no taring manually.  This is not recommended but can be done by setting
+%   your stimWindow object's PtbSyncTests property to false:
+%     stimWindow = getOr(hw.devices([],false), 'stimWindow');
+%     stimWindow.PtbSyncTests = false;
+%     hwPath = fullfile(getOr(dat.paths, 'rigConfig'), 'hardware.mat');
+%     save(hwPath, 'stimWindow', '-append')
+

Undocumented IDs

Below is a list of all error and warning ids.
% Rigbox:git:runCmd:nameValueArgs
+% Rigbox:git:runCmd:gitNotFound
+% Rigbox:git:update:valueError
+%
+% Rigbox:hw:calibrate:noscales
+% Rigbox:hw:calibrate:deadscale
+% Rigbox:hw:calibrate:partialPVpair
+%
+% Rigbox:srv:unexpectedUDPResponse
+% Rigbox:srv:unexpectedUDP
+% Rigbox:srv:expServer:noHardwareConfig
+%
+% Rigbox:dat:expPath:NotEnoughInputs
+% Rigbox:exp:SignalsExp:NoScreenConfig
+% Rigbox:exp:Parameters:wrongNumberOfColumns
+%
+% Rigbox:dat:expFilePath:NotEnoughInputs
+%
+% Rigbox:MockDialog:newCall:EmptySeq
+%
+% Rigbox:exp:SignalsExp:noTokenSet
+%
+% Rigbox:eui:choiceExpPanel:toolboxRequired
+% Rigbox:setup:toolboxRequired
+%
+% Alyx:newExp:subjectNotFound
+% Alyx:registerFile:InvalidPath
+% Alyx:registerFile:UnableToValidate
+% Alyx:registerFile:EmptyDNSField
+% Alyx:registerFile:InvalidRepoPath
+% Alyx:registerFile:InvalidFileType
+% Alyx:registerFile:InvalidFileName
+% Alyx:registerFile:NoValidPaths
+% Alyx:updateNarrative:UploadFailed
+%
+% Alyx:getFile:InvalidID
+% Alyx:getExpRef:InvalidID
+% Alyx:getFile:InvalidType
+% Alyx:expFilePath:InvalidType
+% Alyx:url2Eid:InvalidURL
+%
+% toStr:isstruct:Unfinished
+%
+% squeak.hw
+% shape:error
+% window:error
+
\ No newline at end of file diff --git a/docs/html/using_ExpPanel.html b/docs/html/using_ExpPanel.html new file mode 100644 index 00000000..816021ce --- /dev/null +++ b/docs/html/using_ExpPanel.html @@ -0,0 +1,360 @@ + + + + + Introduction

Introduction

ExpPanels are panels under the Experiment > Current tab of mc that display information about events occuring during an experiment. This document contains information on how to set up an ExpPanel for customizing the monitoring of an Experiment.

Contents

exp.ExpPanel

The base class for the ExpPanel is the exp.ExpPanel. All subclasses chain a call to this class.

When starting a new experiment in MC a new ExpPanel is created by calling the static contructor method `live`:

p = live(parent, ref, remoteRig, paramsStruct, activateLog)
+doc eui.ExpPanel/live
+

The precise subclass used depends on the `type` parameter in the paramsStruct input. Currently supported types include SingleTargetChoiceWorld, ChoiceWorld, DiscWorld, SurroundChoiceWorld (eui.ChoiceExpPanel); BarMapping (eui.MappingExpPanel); custom a.k.a. Signals (eui.SignalsExpPanel).

For Signals experiments the default ExpPanel class may be overridden by providing a parameter named `expPanelFun` whose value is either a function handle for an ExpPanel constructor or path to the class to be instantiated. This parameter is automatically added in MC if the folder from which the experiment function was loaded contains an ExpPanel. The name must be the same as the experiment function but with 'ExpPanel' added, e.g. for 'advancedChoiceWorld.m', the corresponding ExpPanel file would be 'advancedChoiceWorldExpPanel.m'.

Basic layout

The ExpPanel has the following basic layout...

Title

The panel title contains the experiment reference and the name of the remote rig. When the experiment is initializing or during the cleanup/post-delay phase the title is amber. During the main experiment phase the title turns green and when complete, red. This title colour and other properties are set in the `live` method then subsequently by the `event` method. The title is stored in the Root.Title property.

InfoGrid

The info grid contains all experiment event labels and their current values. As new events occur they're added to the last via a call to addInfoField. There are 4 default fields:

  • Status - The current experimental phase, e.g. 'Pending', 'Complete'. The status is set based on 'ExpUpdate' events from the remote rig (see the `expUpdate` method).
  • Duration - The time elapsed since the experiment began. This is updated each time the `update` method is called (every 100ms in MC).
  • Trial count - The total number of trials. This field is updated based on the 'newTrial' ExpUpdate status (see `expUpdate` method).
  • Condition - The current trial condition. This only appears if `conditionId` parameter is defined.

The fields may be hidden by right-clicking one and selecting 'Hide field'. The hidden fields may be reset by selecting 'Reset hidden'.

CustomPanel

A container for subclasses to build plots into. For example in the ChoiceWorld Experiment, this contains a psychometric curve plot and the trace of the wheel input.

CommentsBox

An input field for taking notes. These are automatically saved to the Log (see dat.logPath, dat.logEntries). If logged into Alyx the notes are also saved to the database session narrative (see Alyx.updateNarrative). The comments box may be hidden by right-clicking and selecting 'Hide comments'.

ButtonPanel

A set of buttons for ending/aborting the experiment as well as viewing the parameter set. If End is pressed, the experiment is ended after the post delay, the block's endStatus field is set to 'quit', and ALF files may be extracted from the block. If Abort is pressed, the post delay is skipped, the endStatus is set to 'aborted' and no ALF files are extracted from the block during save.

Method call sequence

Below is the sequence of method calls. This is useful to be aware of when making your own subclass.

mc/beginExp
+     |
+eui.ExpPanel/live
+     |
+eui._ExpPanel/_ExpPanel (may be a subclass constructor, e.g.
+     |                   SignalsExpPanel)
+eui._ExpPanel/build (subclasses should chain call to superclass build)
+     |
+eui.ExpPanel/addInfoField (adds any default info labels, e.g.
+                           TrialCount)
eui._ExpPanel/update (mc timer callback)
+     | (if eui.SignalsExpPanel or subclass.
+     |  NB: All subclasses should chain a call to superclass update)
+eui._ExpPanel/processUpdates (method only present in SignalExpPanel
+     |                        classes)
+eui.ExpPanel/addInfoField (adds any new Signals event fields)

exp.SignalsExpPanel

The subclass, eui.SignalsExpPanel, is the default class for Signals Experiments. In this class, all Signals updates are shown as InfoFields whose colours pulse green as the values update. The signals sent from the stimulus computer includes events, parameters, inputs and outputs signals. The 'Trial count' field reflects the value of events.trialNum.

The UpdatesFilter property contains a list of signals updates to create a label for, or if Exclude == true, all signals names in this list are ignored. This is useful when your events structure is large and you don't wish to see all of them during the experiment.

exp.SignalsExp periodically(1) sends signals event updates to the MC computer(2). These updates trigger the expUpdate method which stores the updates in the SignalUpdates property. All updates in this property are delt with and removed by the processUpdates method, which is called via the update by the MC Refresh timer once per 100ms(3).

The SignalUpdate property is a struct with the following fields:

  • name - The name of the signal, e.g. 'events.newTrial'
  • value - The value of the signal.
  • timestamp - a date vector of the date and time when the signal was queued. (NB: This is in the system time of the remote rig and depends on its timezone. These timestamps aren't as precise as those in the block file).

When new updates are processed in eui.SignalsExpPanel, if an info field does not already exist, one is created. When the FormatLabels property is true the Signals update labels are formatted as sperate words. For example 'events.newTrial' is displayed as 'New trial'. This flag and others such as the UpdatesFilter can be set it your subclass constructor.

Custom Signals ExpPanels

Below is a list of steps to follow when creating a custom Signals ExpPanel, for an example of this see advancedChoiceWorldExpPanel(4):

  1. Subclass eui.SignalsExpPanel Subclassing means you will inherit all of the methods and properties found in eui.SignalsExpPanel and eui.ExpPanel.
  2. Add any extra properties specific to your ExpPanel For example if you're creating a new plot you may wish to store the axes in a property. (c.f. PsychometricAxes in advancedChoiceWorldExpPanel)
  3. Add a constructor to initialize any properties if required Chain a call to the superclass method like so: obj = obj@eui.SignalsExpPanel(parent, ref, params, logEntry);
  4. Add a build method to initialize an axes or extra UI elements. Typically everything built here will use obj.CustomPanel as the parent container. This method must have protected access. Chain a call to the superclass method first: build@eui.SignalsExpPanel(obj, parent);
  5. Add a processUpdates to deal with your experiment-specific events. Here you can add code to update plots, etc. based on the event updates. This method must have protected access. Instead of chaining a call, copy the code from eui.SignalsExpPanel/processUpdates directly and use it as a template for your own functions.

There are some useful superclass methods that are useful to keep in mind:

  • mergeTrialData - Update the local block structure with data from the last trial. This is found in eui.ExpPanel.
  • newTrial - This doesn't do anything in the superclasses but is a good place to put code that should be executed at events.newTrial. Call it from processUpdates.
  • trialCompleted - As with newTrial, this could be used as a place for code that runs after e.g. an outputs or feedback event.
  • expStopped - Useful for executing code when the session ends. Must chain a call to superclass here.
  • expStarted - See above.
  • cleanup - Place code here for e.g. stopping timers, clearing listeners, etc.

Here are some useful properties to be aware of:

  • Parameters - A copy of that experiment's paramStruct.
  • UpdatesFilter - As mentioned above, this holds a cell array of events you wish to ignore/include. It's behaviour depends on whether the Exclude property is true or false.
  • RecentColour - The colour of recently updated Signals update events in the InfoGrid. This can be changed dynamically during the session, for instance could turn red as the subject's performance declines or towards the end of the session.

Finally there are some other useful untilities to be aware of:

  • +psy - The psy package contains useful functions for plotting psychometrics and producing fits.
  • bui.Axes - This class provides a convenient way to interact with plotting axes. It's particularly useful if you wish to add multple elements to the same axes. Below is an example from advancedChoiceWorld:
obj.ExperimentAxes = bui.Axes(plotgrid); % Create new bui.Axes object
+obj.ExperimentAxes.ActivePositionProperty = 'position';
+obj.ExperimentAxes.XTickLabel = []; % Remove the X tick labels
+obj.ExperimentAxes.NextPlot = 'add'; % Add new plots without clearing axes
+% First initialize a plot for the wheel trace and store the resulting axes
+obj.ExperimentHands.wheelH = plot(obj.ExperimentAxes,...
+  [0 0],...
+  [NaN NaN],...
+  'Color', .75*[1 1 1]);
+% Now initialize a threshold line on the same plot
+obj.ExperimentHands.threshL = plot(obj.ExperimentAxes, ...
+  [0 0],...
+  [NaN NaN],...
+  'Color', [1 1 1], 'LineWidth', 4);
+% Note that updating plots can be memory intensive, so consider tweaks such
+% as updating the underlying plot data instead of clearing and redrawing:
+set(obj.ExperimentHands.wheelH,...
+  'XData', xx,...
+  'YData', tt);
+% Also take a look at the drawnow builtin function:
+doc drawnow
+

Notes, etc.

(1) exp.SignalsExp sends any new Signals event updates once every 100ms: opentoline(which('exp.SignalsExp'), 731, 9)

(2) Any number of computers may listen for these updates, see websocket_config

(3) See eui.MControl: opentoline(which('eui.MControl'), 84, 7)

(4) The below three lines will open this file:

% rigbox = getOr(dat.paths, 'rigbox'); % Location of Rigbox code
+% exampleExps = fullfile(rigbox, 'signals', 'docs', 'examples');
+% open(fullfile(exampleExps, 'advancedChoiceWorldExpPanel.m'))
+
+% Author: Miles Wells
+%
+% v1.0.0
+
+%#ok<*NOPTS,*ASGLU,*NASGU>
+
\ No newline at end of file diff --git a/docs/html/using_parameters.html b/docs/html/using_parameters.html index cc7e469d..44e94646 100644 --- a/docs/html/using_parameters.html +++ b/docs/html/using_parameters.html @@ -6,7 +6,7 @@ Introduction

Contents

Introduction

This document demonstrates how to test Signals Experiment Definition (expDef) functions in the test GUI. The GUI opens a PTB window and a Parameter Editor for live-updating parameters. Before opening the test GUI, loading the debug settings for PTB will make the window transparent. This is particularly useful on small screens(1).

PsychDebugWindowConfiguration
+

Opening your expDef in the GUI

Upon calling the eui.SignalsTest class with no inputs you will be prompted to select your function from the file browser. As with MC, the default folder location is set by the 'expDefinitions' field in dat.paths.

You can also call the function with the function name or function handle. The function must be on the MATLAB path. Let's run one of the example expDef functions: the Burgess wheel task(2) implemented in Signals.

PsychDebugWindowConfiguration % Make window transparant and turn of blocking
+root = fileparts(which('addRigboxPaths')); % Location of Rigbox root dir
+cd(fullfile(root, 'signals', 'docs', 'examples')) % Change to examples folder
+
+e = eui.SignalsTest('advancedChoiceWorld') % Start GUI and loaded expDef
+% e = eui.SignalsTest() % Opens a file navigator
+% e = eui.SignalsTest(@advancedChoiceWorld) % Function handle
+% e = eui.SignalsTest('full/path/to/expDef.m') % Absolute file path
+
+e = 
+
+  SignalsTest with properties:
+
+          Hardware: [1×1 struct]
+               Ref: '2020-01-30_1_test'
+    LoggingDisplay: [1×1 UIControl]
+          LivePlot: off
+      ShowExpPanel: on
+      SingleScreen: off
+        Experiment: [0×0 exp.test.Signals]
+       ParamEditor: [1×1 eui.ParamEditor]
+       LivePlotFig: [0×0 Figure]
+          ExpPanel: [0×0 eui.ExpPanel]
+         IsRunning: 0
+
+

Default settings

The hardware wheel input is simulated in the experiment GUI by the position of the cursor over the stimulus window. Upon clicking start the 'expStart' event updates with the the experiment ref string. The ref string can be changed by editing the Ref property before pressing start:

e.Ref = dat.constructExpRef('subject', now-7, 2);
+
+% Moving the cursor over the window will move the visual stimulus in the
+% stimulus window.
+e.Hardware.mouseInput % Uses the eui.SignalsTest/getMouse method
+
+ans = 
+
+  struct with fields:
+
+    readAbsolutePosition: @(varargin)obj.getMouse(varargin{:})
+       MillimetresFactor: 0.1000
+       EncoderResolution: 1
+              ZeroOffset: 0
+                    zero: @nop
+
+

Testing different hardware

A hardware structure can be assigned to the Hardware property of the test object (see configuring hardware devices):

e.Hardware = hw.devices;  % Assign a rig's actual hardware settings
+
Warning: hardware config not found for hostname desktop-c6p85d3 
+

Editing parameters

Parameters are shown in the eui.ParamEditor which is in the 'Parameters' box. When you select an expDef, the default parameters are loaded. These are the names and values defined in the try-catch block at the end of the expDef. Parameters can be edited before pressing start, and saved/loaded using the 'saved sets' section. Saved sets are located in the mainRepository (set in your dat.paths file), in a file called 'parameterProfiles.mat'. For more info see Using Parameters. The parameters can be updated live during the experiment. In order to do this, check 'Post new parameters on edit' in the options dialog.

Live plotting

The values of the event Signals can be plotted live by checking the LivePlot option in the Options popup.

Clicking on each subplot will cycle through the three plot modes. The default mode (0) creates a stair plot with each value update marked with an x. Mode 1 plots each value as a discrete point. Mode 2 plots a simple line, with now markers to indicate value updates. Note, if a Signal takes a vector or matrix as its value, the mode is switched to 1 and the size of the array is added as an text annotation. If the value is a charecter array, the mode is switched to 1 and the value is plotted as a text annotation. For more details on the plotting function see sig.test.timeplot.

For more about options see the help for the setOptions method.

e.setOptions % Open Options dialog
+help eui.SignalsTest/setOptions
+
  SETOPTIONS callback for 'Options' button
+    Sets various parameters related to monitering the experiment.
+    
+    Options:
+      Plot Signals (off): Plot all events, input and output Signals
+        against time in a separate figure.  Clicking on each subplot 
+        will cycle through the plotting styles.
+      Show experiment panel (on): Instantiate an eui.SignalsExpPanel
+        for monitoring the experiment updates.  The ExpPanelFun
+        parameter defines a custom ExpPanel function to display.  NB:
+        Unlike in MC, the comments box is hidden.
+      View PTB window as single screen (off): When true, the default
+        setting of the window simulates a 4:3 aspect ratio screen.
+      Post new parameters on edit (off): When true, whenever a
+        parameter is edited while the experiment is running, the
+        parameter Signals immediately update.
+ 
+  See also SIG.TEST.TIMEPLOT, EUI.SIGNALSEXPPANEL
+
+

Experiment panel

The 'Show experiment panel' option is on by default and instantiates an experiment panel (ExpPanel) that displays Signals updates after the experiment has started. A custom ExpPanel can be defined via your expDef's 'expPanelFun' parameter, otherwise eui.SignalsExpPanel is used. For more info, see Using ExpPanel.

+ +

Debugging

A number of features make debugging a little easier:

  1. The expDef is re-loaded each time you start the experiment, so you can make changes without having to reload the GUI.
  2. The parameters can be updated live during the experiment (see Editing Parmeters).
  3. You can pause and resume the experiment at any time by pressing the esc key. While paused, no Signals are updated. Note that while the experiment is running you can not use the command prompt, even when paused.
  4. Signals is run in debug mode, so the error messages printed give more informative information about the exact cause of the error. Below is an example of a typical Signals error thrown. The top of the stack gives the name of the function that threw the error, the error message and the line at which it occured. The 'Caused by' section gives the node ids involved (e.g. node 61 -> node 62), their values at the time of the error (e.g. bombWorld/timeSampler([0; 0.1; 0.09]) ), and the name and line of problem Signal in your expDef, e.g. 'Error in [...]bombWorld.m (line 22)'; pars.preStimulusDelay.map(@bombWorld/timeSampler). NB: If you call eui.SignalsTest with a function handle, the line number of your expDef can not be determined.

Error using bombWorld/timeSampler (line 183) Expected char; was double instead.

[...]

Caused by:
Error in Net 1 mapping Node 61 to 62:
function call 'bombWorld/timeSampler' with input [0; 0.1; 0.09] produced an error:
Expected char; was double instead.
Error in C:\Users\User\Documents\Github\rigbox\signals\docs\examples\bombWorld.m (line 22)
pars.preStimulusDelay.map(@bombWorld/timeSampler)

Notes

(1) These settings can be cleared by calling the Screen function:

clear Screen
+% (2) <https://doi.org/10.1016/j.celrep.2017.08.047 DOI:10.1016/j.celrep.2017.08.047>
+

Etc.

Author: Miles Wells

v0.1.0

% INTERNAL:
+%  ln79 ExpPanel.png
+%  ln103-114 make red
+%#ok<*NOPTS,*NASGU,*ASGLU>
+
\ No newline at end of file diff --git a/docs/html/using_test_gui.png b/docs/html/using_test_gui.png new file mode 100644 index 00000000..f1a41d4a Binary files /dev/null and b/docs/html/using_test_gui.png differ diff --git a/docs/html/using_test_gui_01.png b/docs/html/using_test_gui_01.png new file mode 100644 index 00000000..259ed1e2 Binary files /dev/null and b/docs/html/using_test_gui_01.png differ diff --git a/docs/html/using_test_gui_02.png b/docs/html/using_test_gui_02.png new file mode 100644 index 00000000..c1a83959 Binary files /dev/null and b/docs/html/using_test_gui_02.png differ diff --git a/docs/index.m b/docs/index.m new file mode 100644 index 00000000..d6f2fb83 --- /dev/null +++ b/docs/index.m @@ -0,0 +1,123 @@ +%% Rigging Toolbox Documentation +% Below is a list of useful topics: +% +% * <./paths_config.html Setting up dat.paths> +% * <./hardware_config.html How to configure hardware on the stimulus computer> +% * <./using_dat_package.html How to query data locations and log experiments> +% * <./websocket_config.html Setting up communication between the stimulus computer and MC> +% * <./using_test_gui.html Playing around with Signals Experiment Definitions> +% * <./SignalsPrimer.html How to create experiments in signals> +% * <./using_parameters.html How to create and edit experiment parameters> +% * <./using_timeline.html Using Timeline for time alignment> +% * <./using_services.html Setting up auxiliary services> +% * <./AlyxMatlabPrimer.html How to interact with an Alyx database> +% * <./using_ExpPanel.html How to create a custom Experiment Panel> +% * <./troubleshooting.html Troubleshooting Rigbox errors> +% +% @todo Further files to add to docs +% @body Burgess config, setting up shared paths + +%% Code organization +% Below is a list of Rigbox's subdirectories and an overview of their +% respective contents. For more details, see the REAME.md and Contents.m +% files for each package folder. + +%%% +dat +% The 'data' package contains code pertaining to the organization and +% logging of data. It contains functions that generate and parse unique +% experiment reference ids, and return file paths where subject data and +% rig configuration information is stored. Other functions include those +% that manage experimental log entries and parameter profiles. A nice +% metaphor for this package is a lab notebook. +doc +dat + +%%% +eui +% The 'experiment user interface' package contains code pertaining to the +% Rigbox user interface. It contains code for constructing the mc GUI +% (MControl.m), and for plotting live experiment data or generating tables +% for viewing experiment parameters and subject logs. +% +% This package is exclusively used by the master computer. +doc +eui + +%%% +exp +% The 'experiment' package is for the initialization and running of +% behavioural experiments. It contains code that define a framework for +% event- and state-based experiments. Actions such as visual stimulus +% presentation or reward delivery can be controlled by experiment phases, +% and experiment phases are managed by an event-handling system (e.g. +% ResponseEventInfo). +% +% The package also triggers auxiliary services (e.g. starting remote +% acquisition software), and loads parameters for presentation for each +% trial. The principle two base classes that control these experiments are +% 'Experiment' and its 'signals package' counterpart, 'SignalsExp'. +helpwin +exp + +%%% +hw +% The 'hardware' package is for configuring, and interfacing with, hardware +% (such as screens, DAQ devices, weighing scales and lick detectors). +% Within this is the '+ptb' package which contains classes for interacting +% with PsychToolbox. +% +% |hw.devices| loads and initializes all the hardware for a specific +% experimental rig. There are also classes for unifying system and hardware +% clocks. +doc hw + +%%% +psy +% The 'psychometrics' package contains simple functions for processing and +% plotting psychometric data. +doc psy + +%%% +srv +% The 'stim server' package contains the 'expServer' function as well as +% classes that manage communications between rig computers. +% +% The 'Service' base class allows the stimulus computer to start and stop +% auxiliary acquisition systems at the beginning and end of experiments. +% +% The 'StimulusControl' class is used by the master computer to manage the +% stimulus computer. +% +% *Note*: Lower-level communication protocol code is found in the +% 'cortexlab/+io' package. +doc +srv + +%%% cb-tools/burgbox +% 'Burgbox' contains many simple helper functions that are used by the main +% packages. Within this directory are additional packages: +% +% * +bui --- Classes for managing graphics objects such as axes +% * +aud --- Functions for interacting with PsychoPortAudio +% * +file --- Functions for simplifying directory and file management, for instance returning the modified dates for specified folders or filtering an array of directories by those that exist +% * +fun --- Convenience functions for working with function handles in MATLAB, e.g. functions similar cellfun that are agnostic of input type, or ones that cache function outputs +% * +img --- Classes that deal with image and frame data (DEPRECATED) +% * +io --- Lower-level communications classes for managing UDP and TCP/IP Web sockets +% * +plt --- A few small plotting functions (DEPRECATED) +% * +vis --- Functions for returning various windowed visual stimuli (i.g. gabor gratings) +% * +ws --- An early Web socket package using SuperWebSocket (DEPRECATED) + +%%% cortexlab +% The 'cortexlab' directory is intended for functions and classes that are +% rig or CortexLab specific, for example, code that allows compatibility +% with other stimulus presentation packages used by CortexLab (e.g. MPEP) + +%%% tests +% The 'tests' directory contains code for running unit tests within Rigbox. + +%%% docs +% Contains various guides for how to configure and use Rigbox. + +%%% submodules +% Additional information on the +% , +% , +% and +% submodules +% can be found in their respective github repositories. + +%% Etc. +% Author: Miles Wells +% +% v0.1.0 diff --git a/docs/setup/Burgess_setup.m b/docs/setup/Burgess_setup.m index 44abd508..0a0644c8 100644 --- a/docs/setup/Burgess_setup.m +++ b/docs/setup/Burgess_setup.m @@ -1,6 +1,44 @@ -%% Burgess steering wheel -% Below are some reasonable default hardware configurations for the Burgess -% steering wheel task +%% Burgess steering wheel task +% Our laboratory developed a steering wheel setup to probe mouse +% behavior(1). In this setup, a mouse turns a steering wheel with its front +% paws to indicate whether a visual stimulus appears to its left or to its +% right. +% +% This setup is being adopted in multiple laboratories, from Stanford to +% Tokyo, and is being deployed by the International Brain Laboratory. +% +% To facilitate this deployment, we here provide instructions to build the +% setup with components that are entirely off-the-shelf or 3-D printed +% +% <> +% + +%% Introduction +% This document gives instructions on how to build a basic version of the +% steering wheel setup to probe mouse behavior, introduced by Burgess et +% al. The goal is to make it easy for other laboratories, including those +% that make the International Brain Laboratory, to replicate the task and +% extend it in various directions. To this end, these instructions rely +% entirely on materials that can be bought off the shelf, or ordered online +% based on 3-D drawings. In this steering wheel setup, we place a steering +% wheel under the front paws of a head-fixed mouse, and we couple the +% wheel's rotation to the horizontal position of a visual stimulus on the +% screens. Turning the wheel left or right moves the stimulus left or +% right. The mouse is then trained to decide whether a stimulus appears on +% its left or its right. Using the wheel, the mouse indicates its choice by +% moving the stimulus to the center. A correct decision is rewarded with a +% drop of water and short intertrial interval, while an incorrect decision +% is penalized with a longer timeout and auditory noise. We use this setup +% throughout our laboratory, and deploy it in training rigs and +% experimental rigs. Training rigs are used to train head-fixed mice on the +% steering-wheel task and acquire behavioral data. Experimental rigs have +% additional apparatus to collect electrophysiological and imaging data, +% measure eye movements and licking activity, provide optogenetic +% perturbations, and so on. Up until recently, constructing these setups +% required a machine shop that could provide custom-made components. +% However, for the purposes of spreading this setup to other laboratories, +% we here describe a new version that does not require a machine shop: all +% components can be ordered online or 3D-printed. %% Retrieving hardware file path % The location of the configuration file is set in DAT.PATHS. If running @@ -374,7 +412,7 @@ % (1) %% Etc. -% Author: Miles Wells +% Authors: Lauren E Wool, Miles Wells, Hamish Forrest, and Matteo Carandini % v1.1.0 %#ok<*NOPTS> diff --git a/docs/setup/hardware_config.m b/docs/setup/hardware_config.m index e4d02af5..640cc7db 100644 --- a/docs/setup/hardware_config.m +++ b/docs/setup/hardware_config.m @@ -193,11 +193,10 @@ help WhiteIndex help BlackIndex -%% - Performing gamma calibration from command window -%%% Calibration +%%% Calibration (performing gamma calibration from command window) % This stores the gamma correction tables (See Below) The simplist way to -% to run the calibration is through SRV.EXPSEERVER once the rest of the -% hardware is configures, however it can also be done via the command +% to run the calibration is through |srv.expServer| once the rest of the +% hardware is configured, however it can also be done via the command % window, assuming you have an NI DAQ installed: lightIn = 'ai0'; % The input channel of the photodiode used to measure screen clockIn = 'ai1'; % The clocking pulse input channel @@ -206,8 +205,8 @@ % connection between `clockIn` and `clockOut`. % Make sure the photodiode is placed against the screen before running -stimWindow.Calibration = stimWindow.calibration(DaqDev); % calibration - +stimWindow.Calibration = ... + stimWindow.calibration(DaqDev, lightIn, clockIn, clockOut); save(hardware, 'stimWindow', '-append') % Save the stimWindow to file @@ -221,8 +220,8 @@ stimWindow.flip(); % Whoa! %% - Displaying a Gabor patch -% Make a texture and draw it to the screen with MAKETEXTURE and DRAWTEXTURE -% Let's make a Gabor patch as an example: +% Make a texture and draw it to the screen with |makeTexture| and +% |drawTexture| Let's make a Gabor patch as an example: sz = 1000; % size of texture matrix [xx, yy] = deal(linspace(-sz/2,sz/2,sz)'); phi = 2*pi*rand; % randomised cosine phase @@ -244,12 +243,12 @@ stimWindow.flip; %% - Clearing the window -% To clear the window, the use CLEAR method: +% To clear the window, the use the |clear| method: stimWindow.clear % Re-draw background colour stimWindow.flip; % Flip to screen %% - Drawing text to the screen -% Drawing text to the screen can be done with the DRAWTEXT method: +% Drawing text to the screen can be done with the |drawText| method: [x, y] = deal('center'); % Render the text to the center [nx, ny] = stimWindow.drawText('Hello World', x, y, stimWindow.Red); stimWindow.flip; @@ -517,7 +516,8 @@ % Device Manager (Win + X, then M). Under Universal Serial Bus, you can % see all current USB and serial ports. If you right-click and select % 'Properties' you can view the port number and even reassign them (under -% Advanced) +% Advanced). You can also list all available ports by running |seriallist| +% (|serialportlistt("available")| for >2019b). scaleComPort = 'COM4'; % Set to a different port % The TareCommand and FormatSpec fields should be set based on your scale's % input and output configurations. Check the manual. @@ -568,6 +568,10 @@ save(hardware, 'audioDevices', '-append') +% @TODO Substantiate +% @body Info on PTB support, mention the helper for testing devices, +% mention how audio device naming works + %% Loading your hardware file % To load your rig hardware objects for testing at a rig, you can use % |hw.devices|: @@ -611,8 +615,8 @@ % is that the task bar should stretch across all three of the stimulus % screens. Also check that the stimWindow.ScreenNum is correct in the % hardware.mat file. When set to 0, PsychToolbox uses all screens available -% to Windows; 1 means Windows’ primary screen (see the Display Settings); 2 -% means Windows’ secondary screen, etc. +% to Windows; 1 means Windows' primary screen (see the Display Settings); 2 +% means Windows' secondary screen, etc. %%% I get a ‘PTB synchronization error’ when I run the experiment server. % This happens from time-to-time. When a PsychToolbox window is opened it @@ -659,4 +663,4 @@ % % v1.1.0 -%#ok<*NOPTS,*NASGU,*ASGLU> \ No newline at end of file +%#ok<*NOPTS,*NASGU,*ASGLU> diff --git a/docs/setup/paths_config.m b/docs/setup/paths_config.m index dc76aee6..39443b3f 100644 --- a/docs/setup/paths_config.m +++ b/docs/setup/paths_config.m @@ -1,18 +1,70 @@ -% The 'dat.paths' file is used for configuring important paths for the -% computers which Rigbox runs on and communicates with. These include paths -% to: +%% Introduction +% The |dat.paths| function is used for configuring important paths for the +% computers which Rigbox runs on. These include paths to: % -% 1) either the shared folder(s) OR the remote server(s) on which +% +% # either the shared folder(s) OR the remote server(s) on which % organization-wide configuration files, subject data and experiment data % is stored, and a local directory for generating redundant copies of this -% data -% -% 2) optionally, paths to a remote database (e.g., if using Alyx), -% and a local redundant copy of that database -% -% 3) optionally, paths to any other directories for storing additional +% data. +% # optionally, paths to a remote database (if using Alyx), and a local +% redundant copy of that database +% # optionally, paths to any other directories for storing additional % back-ups (e.g. for working analyses, tapes, etc...) +% # optionally, a path to a custom config file for the local computer. +% +% +%% Setting up the paths +% |dat.paths| is simply a function that returns a struct of directory paths +% to various things. Much of the code in Rigbox calls this function to +% determine where to save and load data. +% +% Running the |addRigboxPaths| function should result in a copy of the +% paths template file being moved to the |+dat| folder. If running `which +% dat.paths` shows this isn't the case, manually copy the template (see +% below) and open the file. The inline comments should explain each field. +open dat.paths + +%% Manually copying the paths template +% The below code should copy |docs\setup\paths_template.m| to +% |+dat\paths.m|: +assert(exist('addRigboxPaths','file') == 2, ... + 'Rigbox not installed. Please run addRigboxPaths.m before continuing') +root = fileparts(which('addRigboxPaths')); % Location of Rigbox root dir +source = fullfile(root, 'docs', 'setup', 'paths_template.m'); +destination = fullfile(root, '+dat', 'paths.m'); +assert(copyfile(source, destination), 'Failed to copy the template file') + +%% Sharing a folder in Windows +% If you don’t yet have a data server, follow the steps below to set up a +% shared folder on the stimulus server computer: % -% 4) optionally, a path to a custom config file for the local computer. +% # Create a folder in C:\ called ‘LocalExpCode’ and one called +% ‘LocalExpData’ (if it doesn’t already exist) +% # Copy everything inside the GitHub\Rigbox\Repositories\data folder into +% ‘LocalExpData’ and everything inside GitHub\Rigbox\Repositories\code into +% ‘LocalExpCode’ +% # Right click on the ‘LocalExpData’ folder and select Properties and +% select the Sharing tab and click ‘Share...’ +% # Under the drop-down list select ‘Everyone’ and click ‘Add’, then +% ‘Share’. +% # Now click ‘Advanced Sharing…’, make sure the ‘Share this folder’ check +% box is selected. Click the ‘Permissions’ button and ensure that under +% the ‘Permissions for Everyone’ section, the ‘Full Control’ is allowed. +% # Repeat step 3, 4 and 5 for ‘LocalExpCode’. +% # You should now be able to navigate to these folder from other computers +% on the network be going to \\\LocalExpData (where +% ‘’ is the computer name of the stimulus server) +% # In dat.paths, change line 21 to be the following: serverName = +% '\\'; % where '' is the +% stimulus server’s computer name +% # On lines 26 and 32, replace ‘data’ with ‘LocalExpData’ and on lines 37 +% and 41, replace the word ‘code’ with ‘LocalExpCode’. +% # Save the paths file in Documents\MATLAB\+dat and do the same on the mc +% computer (the two computers must have the same paths). + + +%% Etc. +% Author: Miles Wells % -% @todo: add instructions w/ code examples for setting up 'dat.paths' \ No newline at end of file +% v0.0.2 diff --git a/docs/setup/paths_template.m b/docs/setup/paths_template.m index e152289c..f2a078b7 100644 --- a/docs/setup/paths_template.m +++ b/docs/setup/paths_template.m @@ -8,6 +8,9 @@ % The main and local repositories are essential for determining where to % save experimental data. % +% If using a seperate MC computer, the main repository must be accessible +% to both the stimulus and master computers. +% % Part of Rigbox % 2013-03 CB created @@ -18,47 +21,57 @@ rig = thishost; end -server1Name = '\\zserver.cortexlab.net'; -server2Name = '\\zubjects.cortexlab.net'; +% Some base locations for storing data. Listing them here means that if we +% change server, only one variable needs changing +server1Name = 'C:\RemoteExpData'; +server2Name = userpath; % Usually ~/Documents/MATLAB basketName = '\\basket.cortexlab.net'; % for working analyses lugaroName = '\\lugaro.cortexlab.net'; % for tape backup %% Essential paths -% Path containing rigbox config folders +% Below are the paths that are essential for running experiments. If using +% both an MC and SC, the same mainRepository must be accessible to both +% computers. In addition, the expDefinitions folder should be shared, or +% at least there should be the same copy of the expDefs on both computers. + +% Path to base folder that contains the Rigbox code p.rigbox = fileparts(which('addRigboxPaths')); % Repository for local copy of everything generated on this rig p.localRepository = 'C:\LocalExpData'; -% Under the new system of having data grouped by mouse +% Under the new system of having data grouped by subject % rather than data type, all experimental data are saved here. -p.mainRepository = fullfile(server1Name, 'Data', 'Subjects'); +p.mainRepository = fullfile(server1Name, 'Subjects'); % Optional alternate named repos may be defined using the repo name % followed by a number. These are searched in addition to the master repo. -p.main2Repository = fullfile(server2Name, 'Subjects'); +% This is useful when distributing data over multiple locations, or +% transitioning to a new remote location +p.main2Repository = fullfile(server2Name, 'Data', 'Subjects'); % Directory for organisation-wide configuration files, for now these should % all remain on zserver -p.globalConfig = fullfile(server1Name, 'Code', 'Rigging', 'config'); +p.globalConfig = fullfile(server2Name, 'Code', 'RigConfig'); % Directory for rig-specific configuration files p.rigConfig = fullfile(p.globalConfig, rig); % Repository for all experiment definitions -p.expDefinitions = fullfile(server1Name, 'Code', 'Rigging', 'ExpDefinitions'); +p.expDefinitions = fullfile(p.rigbox, 'signals', 'docs', 'examples'); %% Non-essential paths % Database url and local queue for cached posts. If empty or undefined, % the AlyxPanel and all Alyx interactions are disabled. NB: If the % protocol isn't specified, https:// is automatically prepended. -p.databaseURL = 'https://alyx.cortexlab.net'; +% p.databaseURL = 'https://alyx.cortexlab.net'; % Uncomment to activate p.localAlyxQueue = 'C:\localAlyxQueue'; % Location of cached posts % Location of git for automatic updates p.gitExe = 'C:\Program Files\Git\cmd\git.exe'; % Day on which to update code (0 = Everyday, 1 = Sunday, etc.) -p.updateSchedule = 0; +p.updateSchedule = 2; % Update every Monday % Alternate file repository: unlike alternates defined with a number (e.g. % 'main2Repository'), 'altRepository' is returned as an alternate to every % named repo -% p.altRepository = fullfile(server1Name, 'Data', 'expInfo'); +% p.altRepository = fullfile(server1Name, 'expInfo'); +% p.alt2Repository = fullfile(server2Name, 'Data', 'expInfo'); %% user-defined repositories % The following paths are not used in the main Rigbox code, however may be diff --git a/docs/setup/websocket_config.m b/docs/setup/websocket_config.m index e93388cd..bc67ba07 100644 --- a/docs/setup/websocket_config.m +++ b/docs/setup/websocket_config.m @@ -167,7 +167,7 @@ % This id means an experiment update from a currently running signals % object (|exp.SignalsExp|) has arrived. Listeners to the ExpUpdate event % are notified with a 'signals' ExpEvent. In |MC| the listeners to this -% are obejects of the |eui.ExpPanel| class, e.g. |eui.squeakExpPanel|. +% are objects of the |eui.ExpPanel| class, e.g. |eui.SignalsExpPanel|. % These panel objects plot and display the update data. %%% - status diff --git a/docs/troubleshooting.m b/docs/troubleshooting.m new file mode 100644 index 00000000..f2fd33ea --- /dev/null +++ b/docs/troubleshooting.m @@ -0,0 +1,378 @@ +%% Troubleshooting +% Often finding the source of a problem seems daunghting when faced with a +% huge Rigbox error stack. Below are some tips on how to quickly get to +% the root of the issue and hopefully solve it. + + +%%% Update the code +% Check what version of the code you're using and that you're up-to-date: +git.runCmd('status'); % Tells me what branch I'm on +git.update(0); % Update now + +% If you're on a development or feature branch try moving to the master +% branch, which should be most stable. +git.runCmd('checkout master'); git.update(0); + + +%%% Examining the stack +% Don't be frightened by a wall of red text! Simply start from the top and +% work out what the errors might mean and what part of code they came from. +% The error at the top is the one that ultimately caused the crash. Try to +% determine if this is a MATLAB builtin function, e.g. +% +% Warning: Error occurred while executing the listener callback for event UpdatePanel defined for class eui.SignalsTest: +% Error using griddedInterpolant +% Interpolation requires at least two sample points in each dimension. +% +% Error in interp1 (line 151) +% F = griddedInterpolant(X,V,method); +% +% TODO Add better example of builtin errors +% +% If you're debugging a signals experiment definition, check for the line +% in your experiment where this particular builtin function was called. NB: +% You can check whether it is specific to your experiment by running one of +% the example experiment definitions such as advancedChoiceWorld.m, found +% in signals/docs/examples. If this runs without error then you're problem +% may be specific to your experiment. You should see the name of your +% definition function and exp.SignalsExp in the stack if they are involved. +% +% If you don't know what a function is, try checking the documentation. +% Consider the following: +% +% Error using open +% Invalid number of channels +% +% Error in audstream.fromSignal (line 16) +% id = audstream.open(sampleRate, nChannels, devIdx); +% [...] +% +% If you're unsure what `audstream.fromSignal` does, try typing `doc +% audstream`. This should tell you that the package deals with audio +% devices in signals. In this case the issue might be that your audio +% settings are incorrect. Take a look at the audio section of +% `docs\setup\hardware_config.m` and see if you can setup your audio +% devices differently. + + +%%% Paths +% By far the most common issue in Rigbox relates to problems with the +% MATLAB paths. Check the following: +% +% # Do you have a paths file in the +dat package? +% Check the location by running `which dat.paths`. Check that a file is +% on the paths and that it's the correct one. +% # Check the paths set in this file. +% Run `p = dat.paths` and inspect the output. Perhaps a path is set +% incorrectly for one of the fields. Note that custom rig paths overwrite +% those written in your paths file. More info found in +% `using_dat_package` and `paths_template`. +% # Do you have path conflicts? +% Make sure MATLAB's set paths don't include other functions that have the +% same name as Rigbox ones. Note that any functions in ~/Documents/MATLAB +% take precedence over others. If you keep seeing the following warning +% check that you've set the paths correctly: +% Warning: Function system has the same name as a MATLAB builtin. We +% suggest you rename the function to avoid a potential name conflict. +% This warning can occur if the tests folder has been added to the paths +% by mistake. Always set the paths by running `addRigboxPaths` and never +% set them manually as some folders should not be visible to MATLAB. +% # Check your working directory +% MATLAB prioritizes functions found in your working directory over any +% others in your path list so try to change into a 'safe' folder before +% re-running your code: +% pwd % display working directory +% cd ~/Documents/MATLAB +% # Check your variable names +% Make sure your variable names don't shadow a function or package in +% Rigbox, for instance if in an experiment definition you create a varible +% called `vis`, you will no longer be able to access functions in the +vis +% package from within the function: +% vis = 23; +% img = vis.image(t); +% Error: Reference to non-existent field 'image'. + + +%%% Reverting +% If these errors only started occuring after updating the code, +% particularly if you hadn't updated in a long time, try reverting to the +% previous version of the code. This can help determine if the update +% really was the culprit and will allow you to keep using the code on +% outdated machines. Previous stable releases can be found on the Github +% page under releases. NB: For the most recent stable code always pull +% directly from the master branch + + +%%% Posting an issue on Github +% If you're completely stumped, open an issue on the Rigbox Github page (or +% alyx-matlab if you think it's related to the Alyx database). When +% creating an issue, read the bug report template carefully and be sure to +% provide as much information as possible. +% +% If you tracked down the problem but found the error to be confusing or +% too vague, feel free to post a feature request describing how better to +% present the error. This is an area in need of improvment. You could also +% make a change yourself and submit a pull request. For more info see +% CONTRIBUTING.md + + +%% FAQ +% Below are some frequently asked questions and suggestions for fixing +% them. Note there are plenty of other FAQs in the various setup scripts +% with more specific information. + + +%% Error and warning IDs +% Below is a list of Rigbox error & warning IDs. This list is currently +% incomplete and there aren't yet very standard. Typically the ID has the +% following structure: module:package:function:error +% +% These are here for search convenience and may soon contain more detailed +% troubleshooting information. + +% ..:..:..:copyPaths +% Problem: +% In order to load various essential configuration files, and to load and +% save experimental data, user specific paths must be retrieved via calls +% to |dat.paths|. This error means the function is not on MATLAB's search +% path. +% +% Solution: +% Add your +dat\paths.m file to MATLAB's search path. A template is +% present in \docs\setup\paths_template.m. This file is automatically +% copied by addRigboxPaths to +dat\. If you haven't already done so, run +% |addRigboxPaths| to ensure all other paths have been correctly set. +% +% See also README.md for further setup information. +% +% IDs +% Rigbox:git:update:copyPaths +% signals:test:copyPaths + +% ..:..:noRemoteFile +% Problem: +% % TODO Add problem & solution for noRemoteFile error +% +% Solution: +% +% +% IDs +% Rigbox:mc:noRemoteFile + +% ..:..:..:notInTest +% Problem: +% This occurs when a mock function is called when the INTEST global +% variable is not set. These mock functions shadow Rigbox and builtin +% functions, meaning they have the same name. +% +% Solution: +% If this function was called during a test, add the following to the top +% of your test or in the constructor: +% global INTEST +% INTEST = true +% Ensure that this is cleared during the teardown: +% addteardown(@clear, INTEST) % If in a class +% mess = onCleanup(@clear, INTEST) % If in a function +% +% If the mock in question is a class, set the InTest flag instead of the +% global variable: +% mock = MockDialog; % An example using MockDialog class +% mock.InTest = true; +% addteardown(@clear, MockDialog) % Clear mock class when done +% mess = onCleanup(@clear, MockDialog) % If in a function +% +% If you are in not running tests, ensure that tests/fixtures is not in +% your MATLAB path and that you are in a different working directory. It +% is best to remove all Rigbox paths and readd them using `addRigboxPaths` +% +% IDs +% Rigbox:tests:system:notInTest +% Rigbox:tests:modDate:notInTest +% Rigbox:tests:paths:notInTest +% Rigbox:tests:pnet:notInTest +% Rigbox:tests:modDate:missingTestFlag % TODO change name +% Rigbox:MockDialog:newCall:InTestFalse + +% ..:..:..:behaviourNotSet +% Problem: +% A mock function was called while in a test, however the behaviour for +% this particular input has not been defined. +% +% Solution: +% If not testing a specific behavior for this function's output, simply +% supress the warning in your test, remembering to restore the warning +% state: +% origState = warning; +% addteardown(@warning, origState) % If in a class +% mess = onCleanup(@warning, origState) % If in a function +% warning('Rigbox:MockDialog:newCall:behaviourNotSet', 'off') +% +% If you're specifically testing the behavior when the mock returns a +% particular output then check that you've set the input-output map +% correctly: usually this is done by first calling the mock with input +% identical to function under test as well as the output you want to see. +% Check the input is formatted correctly. For more information see the +% help of the particular mock you are using. +% +% IDs +% Rigbox:tests:system:valueNotSet % TODO change name +% Rigbox:MockDialog:newCall:behaviourNotSet +% + +% ..:..:mkdirFailed +% Problem: +% MATLAB was unable to create a new folder on the system. +% +% Solution: +% In general Rigbox code only creates new folders when a new experiment is +% created. The folders are usually created in the localRepository and +% mainRepository locations that are set in your paths file. If either of +% these are remote (e.g. a server accessed via SMB) check that you can +% navigate to the location in Windows' File Explorer (sometimes the access +% credentials need setting first). If you can, next check the permissions +% of these locations. If the folders are read-only, MATLAB will not be +% able to create a new experiment folder there. Either change the +% permissions or set a different path in |dat.paths|. One final thing to +% check is that the folder names are valid: the presence of a folder that +% is not correctly numbered in the subject's date folder may lead to an +% invalid expRef. Withtin a date folder there should only be folders name +% '1', '2', '3', etc. +% +% IDs +% Alyx:newExp:mkdirFailed +% Rigbox:dat:newExp:mkdirFailed +% + +% ..:newExp:expFoldersAlreadyExist +% Problem: +% The folder structure for a newly generated experiment reference is +% already in place. +% +% Experiment references are generated based on subject name, today's date +% and the experiment number, which is found by looking at the folder +% structure of the main repository. In a subject's experiment folder for +% a given date there are numbered folders. When running a new experiment, +% the code takes the folder name with the largest number and adds 1. It +% then checks that this numbered folder doesn't exist in the other +% repositories. If it does, an error is thrown so that no previous +% experiment data is overwritten. +% +% Solution: +% Check the folder structure for all your repositories (namely the +% localRepository and mainRepository set in |dat.paths|). It may be that +% there is an empty experiment folder in the localRepository but not the +% mainRepository, in which case you can delete it. Alternatively, if you +% find a full experiment folder in the local but not the main, copy it +% over so that the two match. This will avoid a duplicate expRef being +% created (remember, new expRefs are created based on the folder structure +% of the mainRepository only). +% +% IDs +% Alyx:newExp:expFoldersAlreadyExist +% Rigbox:dat:newExp:expFoldersAlreadyExist +% + +% ..:..:expRefNotFound +% Problem: +% The experiment reference string does not correspond to the folder +% structure in your mainRepository path. Usually determined via a call to +% |dat.expExists|. +% +% Solution: +% Check that the mainRepository paths are the same on both the computer +% that creates the experiment (e.g. MC) and the one that loads the +% experiment (e.g. the one that runs |srv.expServer|). For an experiment +% to exist, the subject > date > sequence folder structure should exist in +% the mainRepository. To see the mainRepository location, run the +% following: +% getOr(dat.paths, 'mainRepository') +% For example if the output is '\\server\Subjects\' then for the expRef +% '2019-11-25_1_test' to exist, the following folder should exist: +% \\server\Subjects\test\2019-11-25\1 +% +% IDs +% Rigbox:srv:expServer:expRefNotFound + +% ----- ! PTB - ERROR: SYNCHRONIZATION FAILURE ! ---- +% Problem: +% To quote PsychToolbox: One or more internal checks indicate that +% synchronization of Psychtoolbox to the vertical retrace (VBL) is not +% working on your setup.This will seriously impair proper stimulus +% presentation and stimulus presentation timing! +% +% Solution: +% There are many, many reasons for this error. Here's a quick list of +% things to try, in order: +% +% # Simply re-trying a couple of times. Sometimes it happens +% sporadically. +% # Check the monitor(s) are on and plugged in. If you're using +% multiple monitors they should be of the same make and model. If they +% aren't, try with just one monitor first. +% # If you're using multiple screens in NVIDEA's 'Mosaic' mode, the +% settings may have changed: sometimes Mosiac becomes deactivated and you +% should set it up again. +% # If you're using a remote connection for that computer it may be +% interfering with the graphics settings. Examples of a remote +% connection include VNC servers, TeamViewer and Windows Remote Desktop. +% Try opening the PTB Window without any of these remote services. +% # Update the graphics card drivers and firmware. This often helps. +% # Read the PTB docs carefully and follow their suggestions. The docs +% can be found at http://psychtoolbox.org/docs/SyncTrouble. +% # If all else fails. You can skip these tests and check that there is +% no taring manually. This is not recommended but can be done by setting +% your stimWindow object's PtbSyncTests property to false: +% stimWindow = getOr(hw.devices([],false), 'stimWindow'); +% stimWindow.PtbSyncTests = false; +% hwPath = fullfile(getOr(dat.paths, 'rigConfig'), 'hardware.mat'); +% save(hwPath, 'stimWindow', '-append') + +%%% Undocumented IDs +% Below is a list of all error and warning ids. + +% Rigbox:git:runCmd:nameValueArgs +% Rigbox:git:runCmd:gitNotFound +% Rigbox:git:update:valueError +% +% Rigbox:hw:calibrate:noscales +% Rigbox:hw:calibrate:deadscale +% Rigbox:hw:calibrate:partialPVpair +% +% Rigbox:srv:unexpectedUDPResponse +% Rigbox:srv:unexpectedUDP +% Rigbox:srv:expServer:noHardwareConfig +% +% Rigbox:dat:expPath:NotEnoughInputs +% Rigbox:exp:SignalsExp:NoScreenConfig +% Rigbox:exp:Parameters:wrongNumberOfColumns +% +% Rigbox:dat:expFilePath:NotEnoughInputs +% +% Rigbox:MockDialog:newCall:EmptySeq +% +% Rigbox:exp:SignalsExp:noTokenSet +% +% Rigbox:eui:choiceExpPanel:toolboxRequired +% Rigbox:setup:toolboxRequired +% +% Alyx:newExp:subjectNotFound +% Alyx:registerFile:InvalidPath +% Alyx:registerFile:UnableToValidate +% Alyx:registerFile:EmptyDNSField +% Alyx:registerFile:InvalidRepoPath +% Alyx:registerFile:InvalidFileType +% Alyx:registerFile:InvalidFileName +% Alyx:registerFile:NoValidPaths +% Alyx:updateNarrative:UploadFailed +% +% Alyx:getFile:InvalidID +% Alyx:getExpRef:InvalidID +% Alyx:getFile:InvalidType +% Alyx:expFilePath:InvalidType +% Alyx:url2Eid:InvalidURL +% +% toStr:isstruct:Unfinished +% +% squeak.hw +% shape:error +% window:error diff --git a/docs/using_ExpPanel.m b/docs/using_ExpPanel.m new file mode 100644 index 00000000..8afb6749 --- /dev/null +++ b/docs/using_ExpPanel.m @@ -0,0 +1,241 @@ +%% Introduction +% ExpPanels are panels under the Experiment > Current tab of mc that +% display information about events occuring during an experiment. This +% document contains information on how to set up an ExpPanel for +% customizing the monitoring of an Experiment. + +%% exp.ExpPanel +% The base class for the ExpPanel is the |exp.ExpPanel|. All subclasses +% chain a call to this class. +% +% When starting a new experiment in MC a new ExpPanel is created by calling +% the static contructor method `live`: +% +% p = live(parent, ref, remoteRig, paramsStruct, activateLog) +% doc eui.ExpPanel/live +% +% The precise subclass used depends on the `type` parameter in the +% paramsStruct input. Currently supported types include +% SingleTargetChoiceWorld, ChoiceWorld, DiscWorld, SurroundChoiceWorld +% (|eui.ChoiceExpPanel|); BarMapping (|eui.MappingExpPanel|); custom a.k.a. +% Signals (|eui.SignalsExpPanel|). +% +% For Signals experiments the default ExpPanel class may be overridden by +% providing a parameter named `expPanelFun` whose value is either a +% function handle for an ExpPanel constructor or path to the class to be +% instantiated. This parameter is automatically added in MC if the folder +% from which the experiment function was loaded contains an ExpPanel. The +% name must be the same as the experiment function but with 'ExpPanel' +% added, e.g. for 'advancedChoiceWorld.m', the corresponding ExpPanel file +% would be 'advancedChoiceWorldExpPanel.m'. + +%% Basic layout +% The ExpPanel has the following basic layout... + +%%% Title +% The panel title contains the experiment reference and the name of the +% remote rig. When the experiment is initializing or during the +% cleanup/post-delay phase the title is amber. During the main experiment +% phase the title turns green and when complete, red. This title colour +% and other properties are set in the `live` method then subsequently by +% the `event` method. The title is stored in the Root.Title property. + +%%% InfoGrid +% The info grid contains all experiment event labels and their current +% values. As new events occur they're added to the last via a call to +% addInfoField. There are 4 default fields: +% +% * Status - The current experimental phase, e.g. 'Pending', 'Complete'. +% The status is set based on 'ExpUpdate' events from the remote rig +% (see the `expUpdate` method). +% * Duration - The time elapsed since the experiment began. This +% is updated each time the `update` method is called (every 100ms in +% MC). +% * Trial count - The total number of trials. This field is updated based +% on the 'newTrial' ExpUpdate status (see `expUpdate` method). +% * Condition - The current trial condition. This only appears if +% `conditionId` parameter is defined. +% +% The fields may be hidden by right-clicking one and selecting 'Hide +% field'. The hidden fields may be reset by selecting 'Reset hidden'. + +%%% CustomPanel +% A container for subclasses to build plots into. For example in the +% ChoiceWorld Experiment, this contains a psychometric curve plot and the +% trace of the wheel input. + +%%% CommentsBox +% An input field for taking notes. These are automatically saved to the +% Log (see |dat.logPath|, |dat.logEntries|). If logged into Alyx the notes +% are also saved to the database session narrative (see +% |Alyx.updateNarrative|). The comments box may be hidden by +% right-clicking and selecting 'Hide comments'. + +%%% ButtonPanel +% A set of buttons for ending/aborting the experiment as well as viewing +% the parameter set. If End is pressed, the experiment is ended after the +% post delay, the block's endStatus field is set to 'quit', and ALF files +% may be extracted from the block. If Abort is pressed, the post delay is +% skipped, the endStatus is set to 'aborted' and no ALF files are extracted +% from the block during save. + +%% Method call sequence +% Below is the sequence of method calls. This is useful to be aware of +% when making your own subclass. +% +% mc/beginExp +% | +% eui.ExpPanel/live +% | +% eui._ExpPanel/_ExpPanel (may be a subclass constructor, e.g. +% | SignalsExpPanel) +% eui._ExpPanel/build (subclasses should chain call to superclass build) +% | +% eui.ExpPanel/addInfoField (adds any default info labels, e.g. +% TrialCount) +% +% +% eui._ExpPanel/update (mc timer callback) +% | (if eui.SignalsExpPanel or subclass. +% | NB: All subclasses should chain a call to superclass update) +% eui._ExpPanel/processUpdates (method only present in SignalExpPanel +% | classes) +% eui.ExpPanel/addInfoField (adds any new Signals event fields) + +%% exp.SignalsExpPanel +% The subclass, |eui.SignalsExpPanel|, is the default class for Signals +% Experiments. In this class, all Signals updates are shown as InfoFields +% whose colours pulse green as the values update. The signals sent from +% the stimulus computer includes events, parameters, inputs and outputs +% signals. The 'Trial count' field reflects the value of events.trialNum. +% +% The UpdatesFilter property contains a list of signals updates to create a +% label for, or if Exclude == true, all signals names in this list are +% ignored. This is useful when your events structure is large and you +% don't wish to see all of them during the experiment. +% +% |exp.SignalsExp| periodically(1) sends signals event updates to the |MC| +% computer(2). These updates trigger the expUpdate method which stores the +% updates in the SignalUpdates property. All updates in this property are +% delt with and removed by the processUpdates method, which is called via +% the update by the |MC| Refresh timer once per 100ms(3). +% +% The SignalUpdate property is a struct with the following fields: +% +% * name - The name of the signal, e.g. 'events.newTrial' +% * value - The value of the signal. +% * timestamp - a date vector of the date and time when the signal was +% queued. (NB: This is in the system time of the remote rig and depends on +% its timezone. These timestamps aren't as precise as those in the block +% file). +% +% When new updates are processed in |eui.SignalsExpPanel|, if an info field +% does not already exist, one is created. When the FormatLabels property +% is true the Signals update labels are formatted as sperate words. For +% example 'events.newTrial' is displayed as 'New trial'. This flag and +% others such as the UpdatesFilter can be set it your subclass constructor. + +%% Custom Signals ExpPanels +% Below is a list of steps to follow when creating a custom Signals +% ExpPanel, for an example of this see advancedChoiceWorldExpPanel(4): +% +% # Subclass |eui.SignalsExpPanel| +% Subclassing means you will inherit all of the methods and properties +% found in |eui.SignalsExpPanel| and |eui.ExpPanel|. +% # Add any extra properties specific to your ExpPanel +% For example if you're creating a new plot you may wish to store the axes +% in a property. (c.f. PsychometricAxes in advancedChoiceWorldExpPanel) +% # Add a constructor to initialize any properties if required +% Chain a call to the superclass method like so: +% |obj = obj@eui.SignalsExpPanel(parent, ref, params, logEntry);| +% # Add a build method to initialize an axes or extra UI elements. +% Typically everything built here will use obj.CustomPanel as the parent +% container. This method must have protected access. +% Chain a call to the superclass method first: +% |build@eui.SignalsExpPanel(obj, parent);| +% # Add a processUpdates to deal with your experiment-specific events. +% Here you can add code to update plots, etc. based on the event updates. +% This method must have protected access. Instead of chaining a call, +% copy the code from eui.SignalsExpPanel/processUpdates directly and use +% it as a template for your own functions. +% +% +% There are some useful superclass methods that are useful to keep in mind: +% +% * mergeTrialData - Update the local block structure with data from the +% last trial. This is found in eui.ExpPanel. +% * newTrial - This doesn't do anything in the superclasses but is a good +% place to put code that should be executed at events.newTrial. Call it +% from processUpdates. +% * trialCompleted - As with newTrial, this could be used as a place for +% code that runs after e.g. an outputs or feedback event. +% * expStopped - Useful for executing code when the session ends. Must +% chain a call to superclass here. +% * expStarted - See above. +% * cleanup - Place code here for e.g. stopping timers, clearing listeners, +% etc. +% +% +% Here are some useful properties to be aware of: +% +% * Parameters - A copy of that experiment's paramStruct. +% * UpdatesFilter - As mentioned above, this holds a cell array of events +% you wish to ignore/include. It's behaviour depends on whether the +% Exclude property is true or false. +% * RecentColour - The colour of recently updated Signals update events in +% the InfoGrid. This can be changed dynamically during the session, for +% instance could turn red as the subject's performance declines or towards +% the end of the session. +% +% +% Finally there are some other useful untilities to be aware of: +% +% * +psy - The |psy| package contains useful functions for plotting +% psychometrics and producing fits. +% * |bui.Axes| - This class provides a convenient way to interact with +% plotting axes. It's particularly useful if you wish to add multple +% elements to the same axes. Below is an example from advancedChoiceWorld: + +obj.ExperimentAxes = bui.Axes(plotgrid); % Create new bui.Axes object +obj.ExperimentAxes.ActivePositionProperty = 'position'; +obj.ExperimentAxes.XTickLabel = []; % Remove the X tick labels +obj.ExperimentAxes.NextPlot = 'add'; % Add new plots without clearing axes +% First initialize a plot for the wheel trace and store the resulting axes +obj.ExperimentHands.wheelH = plot(obj.ExperimentAxes,... + [0 0],... + [NaN NaN],... + 'Color', .75*[1 1 1]); +% Now initialize a threshold line on the same plot +obj.ExperimentHands.threshL = plot(obj.ExperimentAxes, ... + [0 0],... + [NaN NaN],... + 'Color', [1 1 1], 'LineWidth', 4); +% Note that updating plots can be memory intensive, so consider tweaks such +% as updating the underlying plot data instead of clearing and redrawing: +set(obj.ExperimentHands.wheelH,... + 'XData', xx,... + 'YData', tt); +% Also take a look at the drawnow builtin function: +doc drawnow + +%% Notes, etc. +% (1) |exp.SignalsExp| sends any new Signals event updates once every 100ms: +% opentoline(which('exp.SignalsExp'), 731, 9) +% +% (2) Any number of computers may listen for these updates, see +% <./websocket_config.html websocket_config> +% +% (3) See |eui.MControl|: +% opentoline(which('eui.MControl'), 84, 7) +% +% (4) The below three lines will open this file: + +% rigbox = getOr(dat.paths, 'rigbox'); % Location of Rigbox code +% exampleExps = fullfile(rigbox, 'signals', 'docs', 'examples'); +% open(fullfile(exampleExps, 'advancedChoiceWorldExpPanel.m')) + +% Author: Miles Wells +% +% v1.0.0 + +%#ok<*NOPTS,*ASGLU,*NASGU> diff --git a/docs/using_dat_package.m b/docs/using_dat_package.m index 11c372da..6a74c8a1 100644 --- a/docs/using_dat_package.m +++ b/docs/using_dat_package.m @@ -15,6 +15,23 @@ % experiments. Any number of custom repositories may be set, allowing them % to be queried using functions such as DAT.REPOSPATH and DAT.EXPPATH (see % below). +% +% It may be prefereable to keep the paths file in a shared network drive +% where all rigs can access it. This way only one file needs updating when +% a path gets changed. You can also override and add to the fields set by +% the paths file in a rig specific manner. To do this, create your paths +% as a struct with the name `paths` and save this to a MAT file called +% `paths` in your rig specific config folder: +rigConfig = getOr(dat.paths('exampleRig'), 'rigConfig'); +customPathsFile = fullfile(rigConfig, 'paths.mat'); +paths.mainRepository = 'overide/path'; % Overide main repo for `exampleRig` +paths.addedRepository = 'new/custom/path'; % Add novel repo + +save(customPathsFile, 'paths') % Save your new custom paths file. + +% More info in the paths template: +root = getOr(dat.paths, 'rigbox'); +opentoline(fullfile(root, 'docs', 'setup', 'paths_template.m'), 75) %% Using expRefs % Experiment reference strings are human-readable labels constructed from @@ -117,4 +134,4 @@ % % v1.0.0 -%#ok<*NASGU,*ASGLU,*ASGLU> \ No newline at end of file +%#ok<*NASGU,*ASGLU,*ASGLU> diff --git a/docs/using_parameters.m b/docs/using_parameters.m index 887d98ba..b86a452e 100644 --- a/docs/using_parameters.m +++ b/docs/using_parameters.m @@ -46,7 +46,7 @@ % number of columns. % % Examples: -% + % A global parameter whose value is a charecter paramStruct.rewardKey % (1,1) % A global parameter whose value is a column vector @@ -86,13 +86,13 @@ % A char or function handle of the experiment panel class to use for % visualization during the experiment in MC. This allows users to display % custom plots, etc. for monitoring different experiments. See -% EUI.EXPPANEL, EUI.SQUEAKEXPPANEL (default) for more info. +% EUI.EXPPANEL, EUI.SIGNALSEXPPANEL (default) for more info. %%% type % A legacy parameter that defines what experiment class to use. Options % include 'ChoiceWorld' and 'custom', where the latter indicates a signals % experiment. For these two options the experiment classes are -% EXP.CHOICEWORD and EXP.SIGNALSEXP, respectively (see note 2). +% EXP.CHOICEWORLD and EXP.SIGNALSEXP, respectively (see note 2). %%% services % A cellstr array of service names to be activaed during experiment setup. @@ -108,7 +108,7 @@ %%% isPassive % When true the experiment is a re-play of a previous one. This feature is % not yet fully implemented. - +% % In addition to these, parameters can be attributed descriptions and units % by setting fields with the same name the end in 'Description' and 'Units' % respectively. These are displayed in the ParamEditor GUI. See below. @@ -128,7 +128,7 @@ % following trial. See note 4. %% Saving parameter sets -% EXP.INFERPARAMETERS is useful for loading an experiment's default +% |exp.inferParameters| is useful for loading an experiment's default % parameters (set in the definition function itself). Also, each time you % run an experiment a copy of the modified parameter set is saved in the % experiment folder. The last used parameters for a given experiment and @@ -138,7 +138,7 @@ % given experiment, independent of the subject? For this we use the % <./using_dat_package.html dat package>... % -% To save a new parameter set simply call DAT.SAVEPARAMPROFILE: +% To save a new parameter set simply call |dat.saveParamProfile|: paramStruct.rewardSize = 5; % We want to save our modified params paramSetName = 'highReward'; % Give our saved set a name dat.saveParamProfile('custom', paramSetName, paramStruct) @@ -160,7 +160,7 @@ %% The Parameters object % Modifying sturctures directly is error-prone and time consuming for % certain tasks. To manipulate parameters more easily we use the -% EXP.PARAMETERS class. +% |exp.Parameters| class. parameters = exp.Parameters(paramStruct) % a new Parameters object % The Parameters object contains fields indicating which parameters are @@ -228,16 +228,16 @@ %% The ParamEditor GUI % The easiest way to modify parameters is via the ParamEditor GUI, managed -% by the EUI.PARAMEDITOR class. A ParamEditor object is embedded into MC +% by the |eui.ParamEditor| class. A ParamEditor object is embedded into MC % and can also be instantiated via the Experiment Panels. - +% % To instantiate a standalone editor, call EUI.PARAMEDITOR with a % Parameters object. Additionally a parent figure handle may be provided. -PE = eui.ParamEditor(parameters) - +% % There are two panels that make up the editor. On the left are the global % parameters and on the right is the trial conditions table, containing the % conditional parameters. +PE = eui.ParamEditor(parameters) %%% Global panel % The global panel is pretty simple. Each parameter is represented by the @@ -247,7 +247,7 @@ % commas. For instance typing '1, 3, 5' (without quotes) would set the % value of that parameter to [1; 3; 5]. For numrical parameters spaces % alone will suffice, e.g. '1 3 5'. - +% % To make a parameter conditional, right-clicking on the input field or % title and select 'Make conditional'. The parameter will now appear in % the conditions table to the right. @@ -256,23 +256,23 @@ % Here each parameter is represented as a table column, and its conditions % (i.e. the columns of the parameter) as table rows. The rows may be % re-ordered by dragging the field name of the column you with to move. - +% % New blank conditions (i.e. rows) can be added by clicking the 'New % condition' button at the bottom of the table. - +% % Individual cells can be selected and edited as expected. To select % multiple cells, hold down the ctrl key while clicking. To select % multiple conditions in a row, hold the shift key. Once you've selected a % cell in at least one column, the other buttons become available: - +% % 'Delete condition' allows you to delete the table rows of the selected % cells (i.e. the selected trial conditions). - +% % 'Globalise parameter' makes the columns of the selected cells global % parameters, whereby they are moved to the left panel. The value in the % last selected cell for that parameter is used as the new parameter value. % This may also be done via the context menu. - +% % 'Set values' allows you can set multiple cells are once. One clicked a % small dialog appears with am input field for each selected column. The % number of selected cells for each column is shown in brackets. Entering @@ -284,7 +284,7 @@ % % This is particularly useful for more involved stimulus sets with many % conditions. - +% % By right-clicking anywhere in the condition table you get two extra % options: % @@ -295,7 +295,7 @@ % parameter of the same name. By default this is checked. When unchecked, % the rows of the table are given an index, indicating the set order trial % conditions. - +% % Once your parameters have been modified via the GUI they can be saved by % extracting the underlying parameter stuct and saving to a file: dat.saveParamProfile('custom', 'variant_2', PE.Parameters.Struct) @@ -305,7 +305,7 @@ % different sorts of experiments. In the below examples imagine you have a % signals experiment definition with three conditional parameters, A, B and % C (see note 6). - +% % By default all conditions are presented in a random order n times, where % n is defined by the numRepeats parameter. If numRepeats is made a global % parameter, then all conditions are presented the same number of times. @@ -529,6 +529,8 @@ % Author: Miles Wells % -% v1.0.0 +% v1.1.0 -%#ok<*NOPTS,*ASGLU,*NASGU> \ No newline at end of file +% INTERNAL: +% ln209 ParamEditor.png +%#ok<*NOPTS,*ASGLU,*NASGU> diff --git a/docs/using_wheel.m b/docs/using_wheel.m new file mode 100644 index 00000000..fd02b9ee --- /dev/null +++ b/docs/using_wheel.m @@ -0,0 +1,6 @@ +%% Introduction +% In thehe Burgess wheel task a visual stimulus is yoked to LEGO wheel via +% a rotary encoder. Below are some things to consider when designing or +% modifying a wheel task. + +%% The wheel input in Signals diff --git a/mc.m b/mc.m index 76379578..dc6f288f 100644 --- a/mc.m +++ b/mc.m @@ -7,6 +7,11 @@ % Pull latest changes from remote git.update(); +% Perform some checks before instantiating +% NB: paths file check is performed by git.update +assert(file.exists(fullfile(getOr(dat.paths, 'globalConfig'), 'remote.mat')), ... + 'Rigbox:mc:noRemoteFile', ['No remote file found in globalConfig repository. ',... + 'Please check your paths file and setup your ''remote.mat'' file']) f = figure('Name', 'MC',... 'MenuBar', 'none',... 'Toolbar', 'none',... diff --git a/signals b/signals index 3e99fda8..49768f2d 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 3e99fda8da10d760c0d8df69d781612a73c1df26 +Subproject commit 49768f2d872995512f33b95d604450f6f9c314d6 diff --git a/tests/AlyxPanel_test.m b/tests/AlyxPanel_test.m index 1027f0f3..d13e061e 100644 --- a/tests/AlyxPanel_test.m +++ b/tests/AlyxPanel_test.m @@ -55,6 +55,11 @@ function loadData(testCase) function setupPanel(testCase, BaseURL) % Check paths file assert(endsWith(which('dat.paths'), fullfile('fixtures','+dat','paths.m'))); + + % Set INTEST flag to true + setTestFlag(true); + testCase.addTeardown(@setTestFlag, false) + % Check temp mainRepo folder is empty. An extra safe measure as we % don't won't to delete important folders by accident! mainRepo = dat.reposPath('main','master'); diff --git a/tests/Contents.m b/tests/Contents.m index d78642b5..c9f7d3ce 100644 --- a/tests/Contents.m +++ b/tests/Contents.m @@ -1,40 +1,43 @@ -% TESTS Unit tests for Rigbox. +% TESTS Unit and integration tests for Rigbox. % Version xxx 02-Oct-2019 % The tests folder contains all tests and fixtures for testing core Rigbox -% functions and classes. To quickly run all tests, use `runall`. This -% will additionally run tests on alyx-matlab and signals. To quickly -% check the coverage of a given test, use `checkCoverage`. +% functions and classes. To run all tests, use `runall`. This will +% additionally run tests on alyx-matlab and signals. To quickly check the +% coverage of a given test, use `checkCoverage`. % % Files: -% runall - gathers and runs all tests in Rigbox -% checkCoverage - Check the coverage of a given test -% AlyxPanel_test - Tests for eui.AlyxPanel -% ParamEditor_test - Tests for eui.ParamEditor -% Parameters_test - Tests for exp.Parameters -% calibrate_test - Tests for hw.calibrate -% catStructs_test - Tests for catStructs -% cellflat_test - Tests for cellflat -% cellsprintf_test - Tests for cellsprintf -% dat_test - Tests for the +dat package -% emptyElems_test - Tests for emptyElems -% ensureCell_test - Tests for ensureCell -% expServer_test - Tests for srv.expServer -% fileFunction_test - Tests for fileFunction -% file_test - Tests for the +file package -% fun_test - Tests for the +fun package -% iff_test - Tests for iff -% inferParams_test - inferParams test -% loadVar_test - loadVar test -% mapToCell_test - Tests for mapToCell function -% mergeStructs_test - mergeStructs test -% namedArg_test - namedArg test -% nop_test - Tests for nop -% num2cellstr_test - Tests for num2cellstr -% obj2json_test - Tests for obj2struct -% pick_test - pick test -% repelems_test - repelems test -% structAssign_test - structAssign test -% superSave_test - superSave test -% tabulateArgs_test - Tests for tabulateArgs -% varName_test - varName test - +% runall - Gathers and runs all tests in Rigbox +% checkCoverage - Check the coverage of a given test +% +% AlyxPanel_test - Tests for eui.AlyxPanel +% ExpPanel_test - Tests for eui.ExpPanel +% ParamEditor_test - Tests for eui.ParamEditor +% Parameters_test - Tests for exp.Parameters +% calibrate_test - Tests for hw.calibrate +% catStructs_test - Tests for catStructs +% cellflat_test - Tests for cellflat +% cellsprintf_test - Tests for cellsprintf +% dat_test - Tests for the +dat package +% emptyElems_test - Tests for emptyElems +% ensureCell_test - Tests for ensureCell +% expServer_test - Tests for srv.expServer +% fileFunction_test - Tests for fileFunction +% file_test - Tests for the +file package +% fun_test - Tests for the +fun package +% iff_test - Tests for iff +% inferParams_test - inferParams test +% loadVar_test - loadVar test +% mapToCell_test - Tests for mapToCell function +% mergeStructs_test - mergeStructs test +% namedArg_test - namedArg test +% nop_test - Tests for nop +% num2cellstr_test - Tests for num2cellstr +% obj2json_test - Tests for obj2struct +% pick_test - pick test +% repelems_test - repelems test +% StimulusControl_test - Tests for srv.StimulusControl and +% srv.stimulusControllers +% structAssign_test - structAssign test +% superSave_test - superSave test +% tabulateArgs_test - Tests for tabulateArgs +% varName_test - varName test diff --git a/tests/ExpPanelTest.m b/tests/ExpPanelTest.m new file mode 100644 index 00000000..12f24b29 --- /dev/null +++ b/tests/ExpPanelTest.m @@ -0,0 +1,198 @@ +classdef (SharedTestFixtures={ % add 'fixtures' folder as test fixture + matlab.unittest.fixtures.PathFixture('fixtures'),... + matlab.unittest.fixtures.PathFixture(['fixtures' filesep 'expDefinitions'])})... + ExpPanelTest < matlab.unittest.TestCase + + properties (SetAccess = protected) + % The figure that contains the ExpPanel + Parent + % Handle for ExpPanel + Panel eui.ExpPanel + % Remote Rig object + Remote srv.StimulusControl + % A parameters structure + Parameters + % An experiment reference string + Ref + end + + properties (TestParameter) + % Experiment type under test + ExpType = {'Base', 'Signals'} % TODO Add tests for ChoiceWorld, etc. + end + + methods (TestClassSetup) + function setup(testCase) + % SETUP Set up test case + % The following occurs during setup: + % 1. Creating parent figure, turn off figure visability and delete + % on taredown. + % 2. Set test flag to true to avoid path in test assertion error. + % 3. Applies repos fixture and create a test subject and expRef. + % 4. Instantiates a StimulusControl object for event listeners. + + % Hide figures and add teardown function to restore settings + def = get(0,'DefaultFigureVisible'); + set(0,'DefaultFigureVisible','off'); + testCase.addTeardown(@set, 0, 'DefaultFigureVisible', def); + + % Create figure for panel + testCase.Parent = figure(); + testCase.addTeardown(@delete, testCase.Parent) + + % Set INTEST flag to true + setTestFlag(true); + testCase.addTeardown(@setTestFlag, false) + + % Ensure we're using the correct test paths and add teardowns to + % remove any folders we create + testCase.applyFixture(ReposFixture) + + % Now create a single subject folder for testing the log + subject = 'test'; + mainRepo = dat.reposPath('main', 'master'); + assert(mkdir(fullfile(mainRepo, subject)), ... + 'Failed to create subject folder') + testCase.Ref = dat.constructExpRef(subject, now, 1); + + % Set up a StimulusControl object for simulating rig events + testCase.Remote = srv.StimulusControl.create('testRig'); + end + end + + methods + function setupParams(testCase, ExpType) + % SETUPPARAMS Set up parameters struct + % Create a parameters structure depending on the ExpType. + + switch lower(ExpType) + case 'signals' + % A Signals experiment without the custom ExpPanel. Instantiates + % the eui.SignalsExpPanel class + testCase.Parameters = struct('type', 'custom', 'defFunction', @nop); + case 'choiceworld' + % ChoiceWorld experiment params. Instantiates the + % eui.ChoiceExpPanel class + testCase.Parameters = exp.choiceWorldParams; + case 'custom' + % Signals experiment params with the expPanelFun parameter. + % Calls the function defined in that parameter + testCase.Parameters = exp.inferParameters(@advancedChoiceWorld); + case 'base' + % Instantiates the eui.ExpPanel base class + testCase.Parameters = struct(... + 'experimentFun', @(pars, rig) nop, ... + 'type', 'unknown'); + case 'barmapping' + % Instantiates the eui.MappingExpPanel class + testCase.Parameters = exp.barMappingParams; + otherwise + testCase.Parameters = []; + end + end + end + + methods (TestMethodTeardown) + function clearFigure(testCase) + % Completely reset the figure on taredown + testCase.Parent = clf(testCase.Parent, 'reset'); + end + end + + methods (Test) + function test_live(testCase, ExpType) + % Test the live constructor method for various experiment types. The + % following things are tested: + % 1. Default update labels + % 2. ActivateLog parameter functionality + % 3. Comments box context menu functionality + % 4. TODO Test comments changed callback + % 5. TODO Check params button function + setupParams(testCase, ExpType) + inputs = { + testCase.Parent; + testCase.Ref; + testCase.Remote; + testCase.Parameters}; + testCase.Panel = eui.ExpPanel.live(inputs{:}, 'ActivateLog', false); + + testCase.fatalAssertTrue(isvalid(testCase.Panel)) + % Test the default labels have been created + % Find all labels + labels = findall(testCase.Parent, 'Style', 'text'); + expected = {'0', '-:--', 'Pending', 'Trial count', 'Elapsed', 'Status'}; + testCase.verifyEqual({labels.String}, expected, 'Default labels incorrect') + comments = findall(testCase.Parent, 'Style', 'edit'); + testCase.assertEmpty(comments, 'Unexpected comments box'); + + % Test build with log activated + delete(testCase.Panel) % Delete previous panel + testCase.Panel = eui.ExpPanel.live(inputs{:}, 'ActivateLog', true); + % Check Comments label exists + labels = findall(testCase.Parent, 'Style', 'text'); + commentsLabel = labels(strcmp({labels.String}, 'Comments')); + testCase.assertNotEmpty(commentsLabel) + % Check comments box exits + comments = findall(testCase.Parent, 'Style', 'edit'); + testCase.assertNotEmpty(comments, 'Failed to create comments box'); + % Test comments box hiding + testCase.assertTrue(strcmp(comments.Visible, 'on')) + menuOption = commentsLabel.UIContextMenu.Children(1); + menuOption.MenuSelectedFcn(menuOption) % Trigger menu callback + testCase.assertTrue(strcmp(comments.Visible, 'off'), 'Failed to hide comments') + menuOption.MenuSelectedFcn(menuOption) % Trigger menu callback + testCase.assertTrue(strcmp(comments.Visible, 'on'), 'Failed to show comments') + end + + function test_formatLabels(testCase) + % Test the formatting of InfoField labels in eui.SignalsExpPanel when + % the FormatLabels property is set to true. + + % Parameters for instantiation of eui.SignalsExpPanel class + setupParams(testCase, 'signals') + inputs = { + testCase.Parent; + testCase.Ref; + testCase.Remote; + testCase.Parameters}; + + % Some events to trigger for the panel to accept signals updates + initEvent = srv.ExpEvent('started', testCase.Ref); + startedEvent = srv.ExpEvent('update', testCase.Ref, ... + {'event', 'experimentStarted', clock}); + testCase.Panel = eui.ExpPanel.live(inputs{:}, 'ActivateLog', false); + notify(testCase.Remote, 'ExpStarted', initEvent) + notify(testCase.Remote, 'ExpUpdate', startedEvent) + + % Initialize the signals update event data + data = struct(... + 'name', '', ... + 'value', 'true', ... + 'timestamp', clock); + + % For both states, test the label format + for formatLabels = [false true] + name = toStr(formatLabels); + name(1) = upper(name(1)); + data.name = ['events.testEvent', name]; % A unique event name + testCase.Panel.FormatLabels = formatLabels; % Set flag + % Notify the panel of a new signals update event + signalsEvent = srv.ExpEvent('signals', [], data); + notify(testCase.Remote, 'ExpUpdate', signalsEvent) + testCase.Panel.update % Process the update + % Find the new label and verify its formatting + labels = findobj(testCase.Parent, 'Style', 'text'); + i = (numel(labels)/2) + 1; % Controls returned as [values; labels] + expected = iff(formatLabels, 'Test event true', data.name); + testCase.verifyEqual(labels(i).String, expected, ... + sprintf('Failed to format labels correctly when FormatLabels == %d', formatLabels) ) + end + end + +% function test_starttime(testCase) +% % TODO Test Start time input as input (i.e. for reconnect) +% end + + end + +end \ No newline at end of file diff --git a/tests/ParamEditor_test.m b/tests/ParamEditor_test.m index 9856780d..e8694d82 100644 --- a/tests/ParamEditor_test.m +++ b/tests/ParamEditor_test.m @@ -456,7 +456,7 @@ function test_paramEdits(testCase) testCase.verifyEqual(gLabels(idx).ForegroundColor, [1 0 0], ... 'Unexpected label colour') % Verify change in underlying param struct - par = strcmpi(PE.Parameters.GlobalNames, strrep(gLabels(idx).String, ' ', '')); + par = strcmpi(PE.Parameters.GlobalNames, erase(gLabels(idx).String, ' ')); testCase.verifyEqual(PE.Parameters.Struct.(PE.Parameters.GlobalNames{par}), 666, ... 'UI edit failed to update parameters struct') % Verify Changed event triggered @@ -476,7 +476,7 @@ function test_paramEdits(testCase) testCase.verifyEqual(gLabels(idx).ForegroundColor, [1 0 0], ... 'Unexpected label colour') % Verify change in underlying param struct - par = strcmpi(PE.Parameters.GlobalNames, strrep(gLabels(idx).String, ' ', '')); + par = strcmpi(PE.Parameters.GlobalNames, erase(gLabels(idx).String, ' ')); testCase.verifyEqual(... PE.Parameters.Struct.(PE.Parameters.GlobalNames{par}), gInputs(idx).Value==true, ... 'UI checkbox failed to update parameters struct') diff --git a/tests/README.md b/tests/README.md index 80111fed..af58619c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -17,12 +17,34 @@ assert(~failed, 'The following tests failed:\n%s', strjoin({failures.Name}, '\n' checkCoverage('cellflat') % Folder can be inferred % Check coverage of a package test: -checkCoverage('fun_package', fileparts(which('fun.run'))) +checkCoverage('fun_package', '+fun') +``` + +### Creating new tests +Tests may be added and modified as necessary. Tests may be either scripts, functions or subclasses of `matlab.unittest.TestCase` or `matlab.perftest.TestCase`. When using any current or new fixtures (e.g. loading test data, calling `dat.paths`), use the class form and apply the fixtures folder as a PathsFixture. +For setting up a folder structure for code that saves/loads data via `dat.paths`, apply the ReposFixture. Before calling `dat.paths` or any fixture function that shadows a Rigbox or builtin function, call `setTestFlag(true)` in the setup. This supresses any warnings and errors designed to ensure users don't accidently call the wrong function outside of a test. +Additional test mock functions may be found in `fixtures\util`. For mocking a rig hardware file, sublass `matlab.mock.TestCase` and then call `mockRig` with an instance of your test class. Many of the fixture functions are stateful, containing persistant variables for recording call history or for injecting output behaviours. Clear these are the end of your test by applying the `ClearTestCache` fixture. + +A typical setup method may look like this: +``` +function setup(testCase) + % Set test flag to true while in test + oldTF = setTestFlag(true); + testCase.addTeardown(@setTestFlag(oldTF)) % Reset on teardown + % Create a set of folders for various repository paths + testCase.applyFixture(ReposFixture) + % Clear all persistant variables and cache on teardown + testCase.applyFixture(ClearTestCache) + % Generate a set of mock rig objects and behaviours + [rig, behaviour] = testCase.mockRig; +end ``` ## Contents: +For a full list of test functions see `Contents.m`. + - `fixtures/` - All custom fixtures and functions for shadowing core ones during tests. - `optimizations/` - Performance tests on various functions before and after changes. These are more for historical record and do not need to be run routinely. diff --git a/tests/SignalsExpTest.m b/tests/SignalsExpTest.m new file mode 100644 index 00000000..8d922aea --- /dev/null +++ b/tests/SignalsExpTest.m @@ -0,0 +1,445 @@ +classdef (SharedTestFixtures={ % add 'fixtures' folder as test fixture + matlab.unittest.fixtures.PathFixture('fixtures'),... + matlab.unittest.fixtures.PathFixture(['fixtures' filesep 'expDefinitions']),... + matlab.unittest.fixtures.PathFixture(['fixtures' filesep 'util']),... + matlab.unittest.fixtures.PathFixture(['fixtures' filesep 'util' filesep 'ptb'])})... + SignalsExpTest < matlab.perftest.TestCase & matlab.mock.TestCase + % Note that the experiment object must explicitly be deleted in order for + % the Signals networks to be unloaded. + % + % Four major things left to test: + % 1. Visual stimuli + % 2. Event handlers + % 3. Posting water and trial numbers to Alyx + % 4. Performance of Signals + + properties + % Maximum time in seconds before the quit method is called after + % starting an experiment. Precaution in case we get stuck in the main + % experiment loop + Timeout {mustBePositive} = 5 + end + + properties (SetAccess = protected, Transient) + % Structure of rig device mock objects + Rig + % Structure of mock behavior objects + RigBehaviours + % SignalsExp object + Experiment exp.SignalsExp + % An experiment reference for the test + Ref char + % A basic parameter struct for the test experiment `expDef` + Pars struct + % Timer for quitting experiment after timeout + Timer timer + end + + methods (TestClassSetup) + function setupFolder(testCase) + % SETUPFOLDER Set up subject, queue and config folders for test + % Creates a few folders for saving parameters and hardware. Adds + % teardowns for deletion of these folders. Sets global INTEST flag + % to true and adds teardown. Also creates a custom paths file to + % deactivate Alyx. + + % Set INTEST flag to true + setTestFlag(true); + testCase.addTeardown(@setTestFlag, false) + + % Turn off unwanted warnings + unwanted = { + 'Rigbox:tests:KbQueueCheck:keypressNotSet'; + 'Rigbox:exp:SignalsExp:NoScreenConfig'; + 'Rigbox:exp:SignalsExp:noTokenSet'; + 'toStr:isstruct:Unfinished'}; + cellfun(@(id)testCase.addTeardown(@warning, warning('off',id)), unwanted) + + testCase.applyFixture(ReposFixture) %TODO maybe move to method setup + + % Clear any current networks and add class teardown + deleteNetwork + addTeardown(testCase, @clear, 'createNetwork') + + % Create our timer + testCase.Timer = timer(... + 'StartDelay', 5, ... + 'Tag', 'QuitTimer'); + testCase.addTeardown(@delete, testCase.Timer) + end + end + + methods (TestMethodSetup) + function setMockRig(testCase) + % SETMOCKRIG Inject mock rig with shadowed hw.devices + % 1. Create mock rig device objects + % 2. Set the mock rig object to be returned on calls to hw.devices + % 3. Set some default behaviours and add teardowns + % 4. Set a fake expRef and some parameter defaults + % + % See also mockRig, KbQueueCheck + + % Create fresh set of mock objects + [testCase.Rig, testCase.RigBehaviours] = mockRig(testCase); + % Define some output for the lickDetector + testCase.assignOutputsWhen(... + withAnyInputs(testCase.RigBehaviours.lickDetector.readPosition), ... + randi(100), 0, rand) + % Define some output for daqController. These are accessed when + % determining the water type + testCase.assignOutputsWhen(... + get(testCase.RigBehaviours.daqController.SignalGenerators), ... + struct('WaterType', 'Water')) + testCase.assignOutputsWhen(... + get(testCase.RigBehaviours.daqController.ChannelNames), ... + 'rewardValve') + + % Set a couple of extra fields + testCase.Rig.name = 'testRig'; + testCase.Rig.clock = hw.ptb.Clock; + testCase.Rig.audioDevices = struct(... + 'DeviceName', 'default',... + 'DeviceIndex', -1,... + 'DefaultSampleRate', 44100,... + 'NrOutputChannels', 2); + + % Delete previous experiment, if any + testCase.addTeardown(@testCase.deleteExperiment) + + % First set up a valid experiment reference + testCase.Ref = dat.constructExpRef('test', now, randi(10000)); + assert(mkdir(dat.expPath(testCase.Ref, 'main', 'master'))) + % Set some basic parameters for expDef we'll use + testCase.Pars = struct(... + 'defFunction', @testCase.expDef,... + 'numRepeats', 1000); + + % Clear all persistant variables and cache on teardown + testCase.applyFixture(ClearTestCache) + end + end + + methods (Test) + function test_constructor(testCase) + % Ensure warning on for this test + id = 'Rigbox:exp:SignalsExp:NoScreenConfig'; + orig = warning('on', id); + testCase.addTeardown(@warning, orig) + + % Instantiate + testCase.Experiment = testCase.verifyWarning(... + @()exp.SignalsExp(testCase.Pars, testCase.Rig), id); + + % Check our signals wired properly. expStop behaviour is tested + % separately + expected = {'endTrial'; 'expStart'; 'expStop'; 'newTrial'; 'repeatNum'; 'trialNum'}; + actual = fieldnames(testCase.Experiment.Events); + testCase.assertEqual(sort(actual), expected) + + testCase.Experiment.Events.expStart.post(testCase.Ref) + getVals = @()mapToCell(@(s)s.Node.CurrValue, ... + struct2cell(testCase.Experiment.Events)); + testCase.verifyEqual(getVals(), {testCase.Ref; true; 1; 1; []; []}) + + runSchedule(testCase.Experiment.Events.expStart.Node.Net) + testCase.verifyEqual(getVals(), {testCase.Ref; true; 1; 2; []; true}) + + testCase.Experiment.Events.newTrial.post(false) + runSchedule(testCase.Experiment.Events.expStart.Node.Net) + testCase.verifyEqual(getVals(), {testCase.Ref; true; 2; 4; []; false}) + + % Check outputs, etc. + parsStruct = exp.inferParameters(@advancedChoiceWorld); + testCase.Rig.screens = rand; + testCase.Experiment = testCase.verifyWarningFree(... + @() exp.SignalsExp(parsStruct, testCase.Rig)); + + % Check screens use used in occulus model + testCase.verifyEqual(testCase.Experiment.Occ.screens, testCase.Rig.screens) + +% fieldnames(testCase.Experiment.Inputs); % TODO +% expected = {'wheel'; 'wheelMM'; 'wheelDeg'; 'lick'; 'keyboard'}; +% testCase.verifyEqual(fieldnames(testCase.Experiment.Events), expected) + + % Test output mapping + testCase.assertEqual(fieldnames(testCase.Experiment.Outputs), {'reward'}); + node = testCase.Experiment.Outputs.reward.Node; + affectedIdxs = submit(node.NetId, node.Id, 5); + applyNodes(node.NetId, affectedIdxs); + import matlab.mock.constraints.Occurred + testCase.verifyThat(testCase.RigBehaviours.daqController.command(5), Occurred) + + % Test pars + testCase.Experiment.Events.expStart.post(testCase.Ref) + actual = testCase.Experiment.Params.Node.CurrValue; + testCase.verifyTrue(all(ismember(fieldnames(actual), fieldnames(parsStruct)))) + end + + function test_run(testCase) + + % Create a minimal set or parameters + testCase.Pars.numRepeats = 5; + % Instantiate + testCase.Experiment = exp.SignalsExp(testCase.Pars, testCase.Rig); + data = testCase.Experiment.run(testCase.Ref); + + expected = {'events', 'inputs', 'outputs', 'paramsValues', 'paramsTimes'}; + testCase.assertTrue(all(ismember(expected, fieldnames(data)))) + + % FIXME End status could be different for experiments that complete + testCase.verifyEqual(data.endStatus, 'quit') + tol = 1/(24*60); % 1 minute tolerance + testCase.verifyEqual(data.startDateTime, now, 'AbsTol', tol) + testCase.verifyEqual(datenum(data.startDateTimeStr), data.startDateTime, 'AbsTol', tol) + testCase.verifyEqual(data.expRef, testCase.Ref) + testCase.verifyEqual(data.rigName, testCase.Rig.name) + + % Check the data were saved + % Load block + allSaved = all(file.exists(dat.expFilePath(testCase.Ref, 'Block'))); + testCase.assertTrue(allSaved) + block = dat.loadBlock(testCase.Ref); + testCase.verifyEqual(block, block) + + % TODO Check signals input values + + end + + function test_expStop(testCase) + % Test behaviour of expStop event and of quitting via keypress and + % method calls. There are 7 possibilities here: + % 1. Without expStop defined in def function... + % a. quit method call (by keypress or direct call to quit method) + % b. no more trials (all conditions repeated) + % 2. With expStop defined in def function... + % a. quit method call (by keypress or direct call to quit method) + % b. no more trials (all conditions repeated) + % c. user-defined expStop event takes a value + % + % All but 1b are tested here. + + % Removing lick detector from rig in order to reduce command output + % clutter + testCase.Rig = rmfield(testCase.Rig, 'lickDetector'); + + % Test expStop definition within the experiment function: expStop + % takes value on 11th trial (2c) + testCase.Pars.numRepeats = 15; + + % Instantiate + testCase.Experiment = exp.SignalsExp(testCase.Pars, testCase.Rig); + data = testCase.Experiment.run(testCase.Ref); + + testCase.verifyEqual(data.endStatus, 'quit') + testCase.assertTrue(isfield(data.events, 'expStopValues')) + testCase.verifyTrue(data.events.trialNumValues(end) == 11) + testCase.verifyMatches(data.events.expStopValues, 'complete') + testCase.verifyTrue(numel(data.events.expStopTimes) == 1) + + % Test out of trials (2b) + testCase.Pars.numRepeats = 5; + testCase.Experiment = exp.SignalsExp(testCase.Pars, testCase.Rig); + data = testCase.Experiment.run(testCase.Ref); + + testCase.verifyEqual(data.endStatus, 'quit') + testCase.assertTrue(isfield(data.events, 'expStopValues')) + testCase.verifyTrue(data.events.trialNumValues(end) == 5) + testCase.verifyTrue(data.events.expStopValues) + testCase.verifyTrue(numel(data.events.expStopTimes) == 1) + + % Test quit keypress + KbQueueCheck(-1, 'q'); % Immediately quit + testCase.Experiment = exp.SignalsExp(testCase.Pars, testCase.Rig); + data = testCase.Experiment.run(testCase.Ref); + + testCase.verifyEqual(data.endStatus, 'quit') + testCase.assertTrue(isfield(data.events, 'expStopValues')) + testCase.verifyTrue(data.events.expStopValues) + testCase.verifyTrue(numel(data.events.expStopTimes) == 1) + + % Test quit keypress when no expStop in function (1a) + KbQueueCheck(-1, 'q'); % Immediately quit + testCase.Pars.defFunction = 'advancedChoiceWorld'; + testCase.Experiment = exp.SignalsExp(testCase.Pars, testCase.Rig); + data = testCase.Experiment.run(testCase.Ref); + + testCase.verifyEqual(data.endStatus, 'quit') + testCase.assertTrue(isfield(data.events, 'expStopValues')) + testCase.verifyTrue(data.events.expStopValues) + testCase.verifyTrue(numel(data.events.expStopTimes) == 1) + end + + function test_alyxRegistration(testCase) + % Test Alyx interactions + % First, check the presence of warnings when database url is set and + % we're not logged into Alyx + import matlab.mock.constraints.Occurred + import matlab.mock.constraints.WasCalled + import matlab.mock.AnyArguments + + % Verify databaseURL is set + testCase.assertNotEmpty(getOr(dat.paths, 'databaseURL')) + + % Ensure warning on for this test + id = 'Rigbox:exp:SignalsExp:noTokenSet'; + orig = warning('on', id); + testCase.addTeardown(@warning, orig) + + % Instantiate + testCase.Experiment = exp.SignalsExp(testCase.Pars, testCase.Rig); + testCase.verifyWarning(@()testCase.Experiment.run(testCase.Ref), id) + + % Test with Alyx not logged in + testCase.Experiment = exp.SignalsExp(testCase.Pars, testCase.Rig); + ai = Alyx('',''); + ai.Headless = true; + testCase.Experiment.AlyxInstance = ai; + testCase.Experiment.Communicator = testCase.Rig.communicator; + testCase.verifyWarning(@()testCase.Experiment.run(testCase.Ref), ... + 'Alyx:HeadlessLoginFail') + comm = testCase.RigBehaviours.communicator; + testCase.verifyThat(comm.send('AlyxRequest', AnyArguments), WasCalled) + + % Test with logged in + [ai, behaviour] = testCase.createMock(... + 'addedProperties', properties(ai)', ... + 'addedMethods', methods(ai)'); + testCase.assignOutputsWhen(get(behaviour.IsLoggedIn), true) + testCase.assignOutputsWhen(get(behaviour.Headless), true) + testCase.Experiment = exp.SignalsExp(testCase.Pars, testCase.Rig); + testCase.Experiment.AlyxInstance = ai; + testCase.Experiment.run(testCase.Ref); + testCase.verifyThat(withAnyInputs(behaviour.registerFile), Occurred) + testCase.verifyThat(withAnyInputs(behaviour.postWater), Occurred) + + % Test with no database URL + paths.databaseURL = ''; + save(fullfile(getOr(dat.paths,'rigConfig'), 'paths'), 'paths') + clearCBToolsCache + testCase.assertEmpty(getOr(dat.paths, 'databaseURL')) + + testCase.Experiment = exp.SignalsExp(testCase.Pars, testCase.Rig); + testCase.verifyWarningFree(@()testCase.Experiment.run(testCase.Ref)) + end + + function test_eventHandlers(testCase) + % Also test updates + + import matlab.mock.constraints.Occurred + % Test expStop definition within the experiment function: expStop + % takes value on 11th trial + + % Instantiate and spy on comms + testCase.Experiment = exp.SignalsExp(testCase.Pars, testCase.Rig); + testCase.Experiment.Communicator = testCase.Rig.communicator; + testCase.Experiment.run(testCase.Ref); + + % Get history of communicator interaction and check experiment phase + % updates occured in the correct order. + history = getMockHistory(testCase, testCase.Rig.communicator); + events = sequence({history.Inputs}); + type = @(kind) @(event)strcmp(event{2}, kind); + statuses = events.filter(type('status')); + expected = {... + 'experimentInit'; + 'experimentStarted'; + 'experimentEnded'; + 'experimentCleanup'}; + testCase.verifyEqual(toCell(statuses.map(@(u)u{3}{4})), expected) + % Check signals events sent + testCase.verifyNotEmpty(... + events.filter(type('signals')).first, ... + 'Failed to send any signals updates') + end + + function test_errors(testCase) + % Test that SignalsExp saves data upon an error + wheel = testCase.RigBehaviours.mouseInput; + errorID = 'Rigbox:SignalsExp:Fail'; errorMsg = 'Failed!'; + testCase.throwExceptionWhen(withAnyInputs(wheel.readAbsolutePosition), ... + MException(errorID, errorMsg)) + + testCase.Experiment = exp.SignalsExp(testCase.Pars, testCase.Rig); + + testCase.assertError(@()testCase.Experiment.run(testCase.Ref), errorID) + % Load block + allSaved = all(file.exists(dat.expFilePath(testCase.Ref, 'Block'))); + testCase.assertTrue(allSaved) + data = dat.loadBlock(testCase.Ref); + testCase.verifyEqual(data.endStatus, 'exception') + testCase.verifyEqual(data.exceptionMessage, errorMsg) + end + + function test_checkInput(testCase) + % FIXME This test is not robust + testCase.Experiment = exp.SignalsExp(testCase.Pars, testCase.Rig); + % Set a post delay that should be aborted upon second quit keypress + testCase.Experiment.PostDelay = 5; + + % Simulate random keypress, then two quick presses, then pause, + % another press, resume, quit and urgent quit + abKey = {true, zeros(size(KbName('KeyNames')))}; + abKey{2}(KbName({'a','b'})) = deal(GetSecs); + pKey = testCase.Experiment.PauseKey; + qKey = testCase.Experiment.QuitKey; + otherKey = '7'; + KbQueueCheck(-1, sequence({otherKey abKey pKey otherKey pKey qKey qKey})); + + [T, data] = evalc('testCase.Experiment.run(testCase.Ref)'); + testCase.verifyMatches(T, 'Pause') + testCase.verifyMatches(T, 'Quit') + testCase.verifyTrue(data.duration < testCase.Timeout, ... + 'quit key failed to end experiment') + % Only the first three key presses should be posted to keyboard input + % signal as others either occur during pause or are reserved keys + testCase.verifyEqual(data.inputs.keyboardValues, [otherKey, 'ab'], ... + 'Failed to correctly update keyboard signal') + % Test double-quit abort + testCase.verifyEqual(data.endStatus, 'aborted', ... + 'failed to abort on second quit keypress') + delay = diff([data.events.expStopTimes data.experimentCleanupTime]); + testCase.verifyTrue(delay < testCase.Experiment.PostDelay) + end + + function test_visualStim(testCase) + % TODO + parsStruct = exp.inferParameters(@advancedChoiceWorld); + testCase.Experiment = exp.SignalsExp(parsStruct, testCase.Rig); + end + end + + methods + function deleteExperiment(testCase) + % Ensures deletion of experiment + if ~isempty(testCase.Experiment) && isvalid(testCase.Experiment) + delete(testCase.Experiment) + end + end + + function set.Experiment(testCase, experiment) + % For every new experiment object add event handler for quiting the + % experiment after 5 seconds. This is a saftey precaution in case + % any tests fail and the experiment loop continues indefinitely. + if testCase.Timer.Running; stop(testCase.Timer); end + testCase.deleteExperiment % Delete previous, freeing network slots + experiment.addEventHandler(exp.EventHandler('experimentStarted')); + % TODO Remove display + cb = @(t,~)iff(isvalid(experiment), ... % If not yet deleted + @()fun.applyForce({@()disp('Timer quit'); @()quit(experiment)}), ... % call quit method + @()stop(t)); % otherwise just stop the timer + testCase.Timer.TimerFcn = cb; + experiment.EventHandlers.addCallback(@(~,~)start(testCase.Timer)); + testCase.Experiment = experiment; + end + + end + + methods (Static) + function expDef(~, events, varargin) + % EXPDEF A simple def function for testing constructor + % FIXME: Runaway recursion + events.endTrial = events.newTrial.delay(0); % delay required for updates + events.expStop = then(events.trialNum > 10, 'complete'); + end + end +end \ No newline at end of file diff --git a/tests/StimulusControl_test.m b/tests/StimulusControl_test.m new file mode 100644 index 00000000..2831ed72 --- /dev/null +++ b/tests/StimulusControl_test.m @@ -0,0 +1,106 @@ +%STIMULUSCONTROL_TEST Tests for srv.StimulusControl and +%srv.stimulusControllers +% TODO Create tests for remaining methods +classdef (SharedTestFixtures={ % add 'fixtures' folder as test fixture + matlab.unittest.fixtures.PathFixture('fixtures'),... + matlab.unittest.fixtures.PathFixture(['fixtures' filesep 'util'])})... + StimulusControl_test < matlab.unittest.TestCase & matlab.mock.TestCase + + properties (SetAccess = protected) + % An experiment reference for the test + Ref + % A list of StimulusControl names for testing the remote file + RemoteNames = {'Rig1', 'Sevvan', 'Reet'} + end + + methods (TestClassSetup) + function setupFolder(testCase) + % SETUPFOLDER Set up subject, queue and config folders for test + % Creates a few folders for saving parameters and hardware. Adds + % teardowns for deletion of these folders. Also creates a custom + % paths file to deactivate Alyx. + % + % TODO Make into shared fixture + + assert(endsWith(which('dat.paths'),... + fullfile('tests', 'fixtures', '+dat', 'paths.m'))); + + % Set INTEST flag to true + testCase.setTestFlag(true) + testCase.addTeardown(@testCase.setTestFlag, false) + + % Create a rig config folder + configDir = getOr(dat.paths, 'rigConfig'); + assert(mkdir(configDir), 'Failed to create config directory') + + % Clear loadVar cache + addTeardown(testCase, @clearCBToolsCache) + + % Create a remote file for one of the tests + globalConfigDir = getOr(dat.paths, 'globalConfig'); + stimulusControllers = cellfun(@srv.StimulusControl.create, testCase.RemoteNames); + save(fullfile(globalConfigDir, 'remote'), 'stimulusControllers') + assert(file.exists(fullfile(globalConfigDir, 'remote.mat'))) + + % Add teardown to remove folders + rmFcn = @()assert(rmdir(globalConfigDir, 's'), ... + 'Failed to remove test config folder'); + addTeardown(testCase, rmFcn) + + % Set some default behaviours for some of the objects; create a ref + testCase.Ref = dat.constructExpRef('test', now, randi(10000)); + end + + end + + methods (Test) + + function test_create(testCase) + % Test for constructor defaults + name = testCase.RemoteNames{1}; + sc = srv.StimulusControl.create(name); + testCase.verifyMatches(sc.Name, name, 'Failed to set Name') + testCase.verifyMatches(sc.Uri, ['.*',name,':',num2str(sc.DefaultPort)]) + + uri = 'ws://rig:1428'; + sc = srv.StimulusControl.create(name, uri); + testCase.verifyMatches(sc.Name, name, 'Failed to set Name') + testCase.verifyEqual(sc.Uri, uri, 'Failed to set provided uri') + end + + function test_errorOnFail(testCase) + % A message array to test + id = 'test:error:failSent'; + msg = 'Fail message'; + r = {'success', testCase.Ref, id, msg}; + + srv.StimulusControl.errorOnFail('This is no error') % Test char input + srv.StimulusControl.errorOnFail(r) % Test array input without fail + r{1} = 'fail'; % Change message to fail state + testCase.verifyError(@()srv.StimulusControl.errorOnFail(r), id, ... + 'Failed to throw error with ID') + ex.message = []; + r(3) = []; % Remove ID + try srv.StimulusControl.errorOnFail(r), catch ex, end + testCase.verifyMatches(ex.message, msg, 'Failed to throw error without ID') + end + + function test_stimulusControllers(testCase) + % Test the loading of saved StimulusControl objects via dat.paths + sc = srv.stimulusControllers; + testCase.verifyLength(sc, length(testCase.RemoteNames)) + testCase.verifyTrue(isa(sc, 'srv.StimulusControl')) + testCase.verifyEqual({sc.Name}, sort(testCase.RemoteNames), ... + 'Failed to return array sorted by Name') + end + end + + methods (Static) + function setTestFlag(TF) + % SETTESTFLAG Set global INTEST flag + % Allows setting of test flag via callback function + global INTEST + INTEST = TF; + end + end +end diff --git a/tests/checkCoverage.m b/tests/checkCoverage.m index f58c4b77..a14eeab9 100644 --- a/tests/checkCoverage.m +++ b/tests/checkCoverage.m @@ -11,19 +11,24 @@ % results (array) - array of test result objects % % Examples: -% checkCoverage('cellflat') % Folder can be inferred +% checkCoverage('cellflat') % Folder can be inferred % checkCoverage('fun_package', fileparts(which('fun.run'))) +% checkCoverage('fun_package', '+fun') % narginchk(1,2) if nargin == 1 % Try to divine test function location - funLoc = fileparts(which(strrep(testFile,'_test',''))); + funLoc = fileparts(which(erase(testFile,'_test'))); end import matlab.unittest.TestRunner import matlab.unittest.plugins.CodeCoveragePlugin runner = TestRunner.withTextOutput; -plugin = CodeCoveragePlugin.forFolder(funLoc); +if funLoc(1) == '+' + plugin = CodeCoveragePlugin.forPackage(funLoc(2:end)); +else + plugin = CodeCoveragePlugin.forFolder(funLoc); +end runner.addPlugin(plugin) tests = testsuite(testFile); results = runner.run(tests); \ No newline at end of file diff --git a/tests/cortexlab/bindMpepServer_test.m b/tests/cortexlab/bindMpepServer_test.m new file mode 100644 index 00000000..2e8933f6 --- /dev/null +++ b/tests/cortexlab/bindMpepServer_test.m @@ -0,0 +1,174 @@ +classdef (SharedTestFixtures={ % add 'fixtures' folder as test fixture + matlab.unittest.fixtures.PathFixture(['..' filesep 'fixtures']),... + matlab.unittest.fixtures.PathFixture(['..' filesep 'fixtures' filesep 'util'])})... + bindMpepServer_test < matlab.unittest.TestCase & matlab.mock.TestCase + + properties (SetAccess = protected) + % Timeline mock object + Timeline + % Timeline behaviour object + Behaviour + % An experiment reference for the test + Ref + % Default ports used by bindMpepServer + Ports = [9999, 1001] + end + + methods (TestClassSetup) + function setTestFlag(testCase) + % SETTESTFLAG Set test flag + % Sets global INTEST flag to true and adds teardown. Also creates a + % dummy expRef for tests. + % + % TODO Make into shared fixture + + % Set INTEST flag + assert(endsWith(which('dat.paths'),... + fullfile('tests', 'fixtures', '+dat', 'paths.m'))); + setTestFlag(true); + testCase.addTeardown(@setTestFlag, false) + % Set test expRef + testCase.Ref = dat.constructExpRef('test', now, 1); + end + end + + methods (TestMethodSetup) + function setMockRig(testCase) + % SETMOCKRIG Inject mock rig with shadowed hw.devices + % 1. Create mock timeline + % 2. Set the mock rig object to be returned on calls to hw.devices + % 3. Add teardowns + % + % See also mockRig, KbQueueCheck + + % Create fresh Timeline mock + [testCase.Timeline, testCase.Behaviour] = createMock(testCase, ... + 'AddedProperties', properties(hw.Timeline)', ... + 'AddedMethods', methods(hw.Timeline)'); + + % Inject our mock via calls to hw.devices + rig.timeline = testCase.Timeline; + hw.devices('testRig', false, rig); + + % Clear mock histories just to be safe + testCase.addTeardown(@testCase.clearMockHistory, testCase.Timeline); + testCase.addTeardown(@clear, 'KbQueueCheck', 'pnet', 'devices') + end + end + + methods (Test) + function test_bindMpepListener(testCase) + % Test binding of sockets and returning of tls object + % NB Actually calls bindMpepServer + port = randi(10000); + [T, tls] = evalc(['tl.bindMpepServer(', num2str(port), ')']); + % Check log + testCase.verifyMatches(T, 'Bound UDP sockets', ... + 'failed to log socket bind') + % Check returned fields + expected = {'close'; 'process'; 'listen'; 'AlyxInstance'; 'tlObj'}; + testCase.verifyEqual(fieldnames(tls), expected, ... + 'Unexpected structure returned') + % Check funciton handles + actual = structfun(@(f)isa(f, 'function_handle'), tls); + testCase.verifyEqual(actual, [true(3,1); false(2,1)]) + % Check Alyx instance and Timeline objects set + testCase.verifyTrue(isa(tls.AlyxInstance, 'Alyx'), ... + 'Failed to create Alyx instance') + testCase.verifyTrue(isequal(tls.tlObj, testCase.Timeline), ... + 'Failed to set Timeline') + % Check socket opened on correct port + history = pnet('gethistory'); + testCase.verifyEqual(history{1}, {'udpsocket', port}, ... + 'Failed to open socket on specified port') + end + + function test_close(testCase) + % Test the close callback + tls = tl.bindMpepServer; %#ok % Return tls object + ports = testCase.Ports; % Default ports opened + arrayfun(@(s) pnet('setoutput', s, 'close', []), ports); % Set output + T = evalc('tls.close()'); % Callback + % Check log + testCase.verifyMatches(T, 'Unbinding', 'failed to log close') + % Check close called on each socket + history = pnet('gethistory'); % Get pnet call history + correct = cellfun(@(a) strcmp(a{2}, 'close'), history(end-1:end)); + testCase.verifyTrue(all(correct), 'Failed to close sockets') + end + + function test_process(testCase) + % Test process callback + import matlab.unittest.constraints.IsOfClass + import matlab.mock.constraints.Occurred + [subject, series, seq] = dat.parseExpRef(testCase.Ref); + + tls = tl.bindMpepServer; % Return tls object + ports = testCase.Ports; % Default ports opened + arrayfun(@(s) pnet('setoutput', s, 'readpacket', 1000), ports); % Set output + pnet('setoutput', ports(2), 'gethost', {randi(99,1,4), 88}); % Set output + + % Set messages + % Stringify Alyx instance + ai = Alyx.parseAlyxInstance(testCase.Ref, Alyx('user','')); + % Function for constructing message strings + str = @(cmd) sprintf('%s %s %s %d %s', cmd, subject, ... + datestr(series, 'yyyymmdd'), seq, iff(strcmp(cmd,'alyx'),ai,'')); + % Set behaviour for IsRunning method to pass IsRunning assert + testCase.assignOutputsWhen(get(testCase.Behaviour.IsRunning), false) + % Commands + cmd = {'alyx', 'expstart', 'expend', 'expinterupt'}; + % Set output for 'read' + pnet('setoutput', ports(2), 'read', sequence(mapToCell(str, cmd))); + % Trigger reads + arrayfun(@(~) tls.process(), 1:length(cmd)) + + % Test Timeline interactions + timeline = testCase.Behaviour; + testCase.verifyThat([... + timeline.start(testCase.Ref, IsOfClass(?Alyx)),... % expstart + withAnyInputs(timeline.record), ... % " + withAnyInputs(timeline.stop), ... % expstop + withAnyInputs(timeline.stop)], ... % expinterupt + Occurred('RespectingOrder', true)) + + % Retrieve mock history for Timeline + history = testCase.getMockHistory(testCase.Timeline); + % Find inputs to start method + f = @(method) @(a) strcmp(a.Name, method); + actual = fun.filter(f('start'), history).Inputs{end}; + % Check AlyxInstance updated with the one we passed in above + testCase.verifyEqual(actual.User, 'user', 'Failed to update AlyxInstance') + + % Get pnet history + history = pnet('gethistory'); + % Calls to write should equal the number of messages read + writeCalls = cellfun(@(C) strcmp(C{2}, 'write'), history); + testCase.verifyEqual(sum(writeCalls), length(cmd), 'Failed echo messages') + + % Test process fails + testCase.throwExceptionWhen(withAnyInputs(timeline.start), ... + MException('Timeline:error', 'Error during experiment.')); + % Clear pnet history + pnet('clearhistory'); + % Set output for 'read' + pnet('setoutput', ports(2), 'read', str('expstart')); + % Trigger pnet read; use evalc to supress output + evalc('tls.process()'); + % Set Timeline as already running and check for error + testCase.assignOutputsWhen(get(testCase.Behaviour.IsRunning), true) + evalc('tls.process()'); + + % Check message not echoed after error + history = pnet('gethistory'); + % Calls to write should equal the number of messages read + writeCalls = cellfun(@(C) strcmp(C{2}, 'write'), history); + testCase.verifyFalse(any(writeCalls), 'Unexpected message echo') + end + + function test_listen(testCase) + % TODO Add test for listen function of bindMpepServer + end + end + +end \ No newline at end of file diff --git a/tests/cortexlab/mpepMessageParse_test.m b/tests/cortexlab/mpepMessageParse_test.m new file mode 100644 index 00000000..5655e1e7 --- /dev/null +++ b/tests/cortexlab/mpepMessageParse_test.m @@ -0,0 +1,71 @@ +% mpepMessageParse test +% preconditions +subject = 'M20140123_CB'; +series = num2str(randi(10000)); +seq = randi(100); +ref = dat.constructExpRef(subject, series, seq); + +block = '5'; +stim = '1'; +duration = '36000'; +msg = @(cmd) sprintf('%s %s %s %d %s %s %s', ... + cmd, subject, series, seq, block, stim, duration); + +%% Test 1: infosave +cmd = sprintf('infosave %s_%s_%d', subject, series, seq); +info = dat.mpepMessageParse(cmd); + +assert(strcmp(info.instruction, 'infosave')) +assert(strcmp(info.subject, subject)) +assert(strcmp(info.series, series)) +assert(strcmp(info.exp, num2str(seq))) +assert(strcmp(info.expRef, ref)) + +%% Test 2: hello +info = dat.mpepMessageParse(msg('hello')); +assert(strcmp(info.instruction, 'hello')) +assert(isempty(info.expRef)) + +%% Test 3: full mpep instruction +info = dat.mpepMessageParse(msg('expstart')); + +assert(strcmp(info.instruction, 'expstart')) +assert(strcmp(info.subject, subject)) +assert(strcmp(info.series, series)) +assert(strcmp(info.exp, num2str(seq))) +assert(strcmp(info.expRef, ref)) +assert(strcmp(info.block, block)) +assert(strcmp(info.stim, stim)) +assert(strcmp(info.duration, duration)) + +%% Test 4: series to datestr +series = datestr(now, 'yyyymmdd'); +cmd = sprintf('expinterrupt %s %s %d', subject, series, seq); +info = dat.mpepMessageParse(cmd); + +assert(strcmp(info.series, datestr(now, 'yyyy-mm-dd'))) +assert(strcmp(info.expRef, dat.constructExpRef(subject, now, seq))) + +%% Test 5: empty sequence +cmd = sprintf('expend %s %s', subject, series); +ex.message = ''; +try + dat.mpepMessageParse(cmd); +catch ex +end +assert(contains(ex.message, 'not valid')) + +%% Test 6: Alyx serialization +% Test compatibility with parseAlyxInstance + +token = char(randsample([48:57 65:89 97:122], 36, true)); % Generate token +ai = Alyx.parseAlyxInstance(ref, Alyx('user', token)); % Stringify instance +cmd = sprintf('alyx %s %s %d %s', subject, series, seq, ai); % Make message + +info = dat.mpepMessageParse(cmd); + +assert(strcmp(info.instruction, 'alyx')) +assert(strcmp(info.subject, subject)) +assert(strcmp(info.series, series)) +assert(strcmp(info.exp, num2str(seq))) + diff --git a/tests/cortexlab/setScalePort_test.m b/tests/cortexlab/setScalePort_test.m new file mode 100644 index 00000000..71a0b437 --- /dev/null +++ b/tests/cortexlab/setScalePort_test.m @@ -0,0 +1,82 @@ +classdef (SharedTestFixtures={ % add 'fixtures' folder as test fixture + matlab.unittest.fixtures.PathFixture(['..' filesep 'fixtures'])})... + setScalePort_test < matlab.unittest.TestCase + + methods (TestClassSetup) + function setupFolder(testCase) + % SETUPFOLDER Set up hardware scale objects + % Creates a few folders for saving hardware. Adds teardowns for + % deletion of these folders via ReposFixture. + + % Set INTEST flag to true + setTestFlag(true); + testCase.addTeardown(@setTestFlag, false) + + % Ensure we're using the correct test paths and add teardowns to + % remove any folders we create + testCase.applyFixture(ReposFixture) + + % Now create a couple of hardware files (the rigConfig folder is + % already created in the ReposFixture setup) + scale = hw.WeighingScale; + hwPaths = pick(dat.paths, {'rigConfig', 'globalConfig'}); + hwPaths{2} = [hwPaths{2} filesep hostname]; + assert(mkdir(hwPaths{2}), 'Failed to create extra config path') + + for i = 1:length(hwPaths) % Save scale object into hardware files + save(fullfile(hwPaths{i}, 'hardware'), 'scale'); + end + + addTeardown(testCase, @ClearTestCache) % Remove folders on teardown + end + end + + methods (Test) + function test_setPort(testCase) + % Test setting the COM port of the current rig with various input + % types. + + % Test as full string + port = 'COM3'; + s = hw.setScalePort(port); + scale = testCase.loadScale(hostname); + testCase.verifyEqual(s, scale, 'Failed to return saved scale obj') + testCase.verifyEqual(scale.ComPort, port, ... + 'Failed to set COM port as full string') + + % Test as single char + port = 'COM4'; + hw.setScalePort(port(end)); + scale = testCase.loadScale(hostname); + testCase.verifyEqual(scale.ComPort, port, ... + 'Failed to set COM port as single char') + + % Test as numerical + port = 'COM6'; + hw.setScalePort(str2double(port(end))); + scale = testCase.loadScale(hostname); + testCase.verifyEqual(scale.ComPort, port, ... + 'Failed to set COM port as double') + end + + function test_rigNameInput(testCase) + % Test setting the COM port of a specific rig with lower case COM + % port char array + port = 'COM6'; + [~, name] = fileparts(getOr(dat.paths, 'rigConfig')); + hw.setScalePort(lower(port), name); + scale = testCase.loadScale(name); + testCase.verifyEqual(scale.ComPort, port, ... + 'Failed to set COM port as double') + end + end + + methods (Static) + function scale = loadScale(rigName) + % LOADSCALE Load the scale object from the hardware file for rigName + if nargin == 0, rigName = hostname; end + hwPath = fullfile(getOr(dat.paths, 'globalConfig'), rigName, 'hardware'); + scale = pick(load(hwPath), 'scale'); + end + end +end \ No newline at end of file diff --git a/tests/dat_test.m b/tests/dat_test.m index 3f1c2db0..1bbb5adf 100644 --- a/tests/dat_test.m +++ b/tests/dat_test.m @@ -16,6 +16,10 @@ function setup(testCase) assert(endsWith(which('dat.paths'),... fullfile('tests', 'fixtures', '+dat', 'paths.m'))); + % Set INTEST flag to true + setTestFlag(true); + testCase.addTeardown(@setTestFlag, false) + % Check temp mainRepo folder is empty. An extra safe measure as we % don't won't to delete important folders by accident! mainRepo = dat.reposPath('main', 'master'); @@ -318,6 +322,60 @@ function test_expExists(testCase) testCase.verifyTrue(dat.expExists(refs{1})) end + function test_saveParamProfile(testCase) + % Test saving files when repo folder doesn't yet exist + repos = dat.reposPath('main'); + rmdir(repos{1}) % Delete local repo + + % Full path to expected files + fn = 'parameterProfiles.mat'; + repos = fullfile(repos, fn); + + % Test saving some defaults with a given name: + name = 'testName'; + expType = 'ChoiceWorld'; + dat.saveParamProfile(expType, name, exp.choiceWorldParams) + actual = cellfun(@load, repos); % Load from both locations + for i = 1:length(actual) % Check saved + testCase.verifyEqual(fieldnames(actual(i)), {expType}, 'Failed to append to current file') + testCase.verifyEqual(fieldnames(actual(i).(expType)), {name}, 'Failed to save under given name') + testCase.verifyEqual(actual(i).(expType).(name).type, expType) + end + + % Test saving another param profile. Nothing should be overwritten. + name = 'another'; + types = {expType; 'BarMapping'}; + dat.saveParamProfile(types{end}, name, exp.barMappingParams) + actual = cellfun(@load, repos); % Load from both locations + for i = 1:length(actual) % Check saved + testCase.verifyEqual(fieldnames(actual(i)), types, 'Failed to append to current file') + testCase.verifyEqual(fieldnames(actual(i).(types{end})), {name}, 'Failed to save under given name') + testCase.verifyEqual(actual(i).(types{end}).(name).type, types{end}) + end + end + + function test_loadParamProfiles(testCase) + % Test loading a specific parameter profile from file + fn = 'parameterProfiles.mat'; + repo = fullfile(dat.reposPath('main', 'master'), fn); + + name = 'testSet'; + expType = 'ChoiceWorld'; + set.(expType).testSet = exp.choiceWorldParams; + set.BarMapping.anotherSet = exp.barMappingParams; + save(repo, '-struct', 'set') + + % Test loading existing + profiles = dat.loadParamProfiles(expType); + testCase.verifyEqual(fieldnames(profiles), {name}, 'Failed to load profile') + testCase.verifyEqual(profiles.(name).type, expType) + + % Test loading non-existing exp type + profiles = testCase.verifyWarningFree(@()dat.loadParamProfiles('fake')); + expected = isstruct(profiles) && isempty(fieldnames(profiles)); + testCase.verifyTrue(expected) + end + end methods (Access = private) diff --git a/tests/expServer_test.m b/tests/expServer_test.m index 99971ac4..59629a70 100644 --- a/tests/expServer_test.m +++ b/tests/expServer_test.m @@ -20,54 +20,41 @@ function setupFolder(testCase) % SETUPFOLDER Set up subject, queue and config folders for test % Creates a few folders for saving parameters and hardware. Adds - % teardowns for deletion of these folders. Also creates a custom - % paths file to deactivate Alyx. + % teardowns for deletion of these folders via ReposFixture. Also + % creates a custom paths file to deactivate Alyx. % - % TODO Make into shared fixture - - assert(endsWith(which('dat.paths'),... - fullfile('tests', 'fixtures', '+dat', 'paths.m'))); + + % Set INTEST flag to true + setTestFlag(true); + testCase.addTeardown(@setTestFlag, false) - % Check temp mainRepo folder is empty. An extra safe measure as we - % don't won't to delete important folders by accident! - mainRepo = dat.reposPath('main', 'master'); - assert(~exist(mainRepo, 'dir') || isempty(file.list(mainRepo)),... - 'Test experiment repo not empty. Please set another path or manually empty folder'); + % Ensure we're using the correct test paths and add teardowns to + % remove any folders we create + testCase.applyFixture(ReposFixture) % Now create a single subject folder + mainRepo = dat.reposPath('main', 'master'); assert(mkdir(fullfile(mainRepo, 'test')), ... 'Failed to create subject folder') - - % Create a rig config folder - configDir = getOr(dat.paths, 'rigConfig'); - assert(mkdir(configDir), 'Failed to create config directory') - + % Save a custom path disabling Alyx paths.databaseURL = []; + configDir = getOr(dat.paths, 'rigConfig'); save(fullfile(configDir, 'paths.mat'), 'paths') % Alyx queue location qDir = getOr(dat.paths, 'localAlyxQueue'); assert(mkdir(qDir), 'Failed to create alyx queue') - addTeardown(testCase, @clearCBToolsCache) - - % Add teardown to remove folders - testFolders = {mainRepo; qDir; getOr(dat.paths, 'globalConfig')}; - rmFcn = @(repo)assert(rmdir(repo, 's'), 'Failed to remove test repo %s', repo); - addTeardown(testCase, @cellfun, rmFcn, testFolders) + addTeardown(testCase, @ClearTestCache) end - function setupMock(testCase) - % SETUPMOCK Create mock rig objects and avoid git update - % 1. Sets global INTEST flag to true and adds teardown - % 2. Ensure git update doesn't pull code + function fixUpdates(~) + % FIXUPDATES Ensure git update doesn't pull code + % Have FETCH_HEAD file appear recently modified to avoid triggering + % any code updates. % - % See also MOCKRIG, GIT.UPDATE - - % Set INTEST flag to true - testCase.setTestFlag(true) - testCase.addTeardown(@testCase.setTestFlag, false) + % See also GIT.UPDATE % Make sure git update not triggered root = getOr(dat.paths, 'rigbox'); % Rigbox root directory @@ -224,7 +211,7 @@ function test_quit(testCase) function test_devices_fail(testCase) % Set hw.devices to return empty clear devices; - id = 'rigbox:srv:expServer:missingHardware'; + id = 'Rigbox:srv:expServer:missingHardware'; testCase.verifyError(@srv.expServer, id, ... 'Expected error for misconfigured hardware'); end @@ -923,12 +910,4 @@ function test_run_fail(testCase) end end - methods (Static) - function setTestFlag(TF) - % SETTESTFLAG Set global INTEST flag - % Allows setting of test flag via callback function - global INTEST - INTEST = TF; - end - end end diff --git a/tests/fixtures/+dat/paths.m b/tests/fixtures/+dat/paths.m index fcf87c7e..79e80f8d 100644 --- a/tests/fixtures/+dat/paths.m +++ b/tests/fixtures/+dat/paths.m @@ -1,12 +1,18 @@ function p = paths(rig) %DAT.PATHS Returns struct containing important paths for testing -% p = DAT.PATHS([RIG]) -% TODO: -% - Clean up expDefinitions directory +% p = DAT.PATHS([RIG]) Note that most tests use this function, therefore +% you should re-tun all tests after making any changes. +% % Part of Rigbox % 2013-03 CB created +% Ensure we're in the test enviroment +global INTEST +assert(~isempty(INTEST) && INTEST == true, 'Rigbox:tests:paths:notInTest', ... + 'If testing set INTEST to true, otherwise unset %s from your path', ... + fullfile(fileparts(which('addRigboxPaths')), 'tests', 'fixtures')); + thishost = 'testRig'; if nargin < 1 || isempty(rig) diff --git a/tests/fixtures/+hw/devices.m b/tests/fixtures/+hw/devices.m index 9b435d2c..d5107a99 100644 --- a/tests/fixtures/+hw/devices.m +++ b/tests/fixtures/+hw/devices.m @@ -22,7 +22,12 @@ if isempty(mockRig) mockRig = struct(... 'name', name, ... - 'clock', hw.ptb.Clock); + 'clock', hw.ptb.Clock, ... + 'audioDevices', struct(... + 'DeviceName', 'default',... + 'DeviceIndex', -1,... + 'DefaultSampleRate', 44100,... + 'NrOutputChannels', 2)); end % Set mock diff --git a/tests/fixtures/ClearTestCache.m b/tests/fixtures/ClearTestCache.m new file mode 100644 index 00000000..e3e6a3d8 --- /dev/null +++ b/tests/fixtures/ClearTestCache.m @@ -0,0 +1,24 @@ +classdef ClearTestCache < matlab.unittest.fixtures.Fixture + %CLEARTESTCACHE Clears all test functions that store variables + % Clears all global and persistent test function variables. NB: As + % this calls a function itself clears these functions, the teardown + % order may be important, i.e. if a fixture path changes the variables + % for these functions may not be cleared. On the other hand, the act + % itself of unsetting a path may clear the cache anyway. Further + % testing required. + + methods + function setup(fixture) + fixture.addTeardown(@fixture.clearFunctionCache) + end + end + + methods (Static) + function clearFunctionCache() + % Clear functions used in tests + % cb-tools cache cleared by the ReposFixture teardown + clear system pnet MockDialog KbQueueCheck modDate ... + devices configureDummyExperiment Screen funSpy + end + end +end \ No newline at end of file diff --git a/tests/fixtures/ReposFixture.m b/tests/fixtures/ReposFixture.m new file mode 100644 index 00000000..defddf68 --- /dev/null +++ b/tests/fixtures/ReposFixture.m @@ -0,0 +1,38 @@ +classdef ReposFixture < matlab.unittest.fixtures.Fixture + %REPOSFIXTURE Fixture for using test dat.paths file + % Assures test paths are returned, creates a rig config folder for + % tests to save a hardware file to and upon teardown removes all + % existing test repository folders. + + methods + function setup(fixture) + % SETUP Ensure correct test paths and create rig config folder + import matlab.unittest.fixtures.PathFixture + fixture.applyFixture(PathFixture(fileparts(mfilename('fullpath')))) + + % Check paths file + assert(endsWith(which('dat.paths'),... + fullfile('tests', 'fixtures', '+dat', 'paths.m'))); + + % Check temp mainRepo folder is empty. An extra safe measure as we + % don't won't to delete important folders by accident! + mainRepo = dat.reposPath('main', 'master'); + assert(~exist(mainRepo, 'dir') || isempty(file.list(mainRepo)),... + 'Test experiment repo not empty. Please set another path or manually empty folder'); + + % Create other folders + assert(mkdir(getOr(dat.paths, 'rigConfig')), 'Failed to create config directory') + end + + function teardown(~) + testFolders = [dat.reposPath('main');... + {[dat.reposPath('main', 'm') '2']};... + {getOr(dat.paths, 'globalConfig')};... + {getOr(dat.paths, 'localAlyxQueue')}]; + testFolders = testFolders(file.exists(testFolders)); + rmFcn = @(repo)assert(rmdir(repo, 's'), 'Failed to remove test repo %s', repo); + cellfun(rmFcn, testFolders) + clearCBToolsCache + end + end +end \ No newline at end of file diff --git a/tests/fixtures/funSpy.m b/tests/fixtures/funSpy.m new file mode 100644 index 00000000..9bd9115b --- /dev/null +++ b/tests/fixtures/funSpy.m @@ -0,0 +1,8 @@ +function history = funSpy(varargin) +persistent log + +if isempty(log) + log = containers.Map('KeyType', 'double', 'ValueType', 'any'); +end +log(now) = varargin; +end \ No newline at end of file diff --git a/tests/fixtures/setTestFlag.m b/tests/fixtures/setTestFlag.m new file mode 100644 index 00000000..107c547e --- /dev/null +++ b/tests/fixtures/setTestFlag.m @@ -0,0 +1,6 @@ +function old = setTestFlag(TF) +% SETTESTFLAG Set global INTEST flag +% Allows setting of test flag via callback function +global INTEST +old = INTEST; +INTEST = TF; diff --git a/tests/fixtures/util/+file/modDate.m b/tests/fixtures/util/+file/modDate.m index 9695f401..de1da09e 100644 --- a/tests/fixtures/util/+file/modDate.m +++ b/tests/fixtures/util/+file/modDate.m @@ -12,6 +12,8 @@ % assert(diff(floor([now, file.modDate(p)])) == 0) % clear modDate INTEST % Reset after test % +% Part of Rigbox tests + % 2019-09 MW created persistent dates diff --git a/tests/fixtures/util/+vis/init.m b/tests/fixtures/util/+vis/init.m new file mode 100644 index 00000000..a6eb4fd7 --- /dev/null +++ b/tests/fixtures/util/+vis/init.m @@ -0,0 +1,2 @@ +function occ = init(~) +occ = struct; \ No newline at end of file diff --git a/tests/fixtures/util/Contents.m b/tests/fixtures/util/Contents.m index da73ecb6..8c959df3 100644 --- a/tests/fixtures/util/Contents.m +++ b/tests/fixtures/util/Contents.m @@ -8,4 +8,5 @@ % MockDialog - A class for mocking MATLAB dialog windows % system - Returns preset output status and message for a given input % file.modDate - Returns preset modification date for a given input -% KbQueueCheck - Simulates a preset key press sequence \ No newline at end of file +% KbQueueCheck - Simulates a preset key press sequence +% pnet - Mock function for pnet diff --git a/tests/fixtures/util/KbQueueCheck.m b/tests/fixtures/util/KbQueueCheck.m index 1314c332..c59a59a1 100644 --- a/tests/fixtures/util/KbQueueCheck.m +++ b/tests/fixtures/util/KbQueueCheck.m @@ -25,7 +25,7 @@ % clear KbQueueCheck INTEST % Reset after test % % % Simulate zero keyboard interaction for a given device -% KbQueueCheck(1, [{false}, repmat({zeros(1,256)},1,4)]); +% KbQueueCheck(1, {false, zeros(size(KbName('KeyNames')))}); % assert(~KbQueueCheck(1)) % % 2019-09 MW created @@ -55,7 +55,7 @@ if ~isKey(KbQueue, deviceIndex) % If not set, throw warning and return real mod date warning('Rigbox:tests:KbQueueCheck:keypressNotSet', ... - 'Mock called but date not set, returning actual') + 'Mock called but device output not set, returning actual') orig = pwd; mess = onCleanup(@() cd(orig)); PTB = fileparts(which('SetupPsychtoolbox')); @@ -72,9 +72,10 @@ KbQueue(deviceIndex) = output.rest; end output = output.first; - if ischar(output) % Convert to actual output - idx = KbName(output); + if ischar(output) || isnumeric(output) % Convert to actual output + idx = iff(ischar(output), KbName(output), output); output = cell(1, nargs); + output{1} = true; % pressed output{2} = zeros(size(KbName('KeyNames'))); output{2}(idx) = GetSecs(); end @@ -87,14 +88,17 @@ % Assume all output args set output = keyPress; else - % Assign only second output + % Convert keypress to code if necessary + keyPress = iff(ischar(keyPress), KbName(keyPress), keyPress); + % Assign only first two outputs output = cell(1, nargs); + output{1} = true; % pressed output{2} = zeros(size(KbName('KeyNames'))); - output{2}(KbName(keyPress)) = GetSecs(); + output{2}(keyPress) = GetSecs(); end KbQueue(iff(isempty(deviceIndex), -1, deviceIndex)) = output; % Set our output if isempty(INTEST) || ~INTEST - fprintf('Set date for %s. Please set INTEST flag to true\n', p); + fprintf('Set ouput for device %i. Please set INTEST flag to true\n', deviceIndex); end % Return empty on setting date output = cell(1, nargout); diff --git a/tests/fixtures/util/MockDialog.m b/tests/fixtures/util/MockDialog.m index d9d654f2..e7f3e36d 100644 --- a/tests/fixtures/util/MockDialog.m +++ b/tests/fixtures/util/MockDialog.m @@ -9,7 +9,7 @@ % mockdlg.Dialogs('1st dlg title') = 12; % mockdlg.Dialogs('2nd dlg title') = false; % - % mockdlg = MockDialog.instance('uin32'); + % mockdlg = MockDialog.instance('uint32'); % mockdlg.Dialogs(0) = 12; % mockdlg.Dialogs(1) = {12, 'second input ans', true}; % @@ -92,7 +92,7 @@ function reset(obj) % Check we're in test mode, throw warning if not if ~obj.InTest - warning('MockDialog:newCall:InTestFalse', ... + warning('Rigbox:MockDialog:newCall:notInTest', ... ['MockDialog method called whilst InTest flag set to false. ' ... 'Check paths or set flag to true to avoid this message']) end @@ -142,7 +142,7 @@ function reset(obj) answer = answer.first; obj.Dialogs(key) = obj.Dialogs(key).rest; elseif isa(answer, 'fun.EmptySeq') - warning('MockDialog:NewCall:EmptySeq', ... + warning('Rigbox:MockDialog:newCall:EmptySeq', ... 'End of input sequence, using default input instead') answer = def; end @@ -163,7 +163,8 @@ function reset(obj) key = []; return elseif ~obj.UseDefaults - assert(obj.Dialogs.Count > 0, 'MockDialog:newCall:NoValuesSet', ... + assert(obj.Dialogs.Count > 0, ... + 'Rigbox:MockDialog:newCall:behaviourNotSet', ... 'No values saved in Dialogs property') end key = obj.NumCalls; diff --git a/tests/fixtures/util/mockRig.m b/tests/fixtures/util/mockRig.m index 2f375ed8..879629f2 100644 --- a/tests/fixtures/util/mockRig.m +++ b/tests/fixtures/util/mockRig.m @@ -44,7 +44,7 @@ % lickDetector [rig.lickDetector, behaviour.lickDetector] = ... - createMock(testCase, ?hw.DataLogging); + createMock(testCase, ?hw.PositionSensor); % scale [rig.scale, behaviour.scale] = createMock(testCase, ... @@ -71,4 +71,3 @@ % property definitions. [rig.communicator, behaviour.communicator] = ... createMock(testCase, ?io.Communicator); - diff --git a/tests/fixtures/util/pnet.m b/tests/fixtures/util/pnet.m new file mode 100644 index 00000000..6ad9938a --- /dev/null +++ b/tests/fixtures/util/pnet.m @@ -0,0 +1,118 @@ +function varargout = pnet(varargin) +% PNET Mock function for pnet +% Returns preset output for a given input and spys of calls. Before +% assigning outputs for a given socket, call function with 'udpsocket' and +% the port number to map. +% +% Usage: +% socket = pnet('udpsocket', port) +% pnet(port, 'setoutput', inputArg, output) +% history = pnet('gethistory') +% pnet('clearhistory') +% varargout = pnet(socket, command, varargin) +% +% Examples: +% % Assign output for 'gethost' of port 9999 +% socket = pnet('udpsocket', 9999); +% pnet('setoutput', 9999, 'gethost', {randi(99,1,4), 88}); +% [ip, port] = pnet(socket, 'gethost'); % Return pre-set output +% +% % Assign different output over multiple calls +% socket = pnet('udpsocket', 9999); +% pnet('setoutput', 9999, 'read', sequence({'start', 'stop'})); +% msg = pnet(socket, 'read') % 'start' +% msg = pnet(socket, 'read') % 'stop' +% msg = pnet(socket, 'read') % [] +% +% Part of Rigbox tests + +% 2019-10-17 MW created + +persistent sockets % Structure of outputs to assign +persistent history % Cell array of input arguments +global INTEST + +% Initialize sockets structure +if isempty(sockets) + sockets = struct.empty; +end + +% Process input +switch varargin{1} + case 'udpsocket' + % Check the INTEST flag to ensure that calling mock was intended + if isempty(INTEST) || ~INTEST + warning('Rigbox:tests:pnet:notInTest', ... + ['Mock called without INTEST flag;', ... + 'If called within test, first set INTEST to true.']) + end + % Record socket creation + socket = length(sockets)+1; % Number in order of udpsocket calls + sockets(socket).udpsocket = varargin{2}; % Save port in struct + varargout{1} = socket; % Return socket number + % Append input to history array + history = [history, {varargin}]; + + case 'setoutput' + % Set output + port = varargin{2}; % Port number + % Find index for given port + idx = [sockets.udpsocket] == port; + % Save output for given input string + sockets(idx).(varargin{3}) = varargin{4}; + + case 'clearhistory' + % Clear the cache of function calls + history = []; + + case 'gethistory' + % Return the cache of functions calls + varargout = {history}; + + otherwise + % Return output for given socket number + + % Check the INTEST flag to ensure that calling mock was intended + if isempty(INTEST) || ~INTEST + warning('Rigbox:tests:pnet:notInTest', ... + ['Mock called without INTEST flag;', ... + 'If called within test, first set INTEST to true.']) + end + + % Append input to history array + history = [history, {varargin}]; + % Socket number is index for output map struct + socket = sockets(varargin{1}); + + % Check input previously set + if isfield(socket, varargin{2}) + output = socket.(varargin{2}); + if isa(output, 'fun.Seq') + if isempty(output.rest) % No more in sequence + socket.(varargin{2}) = []; % remove entry + else % Reassign rest + socket.(varargin{2}) = output.rest; + end + output = output.first; + sockets(varargin{1}) = socket; % Update output map + elseif iscell(output) + % Trim output to number of output args for dealing out + output = output(1:nargout); + end + end + + % Assign outputs + if nargout > 0 % If nessessary + if iscell(output) + % Deal contents of each cell out to the output args + [varargout{1:nargout}] = deal(output{:}); + elseif ~ischar(output) && ~isempty(output) + % Deal out each element to the output args + [varargout{1:nargout}] = output(1:nargout); + else + % Output value to all output args + [varargout{1:nargout}] = deal(output); + end + end + +end diff --git a/tests/fixtures/util/ptb/Screen.m b/tests/fixtures/util/ptb/Screen.m new file mode 100644 index 00000000..9349a554 --- /dev/null +++ b/tests/fixtures/util/ptb/Screen.m @@ -0,0 +1,146 @@ +function varargout = Screen(varargin) +% % Copy an image, very quickly, between textures, offscreen windows and onscreen windows. +% [resident [texidresident]] = Screen('PreloadTextures', windowPtr [, texids]); +% Screen('DrawTexture', windowPointer, texturePointer [,sourceRect] [,destinationRect] [,rotationAngle] [, filterMode] [, globalAlpha] [, modulateColor] [, textureShader] [, specialFlags] [, auxParameters]); +% Screen('DrawTextures', windowPointer, texturePointer(s) [, sourceRect(s)] [, destinationRect(s)] [, rotationAngle(s)] [, filterMode(s)] [, globalAlpha(s)] [, modulateColor(s)] [, textureShader] [, specialFlags] [, auxParameters]); +% Screen('CopyWindow', srcWindowPtr, dstWindowPtr, [srcRect], [dstRect], [copyMode]) +% +% % Copy an image, slowly, between matrices and windows : +% imageArray=Screen('GetImage', windowPtr [,rect] [,bufferName] [,floatprecision=0] [,nrchannels=3]) +% Screen('PutImage', windowPtr, imageArray [,rect]); +% +% % Synchronize with the window's screen (on-screen only): +% [VBLTimestamp StimulusOnsetTime swapCertainTime] = Screen('WaitUntilAsyncFlipCertain', windowPtr); +% [info] = Screen('GetFlipInfo', windowPtr [, infoType=0] [, auxArg1]); +% [telapsed] = Screen('DrawingFinished', windowPtr [, dontclear] [, sync]); +% framesSinceLastWait = Screen('WaitBlanking', windowPtr [, waitFrames]); +% +% % Load color lookup table of the window's screen (on-screen only): +% [gammatable, dacbits, reallutsize] = Screen('ReadNormalizedGammaTable', windowPtrOrScreenNumber [, physicalDisplay]); +% [oldtable, success] = Screen('LoadNormalizedGammaTable', windowPtrOrScreenNumber, table [, loadOnNextFlip][, physicalDisplay][, ignoreErrors]); +% oldclut = Screen('LoadCLUT', windowPtrOrScreenNumber [, clut] [, startEntry=0] [, bits=8]); +% +% % Get (and set) information about a window or screen: +% windowPtrs=Screen('Windows'); +% kind=Screen(windowPtr, 'WindowKind'); +% isOffscreen=Screen(windowPtr,'IsOffscreen'); +% hz=Screen('FrameRate', windowPtrOrScreenNumber [, mode] [, reqFrameRate]); +% hz=Screen('NominalFrameRate', windowPtrOrScreenNumber [, mode] [, reqFrameRate]); +% [ monitorFlipInterval nrValidSamples stddev ]=Screen('GetFlipInterval', windowPtr [, nrSamples] [, stddev] [, timeout]); +% screenNumber=Screen('WindowScreenNumber', windowPtr); +% pixelSize=Screen('PixelSize', windowPtrOrScreenNumber); +% pixelSizes=Screen('PixelSizes', windowPtrOrScreenNumber); +% [width, height]=Screen('WindowSize', windowPointerOrScreenNumber [, realFBSize=0]); +% [width, height]=Screen('DisplaySize', ScreenNumber); +% [oldmaximumvalue, oldclampcolors, oldapplyToDoubleInputMakeTexture] = Screen('ColorRange', windowPtr [, maximumvalue][, clampcolors][, applyToDoubleInputMakeTexture]); +% info = Screen('GetWindowInfo', windowPtr [, infoType=0] [, auxArg1]); +% resolutions=Screen('Resolutions', screenNumber); +% oldResolution=Screen('Resolution', screenNumber [, newwidth] [, newheight] [, newHz] [, newPixelSize] [, specialMode]); +% oldSettings = Screen('ConfigureDisplay', setting, screenNumber, outputId [, newwidth][, newheight][, newHz][, newX][, newY]); +% Screen('ConstrainCursor', windowIndex, addConstraint [, rect]); +% +% % Get/set details of environment, computer, and video card (i.e. screen): +% struct=Screen('Version'); +% comp=Screen('Computer'); +% +% % Helper functions. Don't call these directly, use eponymous wrappers: +% [x, y, buttonVector, hasKbFocus, valuators]= Screen('GetMouseHelper', numButtons [, screenNumber][, mouseIndex]); +% Screen('HideCursorHelper', windowPntr [, mouseIndex]); +% Screen('ShowCursorHelper', windowPntr [, cursorshapeid][, mouseIndex]); +% Screen('SetMouseHelper', windowPntrOrScreenNumber, x, y [, mouseIndex][, detachFromMouse]); +% Screen('SetMouseHelper', windowPntrOrScreenNumber, x, y [, mouseIndex][, detachFromMouse]); +% +% % Internal testing of Screen +% timeList= Screen('GetTimelist'); +% Screen('ClearTimelist'); +% Screen('Preference','DebugMakeTexture', enableDebugging); +% +% % Support for 3D graphics rendering and for interfacing with external OpenGL code: +% [targetwindow, IsOpenGLRendering] = Screen('GetOpenGLDrawMode'); +% [textureHandle rect] = Screen('SetOpenGLTextureFromMemPointer', windowPtr, textureHandle, imagePtr, width, height, depth [, upsidedown][, target][, glinternalformat][, gltype][, extdataformat][, specialFlags]); +% [textureHandle rect] = Screen('SetOpenGLTexture', windowPtr, textureHandle, glTexid, target [, glWidth][, glHeight][, glDepth][, textureShader][, specialFlags]); +% [ gltexid gltextarget texcoord_u texcoord_v ] =Screen('GetOpenGLTexture', windowPtr, textureHandle [, x][, y]); +% +% % Support for plugins and for builtin high performance image processing pipeline: +% [ret1, ret2, ...] = Screen('HookFunction', windowPtr, 'Subcommand', 'HookName', arg1, arg2, ...); +% proxyPtr = Screen('OpenProxy', windowPtr [, imagingmode]); +% transtexid = Screen('TransformTexture', sourceTexture, transformProxyPtr [, sourceTexture2][, targetTexture][, specialFlags]); + +% % Draw Text in windows +% [oldFontName,oldFontNumber,oldTextStyle]=Screen('TextFont', windowPtr [,fontNameOrNumber][,textStyle]); +% [normBoundsRect, offsetBoundsRect, textHeight, xAdvance] = Screen('TextBounds', windowPtr, text [,x] [,y] [,yPositionIsBaseline] [,swapTextDirection]); +% [newX, newY, textHeight]=Screen('DrawText', windowPtr, text [,x] [,y] [,color] [,backgroundColor] [,yPositionIsBaseline] [,swapTextDirection]); +% oldTextColor=Screen('TextColor', windowPtr [,colorVector]); +% oldTextBackgroundColor=Screen('TextBackgroundColor', windowPtr [,colorVector]); +% oldMatrix = Screen('TextTransform', windowPtr [, newMatrix]); + +% global INTEST +% persistent history +% +% if isempty(INTEST) || ~INTEST +% error('Rigbox:tests:Screen:notInTest', 'Screen called while out of test') +% end +% +% if isempty(history) +% history = containers.Map('KeyType', 'int32', 'ValueType', 'any'); +% end +% +% if strcmp(varargin{1}, 'GetHistory') +% time = history; +% else +% key = length(history) + 1; +% history(key) = varargin; +% time = GetSecs; +% end + +persistent argMap +if isempty(argMap) + + functions = {... + 'Close' % 0 + 'CloseAll' + 'glPushMatrix' + 'glPopMatrix' + 'glLoadIdentity' + 'glTranslate' + 'glScale' + 'glRotate' + 'DrawLine' + 'DrawArc' + 'FrameArc' + 'FillArc' + 'FillRect' + 'FrameRect' + 'FillOval' + 'FrameOval' + 'FramePoly' + 'FillPoly' + 'BeginOpenGL' + 'EndOpenGL' + 'Preference' % 1 + 'Screens' + 'Windows' + 'Rect' + 'TextModes' + 'TextMode' + 'TextSize' + 'TextStyle' + 'MakeTexture' + 'PanelFitter' + 'SelectStereoDrawBuffer' + 'OpenWindow' % 2 + 'OpenOffscreenWindow' + 'Flip' % 5 + 'AsyncFlipBegin' + 'AsyncFlipEnd' + 'AsyncFlipCheckEnd'}; + + nArgs = [zeros(1,20), ones(1,11), 2, 2, 5, 5, 5, 5]; + argMap = containers.Map(functions, num2cell(nArgs)); +end + +if strcmp(varargin{1}, 'Rect') + varargout = {randi(100, 1, 4)}; +else + varargout = deal(num2cell(randi(10, 1, argMap(varargin{1})))); +end \ No newline at end of file diff --git a/tests/optimizations/ParamEditor_perfTest.m b/tests/optimizations/ParamEditor_perfTest.m index 5fbabdc2..16911c24 100644 --- a/tests/optimizations/ParamEditor_perfTest.m +++ b/tests/optimizations/ParamEditor_perfTest.m @@ -1,6 +1,6 @@ -classdef (SharedTestFixtures={matlab.unittest.fixtures.PathFixture(... -fullfile(getOr(dat.paths,'rigbox'), 'tests', 'fixtures'))})... % add 'fixtures' folder as test fixture -ParamEditor_perfTest < matlab.perftest.TestCase +classdef (SharedTestFixtures={... + matlab.unittest.fixtures.PathFixture('../fixtures')})... % add 'fixtures' folder as test fixture + ParamEditor_perfTest < matlab.perftest.TestCase properties % Figure visibility setting before running tests @@ -17,6 +17,11 @@ methods (TestClassSetup) function setup(testCase) + % Set our test flag to true. This avoids errors and warnings from + % fixture functions. + setTestFlag(true); + testCase.addTeardown(@setTestFlag, false) + % Hide figures and add teardown function to restore settings testCase.FigureVisibleDefault = get(0,'DefaultFigureVisible'); set(0,'DefaultFigureVisible','off'); @@ -226,7 +231,7 @@ function test_paramEdits(testCase) 'Unexpected label colour') % Verify change in underlying param struct par = strcmpi(PE.Parameters.GlobalNames,... - strrep(gLabels(idx).String, ' ', '')); + erase(gLabels(idx).String, ' ')); testCase.verifyEqual(PE.Parameters.Struct.(PE.Parameters.GlobalNames{par}), 666, ... 'UI edit failed to update parameters struct') diff --git a/tests/optimizations/mergeStructs_perftest.m b/tests/optimizations/mergeStructs_perftest.m index 85f9f546..c6084fd2 100644 --- a/tests/optimizations/mergeStructs_perftest.m +++ b/tests/optimizations/mergeStructs_perftest.m @@ -46,7 +46,13 @@ function setup(testCase, n) % src corresponds to conditional pars in SignalExp I = n_global:n+n_global; vals = mapToCell(@(~) rand(10,1), I); - fnConds = strcat('fn', num2cellstr(I)); + small = I < 1000000; + fnConds = strcat('fn', num2cellstr(I(small))); + if any(~small) + % To avoid invalid fieldnames (e.g. '1e+06'), generate names from + % larger numbers using the slower `num2str` function + fnConds = [fnConds strcat('fn', strsplit(num2str(I(~small))))]; + end testCase.src = cell2struct(vals(:), fnConds(:)); end end diff --git a/tests/trialConditions_test.m b/tests/trialConditions_test.m new file mode 100644 index 00000000..28e2f072 --- /dev/null +++ b/tests/trialConditions_test.m @@ -0,0 +1,100 @@ +% Test for exp.trialConditions +[advanceTrial, globalPars, condPars, seed] = sig.test.create; +% Convenience function for checking current value +match = @(s,v) ~isempty(s.Node.CurrValue) && s.Node.CurrValue == v; +% Keep track of expected trial index +idx = advanceTrial.scan(@plus, merge(seed, condPars.map(0))); + +n = 5; % Number of trials; NB: must be greater than 3! +parsStruct = struct('global', rand, 'conditional', 1:n, 'numRepeats', 1); +[~, globalParams, trialParams] = ... + exp.Parameters(parsStruct).toConditionServer(false); + +% Convenience function for checking expected parameters. Because the +% parameters aren't randomized and the condition parameters are numbered, +% the current conditional parameter should be equal to the current trial +% index. +parsMatch = @(p) ... + p.Node.CurrValue.global == globalParams.global &&... + p.Node.CurrValue.conditional == idx.Node.CurrValue; + +%% Test 1: Signals condition server without reset +[params, hasNext, repeatNum] = exp.trialConditions(... + globalPars, condPars, advanceTrial); + +% Update parameters +globalPars.post(globalParams) +condPars.post(trialParams) +signalUpdates = params.map(1).scan(@plus, 0); % Count number of updates + +% Check parameter updates on first advanceTrial +advanceTrial.post(true) +assert(match(hasNext, true), 'failed to update hasNext signal') +assert(match(repeatNum, 1), 'failed to update repeatNum signal') +assert(parsMatch(params), 'unexpected trial params') + +% Check second progression +advanceTrial.post(true) +assert(match(hasNext, true), 'failed to update hasNext signal') +assert(match(repeatNum, 1), 'failed to update repeatNum signal') +assert(parsMatch(params), 'unexpected trial params') + +% Check behaviour on advance trial false +advanceTrial.post(false) +assert(match(hasNext, true), 'failed to update hasNext signal') +assert(match(repeatNum, 2), 'failed to update repeatNum signal') +assert(parsMatch(params), 'unexpected trial params') + +% Check repeat num after advance trial true +while idx.Node.CurrValue ~= n + advanceTrial.post(true) +end +assert(match(hasNext, true), 'failed to update hasNext signal') +assert(match(repeatNum, 1), 'failed to update repeatNum signal') +assert(parsMatch(params), 'unexpected trial params') + +% Check behaviour when all trials finished +advanceTrial.post(true) +assert(match(hasNext, false), 'failed to update hasNext signal') + +% Pars signal should no longer update +advanceTrial.post(true) +assert(match(signalUpdates, idx.Node.CurrValue-1), ... + 'unexpected number of params signal updates') + +%% Test 2: Reset input as signal +[params, hasNext, repeatNum] = exp.trialConditions(... + globalPars, condPars, advanceTrial, seed); + +% Update our parameters +globalPars.post(globalParams) +condPars.post(trialParams) +seed.post(0); % Initialize seed +signalUpdates = params.map(1).scan(@plus, 0); % Count number of updates + +% Check consistent behaviour with seed being signal +advanceTrial.post(true) +advanceTrial.post(false) +advanceTrial.post(true) +assert(match(hasNext, true), 'failed to update hasNext signal') +assert(match(repeatNum, 1), 'failed to update repeatNum signal') +assert(parsMatch(params), 'unexpected trial params') + +% Posting 0 to seed should reset parameter condition index +seed.post(0); +advanceTrial.post(true) +assert(match(hasNext, true), 'failed to update hasNext signal') +assert(match(repeatNum, 1), 'failed to update repeatNum signal') +assert(parsMatch(params), 'unexpected trial params') + +seed.post(n-1); % Change seed to second-to-last trial +advanceTrial.post(true) +assert(match(hasNext, true), 'failed to update hasNext signal') +assert(match(repeatNum, 1), 'failed to update repeatNum signal') +assert(parsMatch(params), 'unexpected trial params') + +advanceTrial.post(true) +assert(match(hasNext, false), 'failed to update hasNext signal') +assert(match(repeatNum, 1), 'failed to update repeatNum signal') +assert(match(signalUpdates, idx.Node.CurrValue), ... + 'unexpected number of params signal updates') diff --git a/wheelAnalysis b/wheelAnalysis index 05f90203..9a65ff93 160000 --- a/wheelAnalysis +++ b/wheelAnalysis @@ -1 +1 @@ -Subproject commit 05f902033bc834c98624d5634be3cf91b737f250 +Subproject commit 9a65ff93259a708dda7601bfc307697458bad991