diff --git a/+dat/addLogEntry.m b/+dat/addLogEntry.m index fcbeb676..7ef7a240 100644 --- a/+dat/addLogEntry.m +++ b/+dat/addLogEntry.m @@ -26,6 +26,7 @@ %% create and store entry e = entry(nextidx); log(nextidx) = e; +% Store an instance of Alyx for narrative registration if nargin > 5; e.AlyxInstance = AlyxInstance; end %% store updated log to *all* repos locations 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/delParamProfile.m b/+dat/delParamProfile.m index d67b4d24..324f9b0c 100644 --- a/+dat/delParamProfile.m +++ b/+dat/delParamProfile.m @@ -8,7 +8,7 @@ function delParamProfile(expType, profileName) %path to repositories fn = 'parameterProfiles.mat'; -repos = fullfile(dat.reposPath('expInfo'), fn); +repos = fullfile(dat.reposPath('main'), fn); %load existing profiles for specified expType profiles = dat.loadParamProfiles(expType); diff --git a/+dat/expExists.m b/+dat/expExists.m index ffd6d8f4..ebb79319 100644 --- a/+dat/expExists.m +++ b/+dat/expExists.m @@ -1,6 +1,11 @@ function b = expExists(expRef) %DAT.EXPEXISTS Confirm existence of experiment(s) with reference -% b = DAT.EXPEXISTS(expRef) TODO +% b = DAT.EXPEXISTS(expRef) Returns true is expRef exists, where expRef +% is an experiment reference string or cell array thereof. For an +% experiment to exist the correct folder structure must be present in the +% main repository's master location. +% +% See Also DAT.LISTEXPS, DAT.PATHS % % Part of Rigbox @@ -14,7 +19,7 @@ function b = check(expRef) % ensure the standard folder given the reference exists - b = file.exists(dat.expPath(expRef, 'expInfo', 'master')); + b = file.exists(dat.expPath(expRef, 'main', 'master')); end end \ No newline at end of file diff --git a/+dat/expFilePath.m b/+dat/expFilePath.m index b2695823..0470a0a1 100644 --- a/+dat/expFilePath.m +++ b/+dat/expFilePath.m @@ -7,105 +7,128 @@ % e.g. to get the paths for an experiments 2 photon TIFF movie: % DAT.EXPFILEPATH('mouse1', datenum(2013, 01, 01), 1, '2p-raw'); % -% [full, filename] = expFilePath(ref, type, [reposlocation]) +% [full, filename] = expFilePath(ref, type, [reposlocation, ext]) % -% [full, filename] = expFilePath(subject, date, seq, type, [reposlocation]) +% [full, filename] = expFilePath(subject, date, seq, type, [reposlocation, ext]) % -% Options for reposlocation are: 'local' or 'master' +% Options for reposlocation are: 'all' (default), 'local', 'master' and 'remote' % Many options for type, e.g. 'block', '2p-raw', 'eyetracking', etc +% If ext is specified, the path returned has the extention ext, otherwise +% the default for that type is used. % % Part of Rigbox % 2013-03 CB created -if nargin == 3 || nargin == 5 - % repos argument was passed, save the value and remove from varargin - location = varargin(end); - varargin(end) = []; -elseif nargin < 2 - error('Not enough arguments supplied.'); -else - % repos argument not passed - location = {}; +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 < 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 + location = {}; + end + typeIdx = 4; +else % Ref, not subject + 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 [varargin{1:end}, singleArgs] = tabulateArgs(varargin{:}); -% last argument is the file type -fileType = varargin{end}; +fileType = varargin{typeIdx}; +extention = iff(any(numel(varargin) == [3,5]), varargin{end},... + cell(1,length(varargin{1}))); +if any(numel(varargin) == [3,5]); varargin(end) = []; end + % convert file types to file suffixes -[repos, suffix, dateLevel] = mapToCell(@typeInfo, fileType); +[repos, suffix, dateLevel] = mapToCell(@typeInfo, fileType(:), extention(:)); reposArgs = cat(2, {repos}, location); % and the rest are for the experiment reference [expPath, expRef] = dat.expPath(varargin{1:end - 1}, reposArgs{:}); - function [repos, suff, dateLevel] = typeInfo(type) + function [repos, suff, dateLevel] = typeInfo(type, newExt) % whether this repository is at the date level or otherwise deeper at the sequence - % level (default) + % level (default). FIXME: Date level doesn't work, perhaps this should + % be modified to work with deeper sequences also? E.g. + % default\2018-05-04\1\2 dateLevel = false; + repos = 'main'; + ext = '.mat'; switch lower(type) case 'block' % MAT-file with info about each set of trials - repos = 'expInfo'; - suff = '_Block.mat'; + suff = '_Block'; case 'hw-info' % MAT-file with info about the hardware used for an experiment - repos = 'expInfo'; - suff = '_hardwareInfo.mat'; + suff = '_hardwareInfo'; case '2p-raw' % TIFF with 2-photon raw fluorescence movies - repos = 'twoPhoton'; suff = '_2P.tif'; + ext = '.tif'; case 'calcium-preview' - repos = 'twoPhoton'; - suff = '_2P_CalciumPreview.tif'; + suff = '_2P_CalciumPreview'; + ext = '.tif'; case 'calcium-reg' - repos = 'twoPhoton'; suff = '_2P_CalciumReg'; + ext = ''; case 'calcium-regframe' - repos = 'twoPhoton'; - suff = '_2P_CalciumRegFrame.tif'; + suff = '_2P_CalciumRegFrame'; + ext = '.tif'; case 'timeline' % MAT-file with acquired timing information - repos = 'expInfo'; - suff = '_Timeline.mat'; + suff = '_Timeline'; case 'calcium-roi' - repos = 'twoPhoton'; - suff = '_ROI.mat'; + suff = '_ROI'; case 'calcium-fc' % minimally filtered fractional change frames - repos = 'twoPhoton'; suff = '_2P_CalciumFC'; + ext = ''; case 'calcium-ffc' % ROI filtered fractional change frames - repos = 'twoPhoton'; suff = '_2P_CalciumFFC'; + ext = ''; case 'calcium-widefield-svd' - repos = 'widefield'; suff = '_SVD'; + ext = ''; case 'eyetracking' - repos = 'eyeTracking'; suff = '_eye'; + ext = ''; case 'parameters' % MAT-file with parameters used for experiment - repos = 'expInfo'; - suff = '_parameters.mat'; + suff = '_parameters'; case 'lasermanip' - repos = 'expInfo'; - suff = '_laserManip.mat'; + suff = '_laserManip'; case 'img-info' - repos = 'twoPhoton'; - suff = '_imgInfo.mat'; + suff = '_imgInfo'; case 'tmaze' - repos = 'expInfo'; - suff = '_TMaze.mat'; + suff = '_TMaze'; case 'expdeffun' - repos = 'expInfo'; - suff = '_expDef.m'; + suff = '_expDef'; + ext = '.m'; case 'svdspatialcomps' dateLevel = true; - % expPath = mapToCell(@fileparts, expPath); - % repos = 'expInfo'; - % suff = '_expDef.m'; otherwise error('"%s" is not a valid file type', type); end + % Append extention to suffix + ext = iff(isempty(newExt)&&~ischar(newExt), ext, newExt); + suff = iff((isempty(ext)&&ischar(ext))||(~isempty(ext)&&ext(1)=='.'),... + [suff, ext], [suff, '.', ext]); end % generate a filename for each experiment @@ -120,5 +143,4 @@ filename = filename{1}; end -end - +end \ No newline at end of file diff --git a/+dat/expLogRequest.m b/+dat/expLogRequest.m index 02d7308e..45efbc15 100644 --- a/+dat/expLogRequest.m +++ b/+dat/expLogRequest.m @@ -6,7 +6,7 @@ if nargin < 2 args = struct; -elseif nargin == 2 && isstruct(varargin{1}); +elseif nargin == 2 && isstruct(varargin{1}) else args = varargin2struct(varargin{:}); end diff --git a/+dat/expParams.m b/+dat/expParams.m index 25839821..260144dd 100644 --- a/+dat/expParams.m +++ b/+dat/expParams.m @@ -7,6 +7,11 @@ % each experiment. Any experiments without saved parameters will return % empty, []. % +% If there are multiple remote 'main' repositories, all are searched +% precedence is given to experiments on the master repository. +% +% See also DAT.LOADBLOCK, DAT.EXPFILEPATH +% % Part of Rigbox % 2013-03 CB created @@ -14,18 +19,17 @@ %If ref is not an array, wrap it so code below works generally [ref, singleArg] = ensureCell(ref); %Get the paths where parameters for each experiment will be, if any -files = dat.expFilePath(ref, 'parameters', 'master'); +files = dat.expFilePath(ref, 'parameters', 'remote'); %Check which param files exist and load those into results array -p = cell(size(ref)); -present = file.exists(files); -p(present) = loadVar(files(present), 'parameters'); +matching = @(p) file.exists(ensureCell(p)); +seq = cellfun(@(f)sequence(ensureCell(f)),files); +p = mapToCell(@(p)iff(isempty(p), [], @() loadVar(p,'parameters')), ... + fun.map(@(p)p.filter(matching).first, seq)); %mu = 0.0490; std = 0.0075 +% p = mapToCell(@loadFun, files); %mu = 0.0443; std = 0.0035 if singleArg %If single arg was passed in (i.e. not a cell array, but just a ref, %make sure we return a single result (i.e. a single parameter variable, %not a cell array of them. p = p{1}; -end - -end - +end \ No newline at end of file diff --git a/+dat/expPath.m b/+dat/expPath.m index b9b1abf6..ff1edb07 100644 --- a/+dat/expPath.m +++ b/+dat/expPath.m @@ -9,10 +9,10 @@ % sames as the above, but returns paths for an experiment with a % specified 'subject', on a particular 'date', and numbered 'seq'. % -% e.g. to get the paths for the 'expInfo' repository, for the first +% e.g. to get the paths for the 'main' repository, for the first % experiment of the day for 'SUBJECTA': % -% paths = DAT.EXPPATH('SUBJECTA', now, 1, 'expInfo'); +% paths = DAT.EXPPATH('SUBJECTA', now, 1, 'main'); % % Part of Rigbox @@ -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/listExps.m b/+dat/listExps.m index c29506a3..44e0438d 100644 --- a/+dat/listExps.m +++ b/+dat/listExps.m @@ -7,33 +7,36 @@ % 2013-03 CB created -% The master 'expInfo' repository is the reference for the existence of +% The master 'main' repository is the reference for the existence of % experiments, as given by the folder structure -expInfoPath = dat.reposPath('expInfo', 'master'); +mainPath = ensureCell(dat.reposPath('main', 'remote')); function [expRef, expDate, expSeq] = subjectExps(subject) % finds experiments for individual subjects % experiment dates correpsond to date formated folders in subject's % folder - subjectPath = fullfile(expInfoPath, subject); - subjectDirs = file.list(subjectPath, 'dirs'); + subjectPath = fullfile(mainPath, subject); + subjectDirs = cellflat(rmEmpty(file.list(subjectPath, 'dirs'))); dateRegExp = '^(?\d\d\d\d)\-?(?\d\d)\-?(?\d\d)$'; dateMatch = regexp(subjectDirs, dateRegExp, 'names'); - dateStrs = subjectDirs(~emptyElems(dateMatch)); + dateStrs = unique(subjectDirs(~emptyElems(dateMatch))); [expDate, expSeq] = mapToCell(@(d) expsForDate(subjectPath, d), dateStrs); expDate = cat(1, expDate{:}); expSeq = cat(1, expSeq{:}); expRef = dat.constructExpRef(repmat({subject}, size(expDate)), expDate, expSeq); %sort them by date first then sequence number [~, isorted] = sort(cellsprintf('%.0d-%03i', expDate, expSeq)); + %remove duplicates that may exist in alternate repos + [~,ia] = unique(expRef); + isorted = intersect(isorted,ia,'stable'); expRef = expRef(isorted); expDate = expDate(isorted); expSeq = expSeq(isorted); end function [dates, seqs] = expsForDate(subjectPath, dateStr) - dateDirs = file.list(fullfile(subjectPath, dateStr), 'dirs'); - seqMatch = cell2mat(regexp(dateDirs, '(?\d+)', 'names')); + dateDirs = rmEmpty(file.list(fullfile(subjectPath, dateStr), 'dirs')); + seqMatch = cell2mat(regexp(cellflat(dateDirs), '(?\d+)', 'names')); if numel(seqMatch) > 0 seqs = str2double({seqMatch.seq}'); else diff --git a/+dat/listSubjects.m b/+dat/listSubjects.m index f3aff6ec..09d9f690 100644 --- a/+dat/listSubjects.m +++ b/+dat/listSubjects.m @@ -1,17 +1,17 @@ function subjects = listSubjects() %DAT.LISTSUBJECTS Lists recorded subjects % subjects = DAT.LISTSUBJECTS() Lists the experimental subjects present -% in experiment info repository ('expInfo'). +% in main experiment repository ('mainRepository'). +% +% See also ALYX.LISTSUBJECTS % % Part of Rigbox % 2013-03 CB created -% The master 'expInfo' repository is the reference for the existence of +% The master 'main' repository is the reference for the existence of % experiments, as given by the folder structure -expInfoPath = dat.reposPath('expInfo', 'master'); - -dirs = file.list(expInfoPath, 'dirs'); -subjects = setdiff(dirs, {'misc'}); %exclude the misc directory +mainPath = dat.reposPath('main', 'remote'); -end \ No newline at end of file +dirs = unique(cellflat(rmEmpty(file.list(mainPath, 'dirs')))); +subjects = dirs(~cellfun(@(d)startsWith(d, '@'), dirs)); % exclude misc directories \ No newline at end of file diff --git a/+dat/loadBlock.m b/+dat/loadBlock.m index 827a74ac..f2064383 100644 --- a/+dat/loadBlock.m +++ b/+dat/loadBlock.m @@ -1,8 +1,20 @@ function block = loadBlock(varargin) %DAT.LOADBLOCK Load the designated experiment block(s) % BLOCK = loadBlock(EXPREF, [EXPTYPE]) -% % BLOCK = loadBlock(SUBJECTREF, EXPDATE, EXPSEQ, [EXPTYPE]) +% +% Loads corresponding block files (if they exist) given one or more +% experiment references. If there are multiple remote 'main' +% repositories, all are searched precedence is given to experiments on +% the master repository. +% +% Can optionally filter by experiment type, returning only those blocks +% whose 'expType' field matches the input. NB: Filtering happens only +% after loading one block per unique experiment. +% +% See also DAT.EXPPARAMS, DAT.EXPFILEPATH +% +% Part of Rigbox if nargin == 2 || nargin == 4 %experiment type was specified in last argument, create a filter function @@ -16,18 +28,22 @@ end % get the full path for each experiments block -blockpath = dat.expFilePath(varargin{:}, 'block', 'master'); - -% the load function takes the path to a MAT-file containing an experiment -% block. If the file exists, it returns the block, if not, it returns an -% 'empty'. -loadFun = @(p) iff(exist(p, 'file'), @() loadVar(p, 'block'), []); +blockpath = dat.expFilePath(varargin{:}, 'block', 'remote'); if iscell(blockpath) - block = mapToCell(filterFun, mapToCell(loadFun, blockpath)); + block = mapToCell(filterFun, mapToCell(@loadFun, blockpath)); else block = filterFun(loadFun(blockpath)); end end +function block = loadFun(p) +% the load function takes the path to a MAT-file containing an experiment +% block. If the file exists, it returns the block, if not, it returns an +% 'empty'. If a list is provided, the first existing file (if any) is +% loaded and returned +p = ensureCell(p); +I = find(file.exists(p),1); +block = iff(isempty(I), [], @() loadVar(p{I}, 'block')); +end \ No newline at end of file diff --git a/+dat/loadParamProfiles.m b/+dat/loadParamProfiles.m index ee0b4c61..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 @@ -8,7 +26,7 @@ % 2017-02 MW Param struct now sorted in ASCII dictionary order fn = 'parameterProfiles.mat'; -masterPath = fullfile(dat.reposPath('expInfo', 'master'), fn); +masterPath = fullfile(dat.reposPath('main', 'master'), fn); p = struct; %default is to return an empty struct @@ -18,7 +36,8 @@ loaded = load(masterPath, expType); %load profiles for specific experiment type warning(origState); if isfield(loaded, expType) - p = orderfields(loaded.(expType)); %extract those profiles to return + [~, I] = sort(lower(fieldnames(loaded.(expType)))); + p = orderfields(loaded.(expType), I); %extract those profiles to return end end diff --git a/+dat/logPath.m b/+dat/logPath.m index 7141129b..8dc80527 100644 --- a/+dat/logPath.m +++ b/+dat/logPath.m @@ -10,8 +10,8 @@ %ensure the subject exists assert(dat.subjectExists(subject), 'Subject "%s" does not exist', subject); -% get path(s) to expInfo repository -reposPath = dat.reposPath('expInfo', varargin{:}); +% get path(s) to main repository +reposPath = dat.reposPath('main', varargin{:}); filename = sprintf('%s_log.mat', subject); subjectPath = file.mkPath(reposPath, subject); diff --git a/+dat/newExp.m b/+dat/newExp.m index 035d351e..cc17cc44 100644 --- a/+dat/newExp.m +++ b/+dat/newExp.m @@ -1,6 +1,17 @@ function [expRef, expSeq] = newExp(subject, expDate, expParams) %DAT.NEWEXP Create a new unique experiment in the database -% [ref, seq] = DAT.NEWEXP(subject, expDate, expParams) TODO +% [ref, seq] = DAT.NEWEXP(subject, expDate, expParams) +% Create a new experiment by creating the relevant folder tree in the +% local and main data repositories in the following format: +% +% subject/ +% |_ YYYY-MM-DD/ +% |_ expSeq/ +% +% If experiment parameters are passed into the function, they are saved +% here. +% +% See also DAT.PATHS % % Part of Rigbox @@ -23,14 +34,13 @@ % check the subject exists in the database exists = any(strcmp(dat.listSubjects, subject)); -assert(exists, sprintf('"%" does not exist', subject)); +assert(exists, sprintf('"%s" does not exist', subject)); % retrieve list of experiments for subject [~, dateList, seqList] = dat.listExps(subject); % filter the list by expdate -expDate = floor(expDate); -filterIdx = dateList == expDate; +filterIdx = dateList == floor(expDate); % find the next sequence number expSeq = max(seqList(filterIdx)) + 1; @@ -39,8 +49,8 @@ expSeq = 1; end -% expInfo repository is the reference location for which experiments exist -[expPath, expRef] = dat.expPath(subject, expDate, expSeq, 'expInfo'); +% main repository is the reference location for which experiments exist +[expPath, expRef] = dat.expPath(subject, floor(expDate), expSeq, 'main'); % ensure nothing went wrong in making a "unique" ref and path to hold assert(~any(file.exists(expPath)), ... sprintf('Something went wrong as experiment folders already exist for "%s".', expRef)); @@ -61,5 +71,14 @@ % now save the experiment parameters variable superSave(dat.expFilePath(expRef, 'parameters'), struct('parameters', expParams)); - +if ~isempty(expParams) + try + % Generate JSON path and save + jsonParams = obj2json(expParams); + jsonPath = dat.expFilePath(expRef, 'parameters', 'master', 'json'); + fid = fopen(jsonPath, 'w'); fprintf(fid, '%s', jsonParams); fclose(fid); + catch ex + warning(ex.identifier, 'Failed to save paramters as JSON: %s', ex.message) + end +end end \ No newline at end of file diff --git a/+dat/parseAlyxInstance.m b/+dat/parseAlyxInstance.m deleted file mode 100644 index 4be14277..00000000 --- a/+dat/parseAlyxInstance.m +++ /dev/null @@ -1,39 +0,0 @@ -function [ref, AlyxInstance] = parseAlyxInstance(varargin) -%DAT.PARSEALYXINSTANCE Converts input to string for UDP message and back -% [UDP_string] = DATA.PARSEALYXINSTANCE(ref, AlyxInstance) -% [ref, AlyxInstance] = DATA.PARSEALYXINSTANCE(UDP_string) -% -% The pattern for 'ref' should be '{date}_{seq#}_{subject}', with two -% date formats accepted, either 'yyyy-mm-dd' or 'yyyymmdd'. -% -% AlyxInstance should be a struct with the following fields, all -% containing strings: 'baseURL', 'token', 'username'[, 'subsessionURL']. -% -% Part of Rigbox - -% 2017-10 MW created - -if nargin > 1 % in [ref, AlyxInstance] - ref = varargin{1}; % extract expRef - ai = varargin{2}; % extract AlyxInstance struct - if isstruct(ai) % if there is an AlyxInstance - ai = orderfields(ai); % alphabetize fields - % remove water requirement remaining field - if isfield(ai, 'water_requirement_remaining') - ai = rmfield(ai, 'water_requirement_remaining'); - end - c = cellfun(@(fn) ai.(fn), fieldnames(ai), 'UniformOutput', false); % get fieldnames - ref = strjoin([ref; c],'\'); % join into single string for UDP, otherwise just output the expRef - end -else % in [UDP_string] - C = strsplit(varargin{1},'\'); % split string - ref = C{1}; % output expRef - if numel(C)>4 % if UDP string included AlyxInstance - AlyxInstance = struct('baseURL', C{2}, 'subsessionURL', C{3},... - 'token', C{4}, 'username', C{5}); % reconstruct AlyxInstance - elseif numel(C)>1 % if AlyxInstance has no subsession set - AlyxInstance = struct('baseURL', C{2}, 'token', C{3}, 'username', C{4}); % reconstruct AlyxInstance - else - AlyxInstance = []; % if input was just an expRef, output empty AlyxInstance - end -end \ No newline at end of file diff --git a/+dat/paths.m b/+dat/paths.m deleted file mode 100644 index 89539761..00000000 --- a/+dat/paths.m +++ /dev/null @@ -1,67 +0,0 @@ -function p = paths(rig) -%DAT.PATHS Returns struct containing important paths -% p = DAT.PATHS([RIG]) -% TODO: -% - Clean up expDefinitions directory -% Part of Rigbox - -% 2013-03 CB created - -thishost = hostname; - -if nargin < 1 || isempty(rig) - rig = thishost; -end - -server1Name = '\\zserver.cortexlab.net'; -% server2Name = '\\zserver2.cortexlab.net'; -% server3Name = '\\zserver3.cortexlab.net'; % 2017-02-18 MW - Currently -% unused by Rigbox -server4Name = '\\zserver4.cortexlab.net'; - -%% defaults -% path containing rigbox config folders -% p.rigbox = fullfile(server1Name, 'code', 'Rigging'); % Potential conflict with AddRigBoxPaths -p.rigbox = fileparts(which('addRigboxPaths')); -% Repository for local copy of everything generated on this rig -p.localRepository = 'C:\LocalExpData'; -% for all data types, under the new system of having data grouped by mouse -% rather than data type -p.mainRepository = fullfile(server1Name, 'Data2', 'Subjects'); -% Repository for info about experiments, i.e. stimulus, behavioural, -% Timeline etc -p.expInfoRepository = p.mainRepository; -% Repository for storing two photon movies -p.twoPhotonRepository = fullfile(server4Name, 'Data', '2P'); - -% for calcium widefield imaging -p.widefieldRepository = fullfile(server1Name, 'data', 'GCAMP'); -% Repository for storing eye tracking movies -p.eyeTrackingRepository = fullfile(server1Name, 'data', 'EyeCamera'); - -% electrophys repositories -p.lfpRepository = fullfile(server1Name, 'Data', 'Cerebus'); -p.spikesRepository = fullfile(server1Name, 'Data', 'multichanspikes'); -% directory for organisation-wide configuration files, for now these should -% all remain on zserver -% p.globalConfig = fullfile(p.rigbox, 'config'); -p.globalConfig = fullfile(server1Name, 'Code', 'Rigging', 'config'); -% directory for rig-specific configuration files -p.rigConfig = fullfile(p.globalConfig, rig); -% repository for all experiment definitions -p.expDefinitions = fullfile(server1Name, 'Code', 'Rigging', 'ExpDefinitions'); - -%% load rig-specific overrides from config file, if any -customPathsFile = fullfile(p.rigConfig, 'paths.mat'); -if file.exists(customPathsFile) - customPaths = loadVar(customPathsFile, 'paths'); - if isfield(customPaths, 'centralRepository') - % 'centralRepository' is deprecated, remove field, if any - customPaths = rmfield(customPaths, 'centralRepository'); - end - % merge paths structures, with precedence on the loaded custom paths - p = mergeStructs(customPaths, p); -end - - -end \ No newline at end of file diff --git a/+dat/reposPath.m b/+dat/reposPath.m index 28ed0532..13890103 100644 --- a/+dat/reposPath.m +++ b/+dat/reposPath.m @@ -4,20 +4,25 @@ % 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 "expInfo" repository: -% savePaths = DAT.REPOSPATH('expInfo') % 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 "expInfo" repository: -% loadPath = DAT.REPOSPATH('expInfo', 'master') % loadPath is a string +% 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 @@ -63,10 +68,20 @@ switch lower(location) case {'master' 'm'} p = paths.([name 'Repository']); + case {'remote' 'r'} + fn = fieldnames(paths); %FIXME The below code is verbose and ugly! + results = regexp(fn, ['(' name '|alt)(\d*)Repository$'], 'tokens'); + remoteRepos = fn(~emptyElems(results)); + matches = cellflat(rmEmpty(results)); + matches(emptyElems(matches)) = {'1'}; + [B,I] = sort(strcat(matches(1:2:end), matches(2:2:end))); + alt = startsWith(B,'alt'); + p = mapToCell(@(n) paths.(n), [remoteRepos(I(~alt)); remoteRepos(I(alt))]); + if numel(p) < 2; p = p{:}; end case {'local' 'l'} p = paths.localRepository; otherwise - error('"%s" is not a recognised repository location.', location{1}); + error('"%s" is not a recognised repository location.', location); end end \ No newline at end of file diff --git a/+dat/saveParamProfile.m b/+dat/saveParamProfile.m index 9ca7b011..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('expInfo'), 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/subjectExists.m b/+dat/subjectExists.m index 3ad31b0e..37799dfb 100644 --- a/+dat/subjectExists.m +++ b/+dat/subjectExists.m @@ -8,6 +8,6 @@ % 2013-03 CB created -b = file.exists(fullfile(dat.reposPath('expInfo', 'master'), ref)); +b = file.exists(fullfile(dat.reposPath('main', 'master'), ref)); end \ No newline at end of file diff --git a/+dat/updateLogEntry.m b/+dat/updateLogEntry.m index ad512209..a5128bd7 100644 --- a/+dat/updateLogEntry.m +++ b/+dat/updateLogEntry.m @@ -10,11 +10,15 @@ function updateLogEntry(subject, id, newEntry) % 2013-03 CB created -if isfield(newEntry, 'AlyxInstance')&&~isempty(newEntry.comments) - data = struct('subject', dat.parseExpRef(newEntry.value.ref),... - 'narrative', newEntry.comments); - alyx.putData(newEntry.AlyxInstance,... - newEntry.AlyxInstance.subsessionURL, data); +if isfield(newEntry, 'AlyxInstance') && ~isempty(getOr(dat.paths, 'databaseURL')) + % Update session narrative on Alyx + if ~isempty(newEntry.comments) && ~strcmp(subject, 'default') + try + newEntry.comments = newEntry.AlyxInstance.updateNarrative(newEntry.comments); + catch + warning('Alyx:updateNarrative:UploadFailed', 'Failed to update Alyx session narrative'); + end + end newEntry = rmfield(newEntry, 'AlyxInstance'); end diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 89006b20..429f0961 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -5,7 +5,7 @@ % % eui.AlyxPanel() opens a stand-alone GUI. eui.AlyxPanel(parent) % constructs the panel inside a parent object. - % + % % Use the login button to retrieve a token from the database. % Use the subject drop-down to select the subject. % Subject weights can be entered using the 'Manual weighing' button. @@ -24,7 +24,7 @@ % starting at tomorrow then the day after, etc. % The 'All WR subjects' button shows the amount of water remaining % today for all mice that are currently on water restriction. - % + % % The 'default' subject is for testing and is usually ignored. % % See also ALYX, EUI.MCONTROL @@ -32,23 +32,25 @@ % 2017-03 NS created % 2017-10 MW made into class properties (SetAccess = private) - AlyxInstance = []; % A struct containing the database URL, a token and the username of the who is logged in - QueuedWeights = {}; % Holds weighings until someone logs in, to be posted + AlyxInstance % An Alyx object to interfacing with the database SubjectList % List of active subjects from database Subject = 'default' % The name of the currently selected subject end - + properties (Access = private) LoggingDisplay % Control for showing log output RootContainer % Handle of the uix.Panel object named 'Alyx' NewExpSubject % Drop-down menu subject list LoginText % Text displaying whether/which user is logged in LoginButton % Button to log in to Alyx + WeightButton % Button to submit weight to Alyx WaterEntry % Text box for entering the amout of water to give - IsHydrogel % UI checkbox indicating whether to water to be given is in gel form + WaterType % UI checkbox indicating whether to water to be given is in gel form WaterRequiredText % Handle to text UI element displaying the water required WaterRemainingText % Handle to text UI element displaying the water remaining LoginTimer % Timer to keep track of how long the user has been logged in, when this expires the user is automatically logged out + WeightTimer % Timer to reset weight button text when scale no longer gives new readings + WaterRemaining % Holds the current water required for the selected subject end events (NotifyAccess = 'protected') @@ -57,20 +59,27 @@ end methods - function obj = AlyxPanel(parent) + function obj = AlyxPanel(parent, active) % Constructor to build all the UI elements and set callbacks to % the relevant functions. If a handle to parant UI object is % not specified, a seperate figure is created. An optional % handle to a logging display panal may be provided, otherwise - % one is created. + % one is created. If the active flag is set to false, the panel + % is inactive and the instance of Alyx will be set to headless. + % The panel defaults to active only if the databaseURL field is + % populated in the paths. + % + % See also Alyx + obj.AlyxInstance = Alyx('',''); if ~nargin % No parant object: create new figure f = figure('Name', 'alyx GUI',... 'MenuBar', 'none',... 'Toolbar', 'none',... 'NumberTitle', 'off',... 'Units', 'normalized',... - 'OuterPosition', [0.1 0.1 0.4 .4]); + 'OuterPosition', [0.1 0.1 0.4 .4],... + 'DeleteFcn', @(~,~)obj.delete); parent = uiextras.VBox('Parent', f,... 'Visible', 'on'); % subject selector @@ -84,6 +93,13 @@ obj.NewExpSubject.addlistener('SelectionChanged', @(src, evt)obj.dispWaterReq(src, evt)); end + % Check to see if there is a remote database url defined in + % the paths, if so activate AlyxPanel + if nargin < 2 + url = char(getOr(dat.paths, 'databaseURL', '')); + active = ~isempty(url); + end + obj.RootContainer = uix.Panel('Parent', parent, 'Title', 'Alyx'); alyxbox = uiextras.VBox('Parent', obj.RootContainer); @@ -98,6 +114,12 @@ 'Callback', @(~,~)obj.login); loginbox.Widths = [-1 75]; + % If active flag set as false, make Alyx headless + if ~active + obj.AlyxInstance.Headless = true; + set(obj.LoginButton, 'Enable', 'off') + end + waterReqbox = uix.HBox('Parent', alyxbox); obj.WaterRequiredText = bui.label('Log in to see water requirements', waterReqbox); % water required text % Button to refresh all data retrieved from Alyx @@ -122,24 +144,24 @@ 'Enable', 'off',... 'Callback', @(~,~)obj.viewAllSubjects); % Button to open a dialog for manually submitting a mouse weight - uicontrol('Parent', waterbox,... + obj.WeightButton = uicontrol('Parent', waterbox,... 'Style', 'pushbutton', ... 'String', 'Manual weighing', ... 'Enable', 'off',... 'Callback', @(~,~)obj.recordWeight); - % Button to launch dialog for submitting gel administrations + % Button to launch dialog for submitting water administrations % for future dates uicontrol('Parent', waterbox,... 'Style', 'pushbutton', ... - 'String', 'Give gel in future', ... + 'String', 'Give water in future', ... 'Enable', 'off',... - 'Callback', @(~,~)obj.giveFutureGel); - % Check box to indicate whether water was gel or liquid - obj.IsHydrogel = uicontrol('Parent', waterbox,... - 'Style', 'checkbox', ... - 'String', 'Hydrogel?', ... + 'Callback', @(~,~)obj.giveFutureWater); + % Dropdown to indicate water type (sucrose, gel, etc.) + obj.WaterType = uicontrol('Parent', waterbox,... + 'Style', 'popupmenu', ... + 'String', {'Water'}, ... 'HorizontalAlignment', 'right',... - 'Value', true, ... + 'Value', 1, ... 'Enable', 'off'); % Input for submitting amount of water obj.WaterEntry = uicontrol('Parent', waterbox,... @@ -165,7 +187,7 @@ 'Style', 'pushbutton', ... 'String', 'Launch webpage for Subject', ... 'Enable', 'off',... - 'Callback', @(~,~)obj.launchSubjectURL); + 'Callback', @(~,~)obj.launchSubjectURL); % Button for launching (and creating) a session for a given subject in the browser uicontrol('Parent', launchbox,... 'Style', 'pushbutton', ... @@ -194,80 +216,74 @@ function delete(obj) delete(obj.LoginTimer) % ... delete it... obj.LoginTimer = []; % ... and remove it end + if ~isempty(obj.WeightTimer) && isvalid(obj.WeightTimer) + stop(obj.WeightTimer) % Stop the timer... + delete(obj.WeightTimer) % ... delete it... + obj.WeightTimer = []; % ... and remove it + end end - function login(obj) + function login(obj, varargin) % Used both to log in and out of Alyx. Logging means to % generate an Alyx token with which to send/request data. % Logging out does not cause the token to expire, instead the % token is simply deleted from this object. + % Temporarily disable the Subject Selector + obj.NewExpSubject.UIControl.Enable = 'off'; + % Reset headless flag in case user wishes to retry connection + obj.AlyxInstance.Headless = false; % Are we logging in or out? - if isempty(obj.AlyxInstance) % logging in + if ~obj.AlyxInstance.IsLoggedIn % logging in % attempt login - [ai, username] = alyx.loginWindow(); % returns an instance if success, empty if you cancel - if ~isempty(ai) % successful - obj.AlyxInstance = ai; - obj.AlyxInstance.username = username; + obj.AlyxInstance = obj.AlyxInstance.login(varargin{:}); % returns an instance if success, empty if you cancel + if obj.AlyxInstance.IsLoggedIn % successful % Start log in timer, to automatically log out after 30 % minutes of 'inactivity' (defined as not calling % dispWaterReq) - obj.LoginTimer = timer('StartDelay', 30*60, 'TimerFcn', @(~,~)obj.login); + obj.LoginTimer = timer('StartDelay', 90*60, 'TimerFcn',... + @(~,~)obj.login, 'BusyMode', 'queue', 'Name', 'Login Timer'); start(obj.LoginTimer) % Enable all buttons set(findall(obj.RootContainer, '-property', 'Enable'), 'Enable', 'on'); - set(obj.LoginText, 'String', ['You are logged in as ', username]); % display which user is logged in + set(obj.LoginText, 'ForegroundColor', 'black',... + 'String', ['You are logged in as ', obj.AlyxInstance.User]); % display which user is logged in set(obj.LoginButton, 'String', 'Logout'); % try updating the subject selectors in other panels - s = alyx.getData(ai, 'subjects?stock=False&alive=True'); - - respUser = cellfun(@(x)x.responsible_user, s, 'uni', false); - subjNames = cellfun(@(x)x.nickname, s, 'uni', false); - - thisUserSubs = sort(subjNames(strcmp(respUser, username))); - otherUserSubs = sort(subjNames); - % note that we leave this User's mice also in - % otherUserSubs, in case they get confused and look - % there. - - newSubs = [{'default'}, thisUserSubs, otherUserSubs]; + newSubs = obj.AlyxInstance.listSubjects; obj.NewExpSubject.Option = newSubs; obj.SubjectList = newSubs; + % update water type list + wt = obj.AlyxInstance.getData('water-type'); + obj.WaterType.String = {wt.name}; + notify(obj, 'Connected'); % Notify listeners of login - obj.log('Logged into Alyx successfully as %s', username); - + obj.log('Logged into Alyx successfully as %s', obj.AlyxInstance.User); + % any database subjects that weren't in the old list of - % subjects will need a folder in expInfo. + % subjects will need a folder in the main repository. firstTimeSubs = newSubs(~ismember(newSubs, dat.listSubjects)); for fts = 1:length(firstTimeSubs) - thisDir = fullfile(dat.reposPath('expInfo', 'master'), firstTimeSubs{fts}); + thisDir = fullfile(dat.reposPath('main', 'master'), firstTimeSubs{fts}); if ~exist(thisDir, 'dir') - fprintf(1, 'making expInfo directory for %s\n', firstTimeSubs{fts}); + fprintf(1, 'making directory for %s\n', firstTimeSubs{fts}); mkdir(thisDir); end end - - % post any un-posted weighings - if ~isempty(obj.QueuedWeights) - try - for w = 1:length(obj.QueuedWeights) - d = obj.QueuedWeights{w}; - wobj = alyx.postData(obj.AlyxInstance, 'weighings/', d); - obj.log('Alyx weight posting succeeded: %.2f for %s', wobj.weight, wobj.subject); - end - obj.QueuedWeights = {}; - catch - obj.log('Failed to post stored weighings') - end - end + elseif obj.AlyxInstance.Headless + % Panel inactive or login failed due to Alyx being down + set(findall(obj.RootContainer, '-property', 'Enable'), 'Enable', 'on'); + set(obj.LoginText, 'ForegroundColor', [0.91, 0.41, 0.17],... + 'String', 'Unable to reach Alyx, posts to be queued'); + set(obj.LoginButton, 'String', 'Retry'); % Retry button + obj.log('Failed to reach Alyx server, please retry later'); else obj.log('Did not log into Alyx'); end else % logging out - obj.AlyxInstance = []; - obj.SubjectList = []; + obj.AlyxInstance = obj.AlyxInstance.logout; if ~isempty(obj.LoginTimer) % If there is a timer object stop(obj.LoginTimer) % Stop the timer... delete(obj.LoginTimer) % ... delete it... @@ -280,91 +296,69 @@ function login(obj) notify(obj, 'Disconnected'); % Notify listeners of logout obj.log('Logged out of Alyx'); end + % Reable the Subject Selector + obj.NewExpSubject.UIControl.Enable = 'on'; + obj.dispWaterReq() end function giveWater(obj) % Callback to the give water button. Posts the value entered % in the text box as either liquid or gel depending on the % state of the 'is hydrogel' check box - ai = obj.AlyxInstance; thisDate = now; amount = str2double(get(obj.WaterEntry, 'String')); - isHydrogel = logical(get(obj.IsHydrogel, 'Value')); - if ~isempty(ai)&&amount~=0&&~isnan(amount) - wa = alyx.postWater(ai, obj.Subject, amount, thisDate, isHydrogel); + type = obj.WaterType.String{obj.WaterType.Value}; + if obj.AlyxInstance.IsLoggedIn && amount~=0 && ~isnan(amount) + wa = obj.AlyxInstance.postWater(obj.Subject, amount, thisDate, type); if ~isempty(wa) % returned us a created water administration object successfully - wstr = iff(isHydrogel, 'Hydrogel', 'Water'); - obj.log('%s administration of %.2f for %s posted successfully to alyx', wstr, amount, obj.Subject); + obj.log('%s administration of %.2f for "%s" posted successfully to alyx', type, amount, obj.Subject); end end % update the water required text dispWaterReq(obj); end - function giveFutureGel(obj) + function giveFutureWater(obj) % Open a dialog allowing one to input water submissions for - % future dates - ai = obj.AlyxInstance; + % future dates. If a -1 is inputted for a particular date, the + % date is saved in the 'WeekendWater' struct of the + % paramProfiles file. This may be used to notify weekend staff + % of the experimentor's intent to train on that date. thisDate = now; - prompt=sprintf('Enter space-separated numbers \n[tomorrow, day after that, day after that.. etc] \nEnter 0 to skip a day'); - answer = inputdlg(prompt,'Future Gel Amounts', [1 50]); - if isempty(answer)||isempty(ai); return; end % user pressed 'Close' or 'x' - amount = str2num(answer{:}); %#ok - weekendDates = thisDate + (1:length(amount)); - for d = 1:length(weekendDates) - if amount(d) > 0 - alyx.postWater(ai, obj.Subject, amount(d), weekendDates(d), 1); - obj.log(['Hydrogel administration of %.2f for %s posted successfully to alyx for ' datestr(weekendDates(d))], amount(d), obj.Subject); - end - end - end - - function dispWaterReq(obj, src, ~) - % Display the amount of water required by the selected subject - % for it to reach its minimum requirement. This function is - % also used to update the selected subject, for example it is - % this funtion to use as a callback to subject dropdown - % listeners - ai = obj.AlyxInstance; - % Set the selected subject if it is an input - if nargin>2; obj.Subject = src.Selected; end - if isempty(ai) - set(obj.WaterRequiredText, 'String', 'Log in to see water requirements'); - return + waterType = obj.WaterType.String{obj.WaterType.Value}; + prompt = sprintf(['To post future ', strrep(lower(waterType), '%', '%%'), ', ',... + 'enter space-separated numbers, i.e. \n',... + '[tomorrow, day after that, day after that.. etc] \n\n',... + 'Enter "0" to skip a day\nEnter "-1" to indicate training for that day\n']); + amtStr = newid(prompt,'Future Amounts', [1 50]); + if isempty(amtStr)||~obj.AlyxInstance.IsLoggedIn + return % user pressed 'Close' or 'x' end - % Refresh the timer as the user isn't inactive - stop(obj.LoginTimer); start(obj.LoginTimer) - try - s = alyx.getData(ai, alyx.makeEndpoint(ai, ['subjects/' obj.Subject])); % struct with data about the subject - if s.water_requirement_total==0 - set(obj.WaterRequiredText, 'String', sprintf('Subject %s not on water restriction', obj.Subject)); - else - set(obj.WaterRequiredText, 'String', ... - sprintf('Subject %s requires %.2f of %.2f today', ... - obj.Subject, s.water_requirement_remaining, s.water_requirement_total)); - end - catch me - d = loadjson(me.message); - if isfield(d, 'detail') && strcmp(d.detail, 'Not found.') - set(obj.WaterRequiredText, 'String', sprintf('Subject %s not found in alyx', obj.Subject)); + amt = str2num(amtStr{:}); %#ok % amount of water + futDates = thisDate + (1:length(amt)); % datenum of all input future dates + + futTrnDates = futDates(amt < 0); % future training dates + if any(futTrnDates) + dat.saveParamProfile('WeekendWater', obj.Subject, futTrnDates); + [~,days] = weekday(futTrnDates, 'long'); + delim = iff(size(days,1) < 3, ' and ', {', ', ' and '}); + obj.log('%s marked for training on %s',... + obj.Subject, strjoin(strtrim(string(days)), delim)); + else % If no training dates given, delete from structure + try + dat.delParamProfile('WeekendWater', obj.Subject); + catch % Subject field may not exist is never marked for training end end - end - - function changeWaterText(obj, src, ~) - % Update the panel text to show the amount of water still - % required for the subject to reach its minimum requirement. - % This text is updated before the value in the water text box - % has been posted to Alyx. For example if the user is unsure - % how much gel over the minimum they have weighed out, pressing - % return will display this without posting to Alyx - % - % See also DISPWATERREQ, GIVEWATER - ai = obj.AlyxInstance; - if ~isempty(ai) && isfield(ai, 'water_requirement_remaining') && ~isempty(ai.water_requirement_remaining) - rem = ai.water_requirement_remaining; - curr = str2double(src.String); - set(obj.WaterRemainingText, 'String', sprintf('(%.2f)', rem-curr)); + + futWtrDates = futDates(amt > 0); % future water giving dates + amtWtrDates = amt(amt > 0); % amount of water to give on future water dates + + for d = 1:length(futWtrDates) + obj.AlyxInstance.postWater(obj.Subject, amtWtrDates(d), futWtrDates(d), waterType); + [~,day] = weekday(futWtrDates(d), 'long'); + obj.log('Water administration of %.2f for %s posted successfully to alyx for %s %s',... + amtWtrDates(d), obj.Subject, day, datestr(futWtrDates(d), 'dd mmm yyyy')); end end @@ -382,83 +376,80 @@ function recordWeight(obj, weight, subject) dlgTitle = 'Manual weight logging'; numLines = 1; defaultAns = {'',''}; - weight = inputdlg(prompt, dlgTitle, numLines, defaultAns); + weight = newid(prompt, dlgTitle, numLines, defaultAns); if isempty(weight); return; end end - % inputdlg returns weight as a cell, otherwise it may now be + % newid returns weight as a cell, otherwise it may now be weight = ensureCell(weight); % ensure it's a cell % convert to double if weight is a string weight = iff(ischar(weight{1}), str2double(weight{1}), weight{1}); - d.subject = subject; - d.weight = weight; - if isempty(ai) % if not logged in, save the weight for later - obj.QueuedWeights{end+1} = d; - obj.log('Warning: Weight not posted to Alyx; will be posted upon login.'); - else % otherwise immediately post to Alyx - try - w = alyx.postData(ai, 'weighings/', d); - obj.log('Alyx weight posting succeeded: %.2f for %s', w.weight, w.subject); - catch - obj.log('Warning: Alyx weight posting failed!'); + try + w = postWeight(ai, weight, subject); + obj.log('Alyx weight posting succeeded: %.2f for %s', w.weight, w.subject); + catch ex + if ~ai.IsLoggedIn % if not logged in, save the weight for later + obj.log('Warning: Weight not posted to Alyx; will be posted upon login.'); + else + obj.log('Warning: Alyx weight posting failed! %s', ex.message); end end + % Update weight and refresh login timer + obj.dispWaterReq end - function launchSessionURL(obj) - % Launch the Webpage for the current session in the default Web - % browser. If no session exists for today's date, a new base - % and/or subsession is created accordingly. - % TODO: Do we really want to create a session if one doesn't - % exist? + function [stat, url] = launchSessionURL(obj) + % Launch the Webpage for the current base session in the + % default Web browser. If no session exists for today's date, + % a new base session is created accordingly. + % + % Outputs: + % stat (double) - returns the status of the operation: + % 0 if successful, 1 or 2 if unsuccessful. + % url (char) - the url for the subject page + % + % See also LAUNCHSUBJECTURL ai = obj.AlyxInstance; % determine whether there is a session for this subj and date - thisDate = alyx.datestr(now); - sessions = alyx.getData(ai, ['sessions?type=Experiment&subject=' obj.Subject]); + thisDate = ai.datestr(now); + sessions = ai.getSessions('subject', obj.Subject, 'date', now); + stat = -1; url = []; - % If the date of this latest session is not the same date as - % today, then create a new session for today - if isempty(sessions) || ~strcmp(sessions{end}.start_time(1:10), thisDate(1:10)) + % If the date of this latest base session is not the same date + % as today, then create a new one for today + if isempty(sessions) % Ask user whether he/she wants to create new session % Construct a questdlg with three options - choice = questdlg('Would you like to create a new session?', ... - ['No session exists for ' datestr(now, 'yyyy-mm-dd')], ... + choice = questdlg('Would you like to create a new base session?', ... + ['No base session exists for ' datestr(now, 'yyyy-mmm-dd')], ... 'Yes','No','No'); % Handle response switch choice case 'Yes' - % Check if base session exists - baseSessions = alyx.getData(ai, ['sessions?type=Experiment&subject=' obj.Subject]); - if isempty(baseSessions) || ~strcmp(baseSessions{end}.start_time(1:10), thisDate(1:10)) - % Create our base session - d = struct; - d.subject = obj.Subject; - d.procedures = {'Behavior training/tasks'}; - d.narrative = 'auto-generated session'; - d.start_time = thisDate; - d.type = 'Base'; - - base_submit = alyx.postData(ai, 'sessions', d); - if ~isfield(base_submit,'subject') % fail - warning('Submitted base session did not return appropriate values'); - warning('Submitted data below:'); - disp(d) - warning('Return values below:'); - disp(base_submit) - return - else % success - obj.log(['Created new base session in Alyx for ' obj.Subject]); - end + % Create our base session + d = struct; + d.subject = obj.Subject; + d.procedures = {'Behavior training/tasks'}; + d.narrative = 'auto-generated session'; + d.start_time = thisDate; + d.type = 'Base'; + d.users = {obj.AlyxInstance.User}; + + thisSess = ai.postData('sessions', d); + if ~isfield(thisSess,'subject') % fail + warning('Submitted base session did not return appropriate values'); + warning('Submitted data below:'); + disp(d) + warning('Return values below:'); + disp(thisSess) + return + else % success + obj.log(['Created new base session in Alyx for ' obj.Subject]); end - % Now create a new SUBSESSION, using the same experiment number - % d = struct; - % d.subject = obj.Subject; - % d.start_time = alyx.datestr(now); - % d.users = {ai.username}; - case 'No' + otherwise return end else - thisSess = sessions{end}; + thisSess = sessions(end); end % parse the uuid from the url in the session object @@ -466,101 +457,133 @@ function launchSessionURL(obj) uuid = u(find(u=='/', 1, 'last')+1:end); % make the admin url - adminURL = fullfile(ai.baseURL, 'admin', 'actions', 'session', uuid, 'change'); + url = [ai.BaseURL, '/admin/actions/session/', uuid, '/change']; % launch the website - web(adminURL, '-browser'); + stat = web(url, '-browser'); end - function launchSubjectURL(obj) + function [stat, url] = launchSubjectURL(obj) + % LAUNCHSUBJECTURL Launch the Webpage for the current subject + % Launches Web page in the default Web browser. Note that the + % logged in state of the AlyxPanel is independent of the + % browser cookies, therefore you may need to log in to see the + % subject page. + % + % Outputs: + % stat (double) - returns the status of the operation: + % 0 if successful, 1 or 2 if unsuccessful. + % url (char) - the url for the subject page + % + % See also LAUNCHSESSIONURL ai = obj.AlyxInstance; - if ~isempty(ai) - s = alyx.getData(ai, alyx.makeEndpoint(ai, ['subjects/' obj.Subject])); - subjURL = fullfile(ai.baseURL, 'admin', 'subjects', 'subject', s.id, 'change'); % this is wrong - need uuid - web(subjURL, '-browser'); - end + s = ai.getData(ai.makeEndpoint(['subjects/' obj.Subject])); + url = sprintf('%s/admin/subjects/subject/%s/change', ai.BaseURL, s.id); + stat = web(url, '-browser'); end - function viewSubjectHistory(obj) + function viewSubjectHistory(obj, ax) + % View historical information about a subject. + % Opens a new window and plots a set of weight graphs as well + % as displaying a table with the water and weight entries for + % the selected subject. If an axes handle is provided, this + % function plots a single weight graph - ai = obj.AlyxInstance; - if ~isempty(ai)&&~strcmp(obj.Subject, 'default') - % collect the data for the table - endpnt = sprintf('water-requirement/%s?start_date=2016-01-01&end_date=%s', obj.Subject, datestr(now, 'yyyy-mm-dd')); - wr = alyx.getData(ai, endpnt); - - hydrogelAmts = cellfun(@(x)x.hydrogel_given, wr.records); - waterAmts = cellfun(@(x)x.water_given, wr.records); - waterExp = cellfun(@(x)x.water_expected, wr.records); - hasWeighing = cellfun(@(x)isfield(x, 'weight_measured'), wr.records); - weight = cellfun(@(x)x.weight_measured, wr.records(hasWeighing)); - weightExp = cellfun(@(x)x.weight_expected, wr.records); - dates = cellfun(@(x)datenum(x.date), wr.records); - - % build the figure to show it + % If not logged in or 'default' is selected, return + if ~obj.AlyxInstance.IsLoggedIn||strcmp(obj.Subject, 'default'); return; end + % collect the data for the table + wr = obj.AlyxInstance.getData(['water-requirement/', obj.Subject]); + iw = iff(isempty(wr.implant_weight), 0, wr.implant_weight); + records = catStructs(wr.records, nan); + % no weighings found + if isempty(wr.records) + obj.log('No weight data found for subject %s', obj.Subject); + return + end + weights = [records.weight]; + weights(isnan([records.weighing_at])) = nan; + expected = [records.expected_weight]; + expected(expected==0|isnan(weights)) = nan; + dates = cellfun(@(x)datenum(x), {records.date}); + + % build the figure to show it + if nargin==1 f = figure('Name', obj.Subject, 'NumberTitle', 'off'); % popup a new figure for this p = get(f, 'Position'); set(f, 'Position', [p(1) p(2) 1100 p(4)]); histbox = uix.HBox('Parent', f, 'BackgroundColor', 'w'); - plotBox = uix.VBox('Parent', histbox, 'BackgroundColor', 'w'); - ax = axes('Parent', plotBox); - plot(dates(hasWeighing), weight, '.-'); - hold on; - plot(dates, weightExp*0.7, 'r', 'LineWidth', 2.0); - plot(dates, weightExp*0.8, 'LineWidth', 2.0, 'Color', [244, 191, 66]/255); - box off; - xlim([min(dates) max(dates)]); + end + + plot(ax, dates, weights, '.-'); + hold(ax, 'on'); + plot(ax, dates, ((expected-iw)*0.7)+iw, 'r', 'LineWidth', 2.0); + plot(ax, dates, ((expected-iw)*0.8)+iw, 'LineWidth', 2.0, 'Color', [244, 191, 66]/255); + box(ax, 'off'); + % Change the plot x axis limits + maxDate = max(dates([records.is_water_restricted]|~isnan(weights))); + if numel(dates) > 1 && ~isempty(maxDate) && min(dates) ~= maxDate + xlim(ax, [min(dates) maxDate]) + else + maxDate = now; + end + if nargin == 1 set(ax, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false)) - ylabel('weight (g)'); - - + else + xticks(ax, 'auto') + ax.XTickLabel = arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false); + end + ylabel(ax, 'weight (g)'); + + if nargin==1 ax = axes('Parent', plotBox); - plot(dates(hasWeighing), weight./weightExp(hasWeighing), '.-'); - hold on; - plot(dates, 0.7*ones(size(dates)), 'r', 'LineWidth', 2.0); - plot(dates, 0.8*ones(size(dates)), 'LineWidth', 2.0, 'Color', [244, 191, 66]/255); - box off; - xlim([min(dates) max(dates)]); + plot(ax, dates, (weights-iw)./(expected-iw), '.-'); + hold(ax, 'on'); + plot(ax, dates, 0.7*ones(size(dates)), 'r', 'LineWidth', 2.0); + plot(ax, dates, 0.8*ones(size(dates)), 'LineWidth', 2.0, 'Color', [244, 191, 66]/255); + box(ax, 'off'); + xlim(ax, [min(dates) maxDate]); set(ax, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false)) - ylabel('weight as pct (%)'); + ylabel(ax, 'weight as pct (%)'); axWater = axes('Parent',plotBox); - plot(dates, waterAmts+hydrogelAmts, '.-'); - hold on; - plot(dates, hydrogelAmts, '.-'); - plot(dates, waterAmts, '.-'); - plot(dates, waterExp, 'r', 'LineWidth', 2.0); - box off; - xlim([min(dates) max(dates)]); + plot(axWater, dates, obj.round([records.given_water_total], 'up'), '.-'); + hold(axWater, 'on'); + plot(axWater, dates, obj.round([records.given_water_supplement], 'down'), '.-'); + plot(axWater, dates, obj.round([records.given_water_reward], 'down'), '.-'); + plot(axWater, dates, obj.round([records.expected_water], 'up'), 'r', 'LineWidth', 2.0); + box(axWater, 'off'); + xlim(axWater, [min(dates) maxDate]); set(axWater, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(axWater, 'XTick'), 'uni', false)) - ylabel('water/hydrogel (mL)'); - + ylabel(axWater, 'water (mL)'); + % Create table of useful weight and water information, + % sorted by date histTable = uitable('Parent', histbox,... 'FontName', 'Consolas',... 'RowName', []); - - weightsByDate = cell([length(dates),1]); - weightsByDate(hasWeighing) = num2cell(weight); + weightsByDate = num2cell(weights); weightsByDate = cellfun(@(x)sprintf('%.1f', x), weightsByDate, 'uni', false); - weightPctByDate = cell([length(dates),1]); - weightPctByDate(hasWeighing) = num2cell(weight./weightExp(hasWeighing)); + weightsByDate(isnan(weights)) = {[]}; + weightPctByDate = num2cell((weights-iw)./(expected-iw)); weightPctByDate = cellfun(@(x)sprintf('%.1f', x*100), weightPctByDate, 'uni', false); + weightPctByDate(isnan(weights)|~[records.is_water_restricted]) = {[]}; dat = horzcat(... arrayfun(@(x)datestr(x), dates', 'uni', false), ... - weightsByDate, ... - arrayfun(@(x)sprintf('%.1f', 0.8*x), weightExp', 'uni', false), ... - weightPctByDate); + weightsByDate', ... + arrayfun(@(x)iff(isnan(x), [], @()sprintf('%.1f', 0.8*(x-iw)+iw)), expected', 'uni', false), ... + weightPctByDate'); waterDat = (... - num2cell(horzcat(waterAmts', hydrogelAmts', ... - waterAmts'+hydrogelAmts', waterExp', waterAmts'+hydrogelAmts'-waterExp'))); + num2cell(horzcat([records.given_water_reward]', [records.given_water_supplement]', ... + [records.given_water_total]', [records.expected_water]',... + [records.given_water_total]'-[records.expected_water]'))); waterDat = cellfun(@(x)sprintf('%.2f', x), waterDat, 'uni', false); + waterDat(~[records.is_water_restricted],[1,3]) = {'ad lib'}; dat = horzcat(dat, waterDat); - set(histTable, 'ColumnName', {'date', 'meas. weight', '80% weight', 'weight pct', 'water', 'hydrogel', 'total', 'min water', 'excess'}, ... + set(histTable, 'ColumnName', {'date', 'meas. weight', '80% weight', 'weight pct', 'water', 'supplement', 'total', 'min water', 'excess'}, ... 'Data', dat(end:-1:1,:),... 'ColumnEditable', false(1,5)); histbox.Widths = [ -1 725]; @@ -569,16 +592,14 @@ function viewSubjectHistory(obj) function viewAllSubjects(obj) ai = obj.AlyxInstance; - if ~isempty(ai) - - wr = alyx.getData(ai, alyx.makeEndpoint(ai, 'water-restricted-subjects')); - - subjs = cellfun(@(x)x.nickname, wr, 'uni', false); - waterReqTotal = cellfun(@(x)x.water_requirement_total, wr, 'uni', false); - waterReqRemain = cellfun(@(x)x.water_requirement_remaining, wr, 'uni', false); + if ai.IsLoggedIn + wr = ai.getData(ai.makeEndpoint('water-restricted-subjects')); % build a figure to show it - f = figure; % popup a new figure for this + f = figure('Name', 'All Water Restricted Subjects', 'NumberTitle', 'off'); % popup a new figure for this + p = get(f, 'Position'); + set(f, 'Position', [p(1) p(2) 295, p(4)]); + wrBox = uix.VBox('Parent', f); wrTable = uitable('Parent', wrBox,... 'FontName', 'Consolas',... @@ -588,16 +609,126 @@ function viewAllSubjects(obj) % colorgen = @(colorNum,text) ['
',text,'
']; colorgen = @(colorNum,text) ['',text,'']; - wrdat = cellfun(@(x)colorgen(1-double(x>0)*[0 0.3 0.3], sprintf('%.2f',x)), waterReqRemain, 'uni', false); + wrdat = cellfun(@(x)colorgen(1-double(x>0)*[0 0.3 0.3],... + sprintf('%.2f',obj.round(x, 'up'))), {wr.remaining_water}, 'uni', false); set(wrTable, 'ColumnName', {'Name', 'Water Required', 'Remaining Requirement'}, ... - 'Data', horzcat(subjs', ... - cellfun(@(x)sprintf('%.2f',x),waterReqTotal', 'uni', false), ... + 'Data', horzcat({wr.nickname}', ... + cellfun(@(x)sprintf('%.2f',obj.round(x, 'up')),{wr.expected_water}', 'uni', false), ... wrdat'), ... 'ColumnEditable', false(1,3)); end end + function dispWaterReq(obj, src, ~) + % Display the amount of water required by the selected subject + % for it to reach its minimum requirement. This function is + % also used to update the selected subject, for example it is + % this funtion to use as a callback to subject dropdown + % listeners + ai = obj.AlyxInstance; + % Set the selected subject if it is an input + if nargin>1; obj.Subject = src.Selected; end + if ~ai.IsLoggedIn + set(obj.WaterRequiredText, 'ForegroundColor', 'black',... + 'String', 'Log in to see water requirements'); + return + end + % Refresh the timer as the user isn't inactive + stop(obj.LoginTimer); start(obj.LoginTimer) + try + s = ai.getData('water-restricted-subjects'); % struct with data about restricted subjects + idx = strcmp(obj.Subject, {s.nickname}); + if ~any(idx) % Subject not on water restriction + set(obj.WaterRequiredText, 'ForegroundColor', 'black',... + 'String', sprintf('Subject %s not on water restriction', obj.Subject)); + else + % Get information on weight and water given + endpnt = sprintf('water-requirement/%s?start_date=%s&end_date=%s',... + obj.Subject, datestr(now, 'yyyy-mm-dd'),datestr(now, 'yyyy-mm-dd')); + wr = ai.getData(endpnt); % Get today's weight and water record + if ~isempty(wr.records) + record = wr.records(end); + else + record = struct(); + end + weight = iff(isempty(record.weighing_at), NaN, record.weight); % Get today's measured weight + water = getOr(record, 'given_water_total', 0); % Get total water given + expected_weight = getOr(record, 'expected_weight', NaN); + % Set colour based on weight percentage + weight_pct = (weight-wr.implant_weight)/(expected_weight-wr.implant_weight); + if weight_pct < 0.7 % Mouse below 70% original weight + colour = 'red'; + weight_pct = '< 70%'; + elseif weight_pct < 0.8 % Mouse below 80% original weight + colour = [0.91, 0.41, 0.17]; % Orange + weight_pct = '< 80%'; + else + colour = 'black'; % Mouse above 80% or no weight measured today + weight_pct = '> 80%'; + end + % Round up water remaining to the near 0.01 + remainder = obj.round(s(idx).remaining_water, 'up'); + % Set text + set(obj.WaterRequiredText, 'ForegroundColor', colour, 'String', ... + sprintf(['Subject %s requires %.2f of %.2f today\n\t '... + 'Weight today: %.2f (%s) Water today: %.2f'], obj.Subject, ... + remainder, obj.round(s(idx).expected_water, 'up'), weight, ... + weight_pct, obj.round(water, 'down'))); + % Set WaterRemaining attribute for changeWaterText callback + obj.WaterRemaining = remainder; + end + catch me + d = me.message; + if isfield(d, 'detail') && strcmp(d.detail, 'Not found.') + set(obj.WaterRequiredText, 'ForegroundColor', 'black',... + 'String', sprintf('Subject %s not found in alyx', obj.Subject)); + else + rethrow(me) + end + end + end + + function updateWeightButton(obj, src, ~) + % Function for changing the text on the weight button to reflect the + % current weight value obtained by the scale. This function must be + % a callback for the hw.WeighingScale NewReading event. If a new + % reading isn't read for 10 sec the manual weighing option is made + % available instead. + % + % Example: + % aiPanel = eui.AlyxPanel; + % lh = event.listener(obj.WeighingScale, 'NewReading',... + % @(src,evt)aiPanel.updateWeightButton(src,evt)); + % + % See also hw.WeighingScale, eui.MControl + set(obj.WeightButton, 'String', sprintf('Record %.1fg', src.readGrams), 'Callback', @(~,~)obj.recordWeight(src.readGrams)) + obj.WeightTimer = timer('Name', 'Last Weight',... + 'TimerFcn', @(~,~)set(obj.WeightButton, 'String', 'Manual weighing', 'Callback', @(~,~)obj.recordWeight),... + 'StopFcn', @(src,~)delete(src), 'StartDelay', 10); + start(obj.WeightTimer) + end + + end + + methods (Access = protected) + + function changeWaterText(obj, src, ~) + % Update the panel text to show the amount of water still + % required for the subject to reach its minimum requirement. + % This text is updated before the value in the water text box + % has been posted to Alyx. For example if the user is unsure + % how much gel over the minimum they have weighed out, pressing + % return will display this without posting to Alyx + % + % See also DISPWATERREQ, GIVEWATER + if obj.AlyxInstance.IsLoggedIn && ~isempty(obj.WaterRemaining) + rem = obj.WaterRemaining; + curr = str2double(src.String); + set(obj.WaterRemainingText, 'String', sprintf('(%.2f)', rem-curr)); + end + end + function log(obj, varargin) % Function for displaying timestamped information about % occurrences. If the LoggingDisplay property is unset, the @@ -609,12 +740,43 @@ function log(obj, varargin) if ~isempty(obj.LoggingDisplay) 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); + current = cellflat(get(obj.LoggingDisplay, 'String')); + %NB: If more that one instance of MATLAB is open, we use + %the last opened LoggingDisplay + set(obj.LoggingDisplay(end), 'String', [current; str], 'Value', numel(current) + 1); else fprintf(message) end end end -end \ No newline at end of file + methods (Static) + function A = round(a, direction, N) + % ROUND Rounds a value a up or down to the nearest N s.f. + % Rounds a value in the specified direction to the nearest N + % significant figures. The default behaviour is the same as + % MATLAB's builtin round function, that is to round to the + % nearest value. + % + % Examples: + % eui.AlyxPanel.round(0.8437, 'up') % 0.85 + % eui.AlyxPanel.round(12.65, 'up', 3) % 12.6 + % eui.AlyxPanel.round(12.6, 'down') % 12 + % + % See also ROUND + if nargin < 2; direction = 'nearest'; end + if nargin < 3; N = 2; end + c = 10.^(N-ceil(log10(abs(a)))); + c(c==Inf) = 0; + switch direction + case 'up' + A = ceil(a.*c)./c; + case 'down' + A = floor(a.*c)./c; + otherwise + A = round(a, N, 'significant'); + end + A(a == 0) = 0; + end + end +end 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 new file mode 100644 index 00000000..277211f1 --- /dev/null +++ b/+eui/ConditionPanel.m @@ -0,0 +1,337 @@ +classdef ConditionPanel < handle + %CONDITIONPANEL Deals with formatting trial conditions UI table + % Designed to be an element of the EUI.PARAMEDITOR class that manages + % the UI elements associated with all Conditional parameters. + % TODO Add set condition idx + + properties + % Handle to UI Table that represents trial conditions + ConditionTable + % Minimum UI Panel width allowed. See also EUI.PARAMEDITOR/ONRESIZE + MinWidth = 80 + % Handle to parent UI container + UIPanel + % Handle to UI container for buttons + ButtonPanel + % Handles to context menu items + ContextMenus + end + + properties (Access = protected) + % Handle to EUI.PARAMEDITOR object + ParamEditor + % UIControl button for adding a new trial condition (row) to the table + NewConditionButton + % UIControl button for deleting trial conditions (rows) from the table + DeleteConditionButton + % UIControl button for making conditional parameter (column) global + MakeGlobalButton + % UIControl button for setting multiple table cells at once + SetValuesButton + % Indicies of selected table cells as array [row, column;...] of each + % selected cell + SelectedCells + end + + methods + function obj = ConditionPanel(f, ParamEditor, varargin) + % FIELDPANEL Panel UI for Conditional parameters + % Input f may be a figure or other UI container object + % ParamEditor is a handle to an eui.ParamEditor object. + % + % See also EUI.PARAMEDITOR, EUI.FIELDPANEL + obj.ParamEditor = ParamEditor; + obj.UIPanel = uix.VBox('Parent', f); + % Create a child menu for the uiContextMenus. The input arg is the + % figure holding the panel + c = uicontextmenu(ParamEditor.Root); + obj.UIPanel.UIContextMenu = c; + obj.ContextMenus = uimenu(c, 'Label', 'Make Global', ... + 'MenuSelectedFcn', @(~,~)obj.makeGlobal, 'Enable', 'off'); + fcn = @(s,~)obj.ParamEditor.setRandomized(~strcmp(s.Checked, 'on')); + obj.ContextMenus(2) = uimenu(c, 'Label', 'Randomize conditions', ... + 'MenuSelectedFcn', fcn, 'Checked', 'on', 'Tag', 'randomize button'); + obj.ContextMenus(3) = uimenu(c, 'Label', 'Sort by selected column', ... + 'MenuSelectedFcn', @(~,~)obj.sortByColumn, ... + 'Tag', 'sort by', 'Enable', 'off'); + % Create condition table + p = uix.Panel('Parent', obj.UIPanel, 'BorderType', 'none'); + obj.ConditionTable = uitable('Parent', p,... + 'FontName', 'Consolas',... + 'RowName', [],... + 'RearrangeableColumns', 'on',... + 'Units', 'normalized',... + 'Position',[0 0 1 1],... + 'UIContextMenu', c,... + 'CellEditCallback', @obj.onEdit,... + 'CellSelectionCallback', @obj.onSelect); + % Create button panel to hold condition control buttons + obj.ButtonPanel = uix.HBox('Parent', obj.UIPanel); + % Define some common properties + props.Style = 'pushbutton'; + props.Units = 'normalized'; + props.Parent = obj.ButtonPanel; + % Create out four buttons + obj.NewConditionButton = uicontrol(props,... + 'String', 'New condition',... + 'TooltipString', 'Add a new condition',... + 'Callback', @(~, ~) obj.newCondition()); + obj.DeleteConditionButton = uicontrol(props,... + 'String', 'Delete condition',... + 'TooltipString', 'Delete the selected condition',... + 'Enable', 'off',... + 'Callback', @(~, ~) obj.deleteSelectedConditions()); + obj.MakeGlobalButton = uicontrol(props,... + 'String', 'Globalise parameter',... + 'TooltipString', sprintf(['Make the selected condition-specific parameter global (i.e. not vary by trial)\n'... + 'This will move it to the global parameters section']),... + 'Enable', 'off',... + 'Callback', @(~, ~) obj.makeGlobal()); + obj.SetValuesButton = uicontrol(props,... + 'String', 'Set values',... + 'TooltipString', 'Set selected values to specified value, range or function',... + 'Enable', 'off',... + 'Callback', @(~, ~) obj.setSelectedValues()); + obj.ButtonPanel.Widths = [-1 -1 -1 -1]; + obj.UIPanel.Heights = [-1 25]; + end + + function onEdit(obj, src, eventData) + % ONEDIT Callback for edits to condition table + % Updates the underlying parameter struct, changes the UI table + % data. The src object should be the UI Table that has been edited, + % and eventData contains the table indices of the edited cell. + % + % See also FILLCONDITIONTABLE, EUI.PARAMEDITOR/UPDATE + row = eventData.Indices(1); + col = eventData.Indices(2); + 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); + reformed = obj.ParamEditor.paramValue2Control(newValue); + % If successful update the cell with default formatting + data = get(src, 'Data'); + if iscell(reformed) + % The reformed data type is a cell, this should be a one element + % wrapping cell + if numel(reformed) == 1 + reformed = reformed{1}; + else + error('Cannot handle data reformatted data type'); + end + end + data{row,col} = reformed; + set(src, 'Data', data); + end + + function clear(obj) + % CLEAR Clear all table data + % Clears all trial condition data from UI Table + % + % See also EUI.PARAMEDITOR/BUILDUI, EUI.PARAMEDITOR/CLEAR + set(obj.ConditionTable, 'ColumnName', [], ... + 'Data', [], 'ColumnEditable', false); + end + + function delete(obj) + % DELETE Deletes the UI container + % Called when this object or its parent ParamEditor is deleted + % See also CLEAR + delete(obj.UIPanel); + end + + function onSelect(obj, ~, eventData) + % ONSELECT Callback for when table cells are (de-)selected + % If at least one cell is selected, ensure buttons and menu items + % are enabled, otherwise disable them. + if nargin > 2; obj.SelectedCells = eventData.Indices; end + controls = ... + [obj.MakeGlobalButton, ... + obj.DeleteConditionButton, ... + obj.SetValuesButton, ... + obj.ContextMenus([1,3])]; + set(controls, 'Enable', iff(size(obj.SelectedCells, 1) > 0, 'on', 'off')); + end + + function makeGlobal(obj) + % MAKEGLOBAL Make condition parameter (table column) global + % Find all selected columns are turn into global parameters, using + % the value of the first selected cell as the global parameter + % value. + % + % See also eui.ParamEditor/globaliseParamAtCell + if isempty(obj.SelectedCells) + disp('nothing selected') + return + end + PE = obj.ParamEditor; + [cols, iu] = unique(obj.SelectedCells(:,2)); + names = PE.Parameters.TrialSpecificNames(cols); + rows = num2cell(obj.SelectedCells(iu,1)); %get rows of unique selected cols + cellfun(@PE.globaliseParamAtCell, names, rows); + % If only numRepeats remains, globalise it + if isequal(PE.Parameters.TrialSpecificNames, {'numRepeats'}) + PE.Parameters.Struct.numRepeats(1,1) = sum(PE.Parameters.Struct.numRepeats); + PE.globaliseParamAtCell('numRepeats', 1) + end + end + + function deleteSelectedConditions(obj) + %DELETESELECTEDCONDITIONS Removes the selected conditions from table + % The callback for the 'Delete condition' button. This removes the + % selected conditions from the table and if less than two conditions + % remain, globalizes them. + % + % See also EXP.PARAMETERS, GLOBALISESELECTEDPARAMETERS + rows = unique(obj.SelectedCells(:,1)); + names = obj.ConditionTable.ColumnName; + numConditions = size(obj.ConditionTable.Data,1); + % If the number of remaining conditions is 1 or less... + if numConditions-length(rows) <= 1 + remainingIdx = find(all(1:numConditions~=rows,1)); + if isempty(remainingIdx); remainingIdx = 1; end + % change selected cells to be all fields (except numRepeats which + % is assumed to always be the last column) + obj.SelectedCells =[ones(length(names),1)*remainingIdx, (1:length(names))']; + %... globalize them + obj.makeGlobal; + else % Otherwise delete the selected conditions as usual + obj.ParamEditor.Parameters.removeConditions(rows); + notify(obj.ParamEditor, 'Changed') + end + % Refresh the table of conditions + obj.fillConditionTable(); + end + + function setSelectedValues(obj) + % SETSELECTEDVALUES Set multiple fields in conditional table at once + % Generates an input dialog for setting multiple trial conditions at + % once. Also allows the use of function handles for more complex + % values. + % + % Examples: + % (1:10:100) % Sets selected rows to [1 11 21 31 41 51 61 71 81 91] + % @(~)randi(100) % Assigned random integer to each selected row + % @(a)a*50 % Multiplies each condition value by 50 + % false % Sets all selected rows to false + % + % See also SETNEWVALS, ONEDIT + PE = obj.ParamEditor; + cols = obj.SelectedCells(:,2); % selected columns + uCol = unique(obj.SelectedCells(:,2)); + rows = obj.SelectedCells(:,1); % selected rows + % get current values of selected cells + currVals = arrayfun(@(u)obj.ConditionTable.Data(rows(cols==u),u), uCol, 'UniformOutput', 0); + names = PE.Parameters.TrialSpecificNames(uCol); % selected column names + promt = cellfun(@(a,b) [a ' (' num2str(sum(cols==b)) ')'],... + names, num2cell(uCol), 'UniformOutput', 0); % names of columns & num selected rows + defaultans = cellfun(@(c) c(1), currVals); + answer = inputdlg(promt,'Set values', 1, cellflat(defaultans)); % prompt for input + if isempty(answer) % if user presses cancel + return + end + % set values for each column + cellfun(@(a,b,c) setNewVals(a,b,c), answer, currVals, names, 'UniformOutput', 0); + function newVals = setNewVals(userIn, currVals, paramName) + % check array orientation + currVals = iff(size(currVals,1)>size(currVals,2),currVals',currVals); + if strStartsWith(userIn,'@') % anon function + func_h = str2func(userIn); + % apply function to each cell + currVals = cellfun(@str2double,currVals, 'UniformOutput', 0); % convert from char + newVals = cellfun(func_h, currVals, 'UniformOutput', 0); + elseif any(userIn==':') % array syntax + arr = eval(userIn); + newVals = num2cell(arr); % convert to cell array + elseif any(userIn==','|userIn==';') % 2D arrays + C = strsplit(userIn, ';'); + newVals = cellfun(@(c)textscan(c, '%f',... + 'ReturnOnError', false,... + 'delimiter', {' ', ','}, 'MultipleDelimsAsOne', 1),... + C); + else % single value to copy across all cells + userIn = str2double(userIn); + newVals = num2cell(ones(size(currVals))*userIn); + end + + if length(newVals)>length(currVals) % too many new values + newVals = newVals(1:length(currVals)); % truncate new array + elseif length(newVals)1; amount = amount(1); end % Take first element (second being laser) - otherwise - return - end - if ~any(amount); return; end % Return if no water was given - try - alyx.postWater(ai, subject, amount*0.001, now, false); - catch - warning('Failed to post the water %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' @@ -321,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, ~) @@ -345,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 @@ -361,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'); @@ -391,10 +576,12 @@ function build(obj, parent) obj.StopButtons = [... uicontrol('Parent', buttonpanel,... 'Style', 'pushbutton',... - 'String', 'End'),... + 'String', 'End',... + 'TooltipString', 'End experiment'),... uicontrol('Parent', buttonpanel,... 'Style', 'pushbutton',... - 'String', 'Abort')]; + 'String', 'Abort',... + 'TooltipString', 'Abort experiment without posting water to Alyx')]; set(obj.StopButtons, 'Enable', 'off', 'Visible', 'off'); uicontrol('Parent', buttonpanel,... 'Style', 'pushbutton',... diff --git a/+eui/FieldPanel.m b/+eui/FieldPanel.m new file mode 100644 index 00000000..a409af1d --- /dev/null +++ b/+eui/FieldPanel.m @@ -0,0 +1,226 @@ +classdef FieldPanel < handle + %FIELDPANEL Deals with formatting global parameter UI elements + % Designed to be an element of the EUI.PARAMEDITOR class that manages + % the UI elements associated with all Global parameters. + + properties + % Minimum allowable width (in pixels) for each UIControl element + MinCtrlWidth = 40 + % Maximum allowable width (in pixels) for each UIControl element + MaxCtrlWidth = 140 + % Space (in pixels) between parent container and parameter fields + Margin = 4 + % Space (in pixels) between each parameter field row + RowSpacing = 1 + % Space (in pixels) between each parameter field column + ColSpacing = 3 + % Handle to parent UI container + UIPanel + % Handles to context menu option for making a parameter conditional + ContextMenu + end + + properties (Access = ?eui.ParamEditor) + % Handle to EUI.PARAMEDITOR object + ParamEditor + % Minimum height (in pixels) of each field row. See ONRESIZE + MinRowHeight + % Listener handle for when parent container is resized + Listener + % Array of UIControl labels + Labels + % Array of UIControl elements. Either 'edit' or 'checkbox' controls + Controls + % Array widths, one for each label in Labels. See ONRESIZE + LabelWidths + end + + methods + function obj = FieldPanel(f, ParamEditor) + % FIELDPANEL Panel UI for Global parameters + % Input f may be a figure or other UI container object + % ParamEditor is a handle to an eui.ParamEditor object. + % + % See also EUI.PARAMEDITOR, EUI.CONDITIONPANEL + obj.ParamEditor = ParamEditor; + p = uix.Panel('Parent', f, 'BorderType', 'none'); + obj.UIPanel = uipanel('Parent', p, 'BorderType', 'none',... + 'Position', [0 0 0.5 1]); + obj.Listener = event.listener(obj.UIPanel, 'SizeChanged', @obj.onResize); + end + + function [label, ctrl] = addField(obj, name, type) + % ADDFIELD Adds a new field label and input control + % Adds a label and control element for representing Global + % parameters. The input name should be identical to a parameter + % fieldname. Type is an optional input specifying the style of + % uicontrol (default 'edit'). From this the label string title is + % derived using exp.Parameters/title. Callbacks are added for the + % context menu and for edits + % + % See also ONEDIT, EXP.PARAMETERS/TITLE, EUI.PARAMEDITOR/BUILDUI + if nargin < 3; type = 'edit'; end + if isempty(obj.ContextMenu) + obj.ContextMenu = uicontextmenu(obj.ParamEditor.Root); + uimenu(obj.ContextMenu, 'Label', 'Make Conditional', ... + 'MenuSelectedFcn', @(~,~)obj.makeConditional); + end + props.TooltipString = obj.ParamEditor.Parameters.description(name); + props.HorizontalAlignment = 'left'; + props.UIContextMenu = obj.ContextMenu; + props.Parent = obj.UIPanel; + props.Tag = name; + title = obj.ParamEditor.Parameters.title(name); + label = uicontrol('Style', 'text', 'String', title, props); + ctrl = uicontrol('Style', type, props); + callback = @(src,~)onEdit(obj, src, name); + set(ctrl, 'Callback', callback); + obj.Labels = [obj.Labels; label]; + obj.Controls = [obj.Controls; ctrl]; + end + + function onEdit(obj, src, id) + % ONEDIT Callback for edits to field controls + % Updates the underlying parameter struct, changes the UI + % value/string and changes the label colour to red. The src object + % should be the edit or checkbox ui control that has been edited, + % and id is the unformatted parameter name (stored in the Tag + % property of the label and control elements). + % + % See also ADDFIELD, EUI.PARAMEDITOR/UPDATE + switch get(src, 'style') + case 'checkbox' + newValue = logical(get(src, 'value')); + obj.ParamEditor.update(id, newValue); + case 'edit' + % if successful update the control with default formatting and + % modified colour + newValue = obj.ParamEditor.update(id, get(src, 'string')); + set(src, 'String', obj.ParamEditor.paramValue2Control(newValue)); + end + changed = strcmp(src.Tag,{obj.Labels.Tag}); + obj.Labels(changed).ForegroundColor = [1 0 0]; + end + + function clear(obj, idx) + % CLEAR Delete a parameter field + % Deletes the label and control elements at index idx. If no index + % given, all controls are deleted. + if nargin == 1 + idx = true(size(obj.Labels)); + end + delete(obj.Labels(idx)) + delete(obj.Controls(idx)) + obj.Labels(idx) = []; + obj.LabelWidths(idx) = []; + obj.Controls(idx) = []; + end + + function makeConditional(obj, name) + % MAKECONDITIONAL Make field parameter into a trial condition + % This function removes the selected field from the global UI panel + % and calls Condition UI to add a column to the trial condition + % table. It also makes a change to the ParamEditor's Parameters via + % the makeTrialSpecific method. + % + % While this function can be called with a parameter name, the + % FieldPanel object is normally a protected property of the + % ParamEditor class, and the only calls to this function are via the + % context menu callback function + % + % See also eui.Parameters/makeTrialSpecific, eui.ConditionPanel/fillConditionTable + if nargin == 1 + selected = obj.ParamEditor.getSelected(); + if isa(selected, 'matlab.ui.control.UIControl') && ... + strcmp(selected.Style, 'text') + name = selected.Tag; + else % Assume control + name = obj.Labels([obj.Controls]==selected).Tag; + end + end + idx = strcmp(name,{obj.Labels.Tag}); + assert(~ismember(name, {'randomiseConditions'}), ... + '%s can not be made a conditional parameter', name) + + obj.clear(idx); + % FIXME The below code could be in a makeConditional method of + % eui.ParamEditor, thus more clearly separating class functionality: + % Editing the exp.Parameters object directly should only be done by + % ParamEditor. This would also make subclassing these panel classes + % more straightforward + obj.ParamEditor.Parameters.makeTrialSpecific(name); + obj.ParamEditor.ConditionalUI.fillConditionTable(); + notify(obj.ParamEditor, 'Changed'); + obj.onResize; + end + + function delete(obj) + % DELETE Deletes the UI container + % Called when this object or its parent ParamEditor is deleted + % See also CLEAR + delete(obj.UIPanel); + end + + function onResize(obj, ~, ~) + % ONRESIZE Re-position field UI elements after container resize + % Calculates the positions all field labels and input controls. + % These are organised into rows and columns that maximize use of + % space. + % + % See also EUI.PARAMEDITOR/ONRESIZE + if isempty(obj.Controls) + return + end + if isempty(obj.LabelWidths) || numel(obj.LabelWidths) ~= numel(obj.Labels) + ext = reshape([obj.Labels.Extent], 4, [])'; + obj.LabelWidths = ext(:,3); + l = uicontrol('Parent', obj.UIPanel, 'Style', 'edit', 'String', 'something'); + obj.MinRowHeight = l.Extent(4); + delete(l); + end + +% %%% resize condition table +% w = numel(obj.ConditionTable.ColumnName); +% % nCols = max(cols); +% % globalWidth = (fullColWidth * nCols) + borderwidth; +% if w > 5; w = 0.5; else; w = 0.1 * w; end +% obj.UI(2).Position = [1-w 0 w 1]; +% obj.UI(1).Position = [0 0 1-w 1]; + + %%% general coordinates + pos = getpixelposition(obj.UIPanel); + borderwidth = obj.Margin; + bounds = [pos(3) pos(4)] - 2*borderwidth; + n = numel(obj.Labels); + vspace = obj.RowSpacing; + hspace = obj.ColSpacing; + rowHeight = obj.MinRowHeight + 2*vspace; + rowsPerCol = floor(bounds(2)/rowHeight); + cols = ceil((1:n)/rowsPerCol)'; + ncols = cols(end); + rows = mod(0:n - 1, rowsPerCol)' + 1; + labelColWidth = max(obj.LabelWidths) + 2*hspace; + ctrlWidthAvail = bounds(1)/ncols - labelColWidth; + ctrlColWidth = max(obj.MinCtrlWidth, min(ctrlWidthAvail, obj.MaxCtrlWidth)); + fullColWidth = labelColWidth + ctrlColWidth; + + %%% coordinates of labels + by = bounds(2) - rows*rowHeight + vspace + 1 + borderwidth; + labelPos = [vspace + (cols - 1)*fullColWidth + 1 + borderwidth... + by... + obj.LabelWidths... + repmat(rowHeight - 2*vspace, n, 1)]; + + %%% coordinates of edits + editPos = [labelColWidth + hspace + (cols - 1)*fullColWidth + 1 + borderwidth ... + by... + repmat(ctrlColWidth - 2*hspace, n, 1)... + repmat(rowHeight - 2*vspace, n, 1)]; + set(obj.Labels, {'Position'}, num2cell(labelPos, 2)); + set(obj.Controls, {'Position'}, num2cell(editPos, 2)); + + end + end + +end + diff --git a/+eui/Log.m b/+eui/Log.m index 3172fa76..2821326f 100644 --- a/+eui/Log.m +++ b/+eui/Log.m @@ -110,50 +110,6 @@ function buildUI(obj, parent) 'RowName', [],... 'ColumnWidth', obj.columnWidths,... 'CellSelectionCallback', @obj.cellSelected); - -% obj.Table = uitable('Style', 'popupmenu', 'Enable', 'on',... -% 'String', {''},... -% 'Callback', @(src, evt) obj.showStack(get(src, 'Value')),... -% 'Parent', vbox); -% -% % set up the axes for displaying current frame image -% obj.Axes = bui.Axes(vbox); -% obj.Axes.ActivePositionProperty = 'Position'; -% obj.Image = imagesc(0, 'Parent', obj.Axes.Handle); -% obj.Axes.XTickLabel = []; -% obj.Axes.YTickLabel = []; -% obj.Axes.DataAspectRatio = [1 1 1]; -% -% % configure handling mouse events over axes to update selector cursor -% obj.Axes.addlistener('MouseLeft',... -% @(src, evt) handleMouseLeft(obj)); -% obj.Axes.addlistener('MouseMoved', @(src, evt) handleMouseMovement(obj, evt)); -% obj.Axes.addlistener('MouseButtonDown', @(src, evt) handleMouseDown(obj, evt)); -% obj.Axes.addlistener('MouseDragged', @(src, evt) handleMouseDragged(obj, evt)); -% -% bottombox = uiextras.HBox('Parent', vbox, 'Padding', 1); -% -% obj.PlayButton = uicontrol('String', '|>',... -% 'Callback', @(src, evt) obj.playStack(),... -% 'Parent', topbox); -% obj.StopButton = uicontrol('String', '||',... -% 'Callback', @(src, evt) obj.stopStack(),... -% 'Enable', 'off',... -% 'Parent', topbox); -% obj.SpeedMenu = uicontrol('Style', 'popupmenu', 'Enable', 'on',... -% 'String', {'', '', '', '', ''},... -% 'Value', find(obj.PlaySpeed == 1, 1),... -% 'Parent', topbox,... -% 'Callback', @(s,e) obj.updatePlayStep()); -% -% obj.FrameSlider = uicontrol('Style', 'slider', 'Enable', 'off',... -% 'Parent', bottombox,... -% 'Callback', @(src, ~) obj.showFrame(get(src, 'Value'))); -% obj.StatusText = uicontrol('Style', 'edit', 'String', '', ..., -% 'Enable', 'inactive', 'Parent', bottombox); -% set(vbox, 'Sizes', [24 -1 24]); -% set(topbox, 'Sizes', [-1 24 24 58]); -% set(bottombox, 'Sizes', [-1 160]); end end diff --git a/+eui/MControl.m b/+eui/MControl.m index c9125e8a..d71ad8f7 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -6,10 +6,6 @@ % - improve it. % - ensure all Parent objects specified explicitly (See GUI Layout % Toolbox 2.3.1/layoutdoc/Getting_Started2.html) - % - Do PrePostExpDelayEdits still store handles now it's moved to new - % dialog? - % - Tidy Options dialog - % - Comment rigOptions function % See also MC, EUI.ALYXPANEL, EUI.EXPPANEL, EUI.LOG, EUI.PARAMEDITOR % % Part of Rigbox @@ -24,6 +20,7 @@ end properties (SetAccess = private) + AlyxPanel % holds the AlyxPanel object (see buildUI(), eui.AlyxPanel()) LogSubject % Subject selector control NewExpSubject % Experiment selector control NewExpType % Experiment type selector control @@ -37,7 +34,6 @@ properties (Access = private) ParamEditor ParamPanel - AlyxPanel % holds the AlyxPanel object (see buildUI(), eui.AlyxPanel()) BeginExpButton % The 'Start' button that begins an experiment RigOptionsButton % The 'Options' button that opens the rig options dialog NewExpFactory % A struct containing all availiable experiment types and function handles to constructors for their default parameters @@ -92,10 +88,13 @@ addlistener(obj.AlyxPanel, 'Disconnected', @obj.expSubjectChanged); try if isfield(rig, 'scale') && ~isempty(rig.scale) - obj.WeighingScale = fieldOrDefault(rig, 'scale'); + obj.WeighingScale = getOr(rig, 'scale'); init(obj.WeighingScale); + % Add listners for new reading, both for the log tab and also for + % the weigh button in the Alyx Panel. obj.Listeners = [obj.Listeners,... - {event.listener(obj.WeighingScale, 'NewReading', @obj.newScalesReading)}]; + {event.listener(obj.WeighingScale, 'NewReading', @obj.newScalesReading)}... + {event.listener(obj.WeighingScale, 'NewReading', @(src,evt)obj.AlyxPanel.updateWeightButton(src,evt))}]; end catch obj.log('Warning: could not connect to weighing scales'); @@ -119,6 +118,16 @@ function newScalesReading(obj, ~, ~) end end + function tabChanged(obj) + % Function to change which subject Alyx uses when user changes tab + if ~obj.AlyxPanel.AlyxInstance.IsLoggedIn; return; end + if obj.TabPanel.SelectedChild == 1 % Log tab + obj.AlyxPanel.dispWaterReq(obj.LogSubject); + else % SelectedChild == 2 Experiment tab + obj.AlyxPanel.dispWaterReq(obj.NewExpSubject); + end + end + function expSubjectChanged(obj, ~, src) % Function deals with subject dropdown list changes switch src.EventName @@ -141,8 +150,8 @@ function expSubjectChanged(obj, ~, src) end obj.AlyxPanel.dispWaterReq(obj.NewExpSubject); case 'Disconnected' % user logged out of Alyx - obj.NewExpSubject.Option = dat.listSubjects; - obj.LogSubject.Option = dat.listSubjects; + obj.NewExpSubject.Option = unique([{'default'}; dat.listSubjects]); + obj.LogSubject.Option = unique([{'default'}; dat.listSubjects]); end end @@ -192,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 @@ -231,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); @@ -238,10 +244,10 @@ function saveParamProfile(obj) % Called by 'Save...' button press, save a new pa end function loadParamProfile(obj, profile) + set(obj.ParamProfileLabel, 'String', 'loading...', 'ForegroundColor', [1 0 0]); % Red 'Loading...' while new set loads if ~isempty(obj.ParamEditor) - %delete existing parameters control - delete(obj.ParamEditor); - set(obj.ParamProfileLabel, 'String', 'loading...', 'ForegroundColor', [1 0 0]); % Red 'Loading...' while new set loads + % Clear existing parameters control + clear(obj.ParamEditor) end factory = obj.NewExpFactory; % Find which 'world' we are in @@ -254,6 +260,7 @@ function loadParamProfile(obj, profile) matchTypes = factory(strcmp({factory.label}, typeName)).matchTypes(); subject = obj.NewExpSubject.Selected; % Find which subject is selected label = 'none'; + set(obj.BeginExpButton, 'Enable', 'off') % Can't run experiment without params! switch lower(profile) case '' % if strcmp(obj.NewExpType.Selected, '') @@ -295,13 +302,23 @@ function loadParamProfile(obj, profile) paramStruct = rmfield(paramStruct, 'services'); end obj.Parameters.Struct = paramStruct; - if ~isempty(paramStruct) % Now parameters are loaded, pass to ParamEditor for display, etc. + if isempty(paramStruct); return; end + % Now parameters are loaded, pass to ParamEditor for display, etc. + if isempty(obj.ParamEditor) obj.ParamEditor = eui.ParamEditor(obj.Parameters, obj.ParamPanel); % Build parameter list in Global panel by calling eui.ParamEditor - obj.ParamEditor.addlistener('Changed', @(src,~) obj.paramChanged); + else + obj.ParamEditor.buildUI(obj.Parameters); + end + obj.ParamEditor.addlistener('Changed', @(src,~) obj.paramChanged); + if strcmp(obj.RemoteRigs.Selected.Status, 'idle') + set(obj.BeginExpButton, 'Enable', 'on') % Re-enable start button end end function paramChanged(obj) + % PARAMCHANGED Indicate to user that parameters have been updated + % Changes the label above the ParamEditor indicating that the + % parameters have been edited s = get(obj.ParamProfileLabel, 'String'); if ~strEndsWith(s, '[EDITED]') set(obj.ParamProfileLabel, 'String', [s ' ' '[EDITED]'], 'ForegroundColor', [1 0 0]); @@ -322,22 +339,27 @@ function rigExpStarted(obj, rig, evt) % Announce that the experiment has started function rigExpStopped(obj, rig, evt) % Announce that the experiment has stopped in the log box obj.log('''%s'' on ''%s'' stopped', evt.Ref, rig.Name); if rig == obj.RemoteRigs.Selected - set([obj.BeginExpButton obj.RigOptionsButton], 'Enable', 'on'); % Re-enable 'Start' button so a new experiment can be started on that rig + set(obj.RigOptionsButton, 'Enable', 'on'); % Enable 'Options' + end + if obj.Parameters.Struct~=nil % If params loaded + set(obj.BeginExpButton, 'Enable', 'on'); % Re-enable 'Start' button so a new experiment can be started on that rig end % Alyx water reporting: indicate amount of water this mouse still needs - if ~isempty(rig.AlyxInstance) + if rig.AlyxInstance.IsLoggedIn try subject = dat.parseExpRef(evt.Ref); - sd = alyx.getData(rig.AlyxInstance, ... - sprintf('subjects/%s', subject)); + sd = rig.AlyxInstance.getData(sprintf('subjects/%s', subject)); obj.log('Water requirement remaining for %s: %.2f (%.2f already given)', ... - subject, sd.water_requirement_remaining, ... - sd.water_requirement_total-sd.water_requirement_remaining); + subject, sd.remaining_water, ... + sd.expected_water-sd.remaining_water); catch subject = dat.parseExpRef(evt.Ref); obj.log('Warning: unable to query Alyx about %s''s water requirements', subject); end - rig.AlyxInstance = []; % remove AlyxInstance from rig; no longer required + % Remove AlyxInstance from rig; no longer required +% delete(rig.AlyxInstance); % Line invalid now Alyx no longer +% handel class + rig.AlyxInstance = []; end end @@ -357,20 +379,30 @@ function rigConnected(obj, rig, ~) % See also REMOTERIGCHANGED, SRV.STIMULUSCONTROL, EUI.EXPPANEL % If rig is connected check no experiments are running... - expRef = rig.ExpRunnning; % returns expRef if 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 @@ -382,7 +414,10 @@ function rigConnected(obj, rig, ~) else % The rig is idle... obj.log('Connected to ''%s''', rig.Name); % ...say so in the log box if rig == obj.RemoteRigs.Selected - set([obj.BeginExpButton obj.RigOptionsButton], 'Enable', 'on'); % Enable 'Start' button + set(obj.RigOptionsButton, 'Enable', 'on'); % Enable 'Options' button + end + if obj.Parameters.Struct~=nil % If parameters loaded + set(obj.BeginExpButton, 'Enable', 'on'); end end end @@ -433,7 +468,10 @@ function remoteRigChanged(obj) obj.log('Could not connect to ''%s'' (%s)', rig.Name, errmsg); end elseif strcmp(rig.Status, 'idle') - set([obj.BeginExpButton obj.RigOptionsButton], 'Enable', 'on'); + set(obj.RigOptionsButton, 'Enable', 'on'); + if obj.Parameters.Struct~=nil + set(obj.BeginExpButton, 'Enable', 'on'); + end else obj.rigConnected(rig); end @@ -515,17 +553,25 @@ function beginExp(obj) % (i.e. no Alyx token is set), the user is prompted to log in and % the token is stored in the rig object so that EXPPANEL can % later post any events to Alyx (for example the amount of water - % received during the task). + % received during the task). An Alyx Experiment and, if required, Base + % session are also created here. % % See also SRV.STIMULUSCONTROL, EUI.EXPPANEL, EUI.ALYXPANEL set([obj.BeginExpButton obj.RigOptionsButton], 'Enable', 'off'); % Grey out buttons rig = obj.RemoteRigs.Selected; % Find which rig is selected + if strcmpi(rig.Status, 'running') + obj.log('Failed because another experiment is running'); + return; + end % Save the current instance of Alyx so that eui.ExpPanel can register water to the correct account - if isempty(obj.AlyxPanel.AlyxInstance)&&~strcmp(obj.NewExpSubject.Selected,'default') + if ~obj.AlyxPanel.AlyxInstance.IsLoggedIn &&... + ~strcmp(obj.NewExpSubject.Selected,'default') &&... + ~obj.AlyxPanel.AlyxInstance.Headless try obj.AlyxPanel.login(); + assert(obj.AlyxPanel.AlyxInstance.IsLoggedIn||obj.AlyxPanel.AlyxInstance.Headless); catch - log('Warning: Must be logged in to Alyx before running an experiment') + obj.log('Warning: Must be logged in to Alyx before running an experiment') return end end @@ -533,64 +579,14 @@ function beginExp(obj) services = rig.Services(rig.SelectedServices); % Add these services to the parameters obj.Parameters.set('services', services(:),... - 'List of experiment services to use during the experiment'); - [expRef, seq] = dat.newExp(obj.NewExpSubject.Selected, now, obj.Parameters.Struct); % Create new experiment reference - % Set up new session on Alyx - if ~isempty(obj.AlyxPanel.AlyxInstance)&&~strcmp(obj.NewExpSubject.Selected,'default') - %Find/create BASE session, then create subsession - thisDate = alyx.datestr(now); - sessions = alyx.getData(obj.AlyxPanel.AlyxInstance,... - ['sessions?type=Base&subject=' obj.NewExpSubject.Selected]); - - %If the date of this latest base session is not the same date as - %today, then create a new base session for today - if isempty(sessions) || ~strcmp(sessions{end}.start_time(1:10), thisDate(1:10)) - d = struct; - d.subject = obj.NewExpSubject.Selected; - d.procedures = {'Behavior training/tasks'}; - d.narrative = 'auto-generated session'; - d.start_time = thisDate; - d.type = 'Base'; - - base_submit = alyx.postData(obj.AlyxPanel.AlyxInstance, 'sessions', d); - if ~isfield(base_submit,'subject') - warning('Submitted base session did not return appropriate values'); - warning('Submitted data below:'); - disp(d) - warning('Return values below:'); - disp(base_submit) - return - else - obj.log(['Created new base session in Alyx for ' obj.NewExpSubject.Selected]); - end - end - - %Now retrieve the sessions again - sessions = alyx.getData(obj.AlyxPanel.AlyxInstance,... - ['sessions?type=Base&subject=' obj.NewExpSubject.Selected]); - latest_base = sessions{end}; - - %Now create a new SUBSESSION, using the same experiment number - d = struct; - d.subject = obj.NewExpSubject.Selected; - d.procedures = {'Behavior training/tasks'}; - d.narrative = 'auto-generated session'; - d.start_time = thisDate; - d.type = 'Experiment'; - d.parent_session = latest_base.url; - d.number = seq; - - subsession = alyx.postData(obj.AlyxPanel.AlyxInstance, 'sessions', d); - if ~isfield(subsession,'subject') - obj.log(['Failed to create new sub-session in Alyx for ' obj.NewExpSubject.Selected]); - disp(d) - end - obj.log(['Created new sub-session in Alyx for ', obj.NewExpSubject.Selected]); - % Add a copy of the AlyxInstance to the rig object for later - % water registration, &c. - rig.AlyxInstance = obj.AlyxPanel.AlyxInstance; - rig.AlyxInstance.subsessionURL = subsession.url; - end + 'List of experiment services to use during the experiment'); + % Create new experiment reference + [expRef, ~, url] = obj.AlyxPanel.AlyxInstance.newExp(... + obj.NewExpSubject.Selected, now, obj.Parameters.Struct); + % Add a copy of the AlyxInstance to the rig object for later + % water registration, &c. + rig.AlyxInstance = obj.AlyxPanel.AlyxInstance; + rig.AlyxInstance.SessionURL = url; panel = eui.ExpPanel.live(obj.ActiveExpsGrid, expRef, rig, obj.Parameters.Struct); obj.LastExpPanel = panel; @@ -608,19 +604,24 @@ function updateWeightPlot(obj) entries = obj.Log.entriesByType('weight-grams'); datenums = floor([entries.date]); obj.WeightAxes.clear(); - if numel(datenums) > 0 - obj.WeightAxes.plot(datenums, [entries.value], '-o'); - dateticks = min(datenums):floor(now); - set(obj.WeightAxes.Handle, 'XTick', dateticks); - obj.WeightAxes.XTickLabel = datestr(dateticks, 'dd-mm'); - obj.WeightAxes.yLabel('Weight (g)'); - xl = [min(datenums) floor(now)]; - if diff(xl) <= 0 - xl(1) = xl(2) - 0.5; - xl(2) = xl(2) + 0.5; - end - obj.WeightAxes.XLim = xl; + if obj.AlyxPanel.AlyxInstance.IsLoggedIn && ~strcmp(obj.LogSubject.Selected,'default') + obj.AlyxPanel.viewSubjectHistory(obj.WeightAxes.Handle) rotateticklabel(obj.WeightAxes.Handle, 45); + else + if numel(datenums) > 0 + obj.WeightAxes.plot(datenums, [entries.value], '-o'); + dateticks = min(datenums):floor(now); + set(obj.WeightAxes.Handle, 'XTick', dateticks); + obj.WeightAxes.XTickLabel = datestr(dateticks, 'dd-mm'); + obj.WeightAxes.yLabel('Weight (g)'); + xl = [min(datenums) floor(now)]; + if diff(xl) <= 0 + xl(1) = xl(2) - 0.5; + xl(2) = xl(2) + 0.5; + end + obj.WeightAxes.XLim = xl; + rotateticklabel(obj.WeightAxes.Handle, 45); + end end end @@ -637,6 +638,7 @@ function plotWeightReading(obj) MinSignificantWeight = 5; %grams if g >= MinSignificantWeight obj.WeightReadingPlot = obj.WeightAxes.scatter(floor(now), g, 20^2, 'p', 'filled'); + obj.WeightAxes.XLim = [min(get(obj.WeightAxes.Handle, 'XTick')) max(now)]; set(obj.RecordWeightButton, 'Enable', 'on', 'String', sprintf('Record %.1fg', g)); end end @@ -696,7 +698,7 @@ function buildUI(obj, parent) % Parent here is the MC window (figure) hbox = uiextras.HBox('Parent', logbox, 'Padding', 5); % container for 'Subject' text and dropdown box bui.label('Subject', hbox); % 'Subject' text next to dropdown box, Child of hbox - obj.LogSubject = bui.Selector(hbox, dat.listSubjects); % Subject dropdown box, Child of hbox + obj.LogSubject = bui.Selector(hbox, unique([{'default'}; dat.listSubjects])); % Subject dropdown box, Child of hbox hbox.Sizes = [50 100]; % resize label and dropdown to be 50px and 100px respectively obj.LogTabs = uiextras.TabPanel('Parent', logbox, 'Padding', 5); % Container for 'Entries' and 'Weights' tab in log obj.Log = eui.Log(obj.LogTabs); % Entries window, all delt with by +eui/Log.m @@ -737,7 +739,7 @@ function buildUI(obj, parent) % Parent here is the MC window (figure) topgrid = uiextras.Grid('Parent', leftSideBox); % grid for containing everything within the tab subjectLabel = bui.label('Subject', topgrid); % 'Subject' label bui.label('Type', topgrid); % 'Type' label - obj.NewExpSubject = bui.Selector(topgrid, dat.listSubjects); % Subject dropdown box + obj.NewExpSubject = bui.Selector(topgrid, unique([{'default'}; dat.listSubjects])); % Subject dropdown box set(subjectLabel, 'FontSize', 11); % Make 'Subject' label larger set(obj.NewExpSubject.UIControl, 'FontSize', 11); % Make dropdown box text larger obj.NewExpSubject.addlistener('SelectionChanged', @obj.expSubjectChanged); % Add listener for subject selection @@ -770,6 +772,7 @@ function buildUI(obj, parent) % Parent here is the MC window (figure) % Create the Alyx panel obj.AlyxPanel = eui.AlyxPanel(headerBox); addlistener(obj.NewExpSubject, 'SelectionChanged', @(src, evt)obj.AlyxPanel.dispWaterReq(src, evt)); + addlistener(obj.LogSubject, 'SelectionChanged', @(src, evt)obj.AlyxPanel.dispWaterReq(src, evt)); % a titled panel for the parameters editor param = uiextras.Panel('Parent', newExpBox, 'Title', 'Parameters', 'Padding', 5); @@ -810,6 +813,7 @@ function buildUI(obj, parent) % Parent here is the MC window (figure) obj.TabPanel.SelectedChild = 2; obj.ExpTabs.TabNames = {'New' 'Current'}; obj.ExpTabs.SelectedChild = 1; + obj.TabPanel.SelectionChangedFcn = @(~,~)obj.tabChanged; end end 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 a513ad6a..83fd57e9 100644 --- a/+eui/ParamEditor.m +++ b/+eui/ParamEditor.m @@ -1,157 +1,184 @@ classdef ParamEditor < handle - %EUI.PARAMEDITOR UI control for configuring experiment parameters - % TODO. See also EXP.PARAMETERS. + %PARAMEDITOR GUI for visualizing and editing experiment parameters + % ParamEditor deals with setting the paramters via a GUI. In general + % this class is involved in constructing a UI comprising a Global UI + % panel, handled by the EUI.FIELDPANEL class, and a Condition table, + % handled by the EUI.CONDITIONPANEL class. It is also responsible for + % updating the underlying EXP.PARAMETERS object and notifying + % downstream listeners of parameter changes. % - % Part of Rigbox - - % 2012-11 CB created - % 2017-03 MW/NS Made global panel scrollable & improved performance of - % buildGlobalUI. - % 2017-03 MW Added set values button + % See also EUI.FIELDPANEL, EUI.CONDITIONPANEL properties - GlobalVSpacing = 20 + % An exp.Parameters object, which keeps track of all parameter changes Parameters end - properties (Dependent) - Enable + properties (Access = {?eui.ConditionPanel, ?eui.FieldPanel}) + % Handle to the EUI.FIELDPANEL object, which manages the display of the + % Global parameters + GlobalUI + % Handle to the EUI.CONDITIONPANEL object, which manages the display of + % the trial conditions within a ui table + ConditionalUI + % Handle to the parent container for the ParamEditor. If constructor + % called with no parent input, then this will be a figure handle, the + % same as Root + Parent + % Handle to the figure within which the ParamEditor is displayed + Root + % A listener for changes to the figure size. See also ONRESIZE + Listener end - properties (Access = private) - Root - GlobalGrid - ConditionTable - TableColumnParamNames = {} - NewConditionButton - DeleteConditionButton - MakeGlobalButton - SetValuesButton - SelectedCells %[row, column;...] of each selected cell - GlobalControls + properties (Dependent) + % Flag for making editor read only by disabling all UI controls + Enable end events + % Event notified each time a user makes an edit to a parameter Changed end methods - function obj = ParamEditor(params, parent) - if nargin < 2 % Can call this function to display parameters is new window + function obj = ParamEditor(pars, parent) + % PARAMEDITOR GUI for visualizing and editing experiment parameters + % The input pars is expected to be an instance of the exp.Parameters + % class. Parent is a handle to a parent figure or UI Panel. If no + % parent is given, the editor is created in a new figure. + % + % See also EUI.FIELDPANEL, EUI.CONDITIONPANEL + if nargin == 0; pars = []; end + if nargin < 2 parent = figure('Name', 'Parameters', 'NumberTitle', 'off',... - 'Toolbar', 'none', 'Menubar', 'none'); + 'Toolbar', 'none', 'Menubar', 'none', 'DeleteFcn', @(~,~)obj.delete); end - obj.Parameters = params; - obj.build(parent); + obj.Root = ancestor(parent, 'Figure'); +% obj.Listener = event.listener(parent, 'SizeChanged', @(~,~)obj.onResize); + obj.Parent = uix.HBox('Parent', parent); + obj.GlobalUI = eui.FieldPanel(obj.Parent, obj); + if nargin == 2; obj.GlobalUI.Margin = 14; end % FIXME Add as generic input name-value pair + obj.ConditionalUI = eui.ConditionPanel(obj.Parent, obj); + obj.buildUI(pars); + % FIXME Current hack for drawing params first time + pos = obj.Root.Position; + obj.Root.Position = pos+0.01; + obj.Root.Position = pos; end - function delete(obj) - disp('ParamEditor destructor called'); - if obj.Root.isvalid - obj.Root.delete(); - end + function selected = getSelected(obj) + % GETSELECTED Return the object currently in focus + % Returns handle to the object currently in focus in the figure, + % that is, the object last clicked on by the user. This is used by + % the FieldPanel context menu to determine which parameter was + % selected. + % + % See also EUI.FIELDPANEL + selected = obj.Root.CurrentObject; end - function value = get.Enable(obj) - value = obj.Root.Enable; + function delete(obj) + % DELETE Deletes all panels + % Called when the ParamEditor object is deleted or its parent figure + % is closed. Deletes all UI elements and data. + % See also CLEAR + delete(obj.GlobalUI); + delete(obj.ConditionalUI); end - + function set.Enable(obj, value) - obj.Root.Enable = value; + % Disable all UI elements + % Render the GUI view-only by disabling all UI elements. Used for + % viewing parameters during an active experiment when the parameters + % can no longer be adjusted. + % See also EUI.EXPPANEL, EUI.CONDITIONPANEL/ONSELECT + cUI = obj.ConditionalUI; + contextMenus = [cUI.ContextMenus obj.GlobalUI.ContextMenu.Children]'; + parent = obj.Parent; % FIXME: use tags instead? + if value == true + arrayfun(@(prop) set(prop, 'Enable', 'on'), ... + [contextMenus; findobj(parent,'Enable','off')]); + cUI.onSelect() % Re-disable buttons if no cells were selected + else + arrayfun(@(prop) set(prop, 'Enable', 'off'), ... + [contextMenus; findobj(parent,'Enable','on')]); + end end - end - - methods %(Access = protected) - function build(obj, parent) % Build parameters panel - obj.Root = uiextras.HBox('Parent', parent, 'Padding', 5, 'Spacing', 5); % Add horizontal container for Global and Conditional panels -% globalPanel = uiextras.Panel('Parent', obj.Root,... % Make 'Global' parameters panel -% 'Title', 'Global', 'Padding', 5); - globPanel = uiextras.Panel('Parent', obj.Root,... % Make 'Global' parameters panel - 'Title', 'Global', 'Padding', 5); - globalPanel = uix.ScrollingPanel('Parent', globPanel,... % Make 'Global' scroll panel - 'Padding', 5); - - obj.GlobalGrid = uiextras.Grid('Parent', globalPanel, 'Padding', 4); % Make grid for parameter fields - obj.buildGlobalUI; % Populate Global panel - globalPanel.Heights = sum(obj.GlobalGrid.RowSizes)+45; - - conditionPanel = uiextras.Panel('Parent', obj.Root,... - 'Title', 'Conditional', 'Padding', 5); % Make 'Conditional' parameters panel - conditionVBox = uiextras.VBox('Parent', conditionPanel); - obj.ConditionTable = uitable('Parent', conditionVBox,... - 'FontName', 'Consolas',... - 'RowName', [],... - 'CellEditCallback', @obj.cellEditCallback,... - 'CellSelectionCallback', @obj.cellSelectionCallback); - obj.fillConditionTable(); - conditionButtonBox = uiextras.HBox('Parent', conditionVBox); - conditionVBox.Sizes = [-1 25]; - obj.NewConditionButton = uicontrol('Parent', conditionButtonBox,... - 'Style', 'pushbutton',... - 'String', 'New condition',... - 'TooltipString', 'Add a new condition',... - 'Callback', @(~, ~) obj.newCondition()); - obj.DeleteConditionButton = uicontrol('Parent', conditionButtonBox,... - 'Style', 'pushbutton',... - 'String', 'Delete condition',... - 'TooltipString', 'Delete the selected condition',... - 'Enable', 'off',... - 'Callback', @(~, ~) obj.deleteSelectedConditions()); - obj.MakeGlobalButton = uicontrol('Parent', conditionButtonBox,... - 'Style', 'pushbutton',... - 'String', 'Globalise parameter',... - 'TooltipString', sprintf(['Make the selected condition-specific parameter global (i.e. not vary by trial)\n'... - 'This will move it to the global parameters section']),... - 'Enable', 'off',... - 'Callback', @(~, ~) obj.globaliseSelectedParameters()); - obj.SetValuesButton = uicontrol('Parent', conditionButtonBox,... - 'Style', 'pushbutton',... - 'String', 'Set values',... - 'TooltipString', sprintf('Set selected values to specified value, range or function'),... - 'Enable', 'off',... - 'Callback', @(~, ~) obj.setSelectedValues()); - - obj.Root.Sizes = [sum(obj.GlobalGrid.ColumnSizes) + 32, -1]; + + function clear(obj) + % CLEAR Clear the Global and Condition panels + % Deletes all UI fields (labels and control elements) and clears the + % Condition Table data. + % + % See also BUILDUI + clear(obj.GlobalUI); + clear(obj.ConditionalUI); end - function buildGlobalUI(obj) % Function to essemble global parameters - globalParamNames = fieldnames(obj.Parameters.assortForExperiment); % assortForExperiment divides params into global and trial-specific parameter structures - obj.GlobalControls = gobjects(length(globalParamNames),3); % Initialize object array (faster than assigning to end of array which results in two calls to constructor) - for i=1:length(globalParamNames) % using for loop (sorry Chris!) to populate object array 2017-02-14 MW - [obj.GlobalControls(i,1), obj.GlobalControls(i,2), obj.GlobalControls(i,3)]... % [editors, labels, buttons] - = obj.addParamUI(globalParamNames{i}); + function buildUI(obj, pars) + % BUILDUI Populate Global and Condition UI panels with paramter set + % Clears any existing fields and the condition table, then loads new + % UI controls and labels for the Global parameters, and fills the + % condition table. The input pars must be an instance of the + % exp.Parameters class. + % + % RandomiseConditions is not added as a field but instead is + % represented as a context menu item + % + % See also EXP.PARAMETERS, EUI.FIELDPANEL/ADDFIELD, + % EUI.CONDITIONPANEL/FILLCONDITIONTABLE + obj.Parameters = pars; + obj.clear() % Clear the current parameter UI elements + if isempty(pars); return; end % Nothing to build + c = obj.GlobalUI; % Handle to FieldPanel object + names = pars.GlobalNames; % Names of the global parameters + for nm = names' + % RandomiseConditions is a special parameter represented in the + % context menu, don't create global param field + if strcmp(nm, 'randomiseConditions'); continue; end + if islogical(pars.Struct.(nm{:})) % If parameter is logical, make checkbox + [~, ctrl] = addField(c, nm{:}, 'checkbox'); + ctrl.Value = pars.Struct.(nm{:}); + else % Otherwise create the default field; a text box + [~, ctrl] = addField(c, nm{:}); + ctrl.String = obj.paramValue2Control(pars.Struct.(nm{:})); + end end - % Above code replaces the following as after 2014a, MATLAB doesn't no - % longer uses numrical handles but instead uses object arrays -% [editors, labels, buttons] = cellfun(... -% @(n) obj.addParamUI(n), fieldnames(globalParams), 'UniformOutput', false); -% editors = cell2mat(editors); -% labels = cell2mat(labels); -% buttons = cell2mat(buttons); -% obj.GlobalControls = [labels, editors, buttons]; -% obj.GlobalGrid.Children = obj.GlobalControls(:); - -% obj.GlobalGrid.Children = -% blah = cat(1,obj.GlobalControls(:,1),obj.GlobalControls(:,2),obj.GlobalControls(:,3)); -% Doesn't work for some reason - MW 2017-02-15 - - child_handles = allchild(obj.GlobalGrid); % Get child handles for GlobalGrid - child_handles = [child_handles(end-1:-3:1); child_handles(end:-3:1); child_handles(end-2:-3:1)]; % Reorder them so all labels come first, then ctrls, then buttons -% child_handles = [child_handles(2:3:end); child_handles(3:3:end); child_handles(1:3:end)]; % Reorder them so all labels come first, then ctrls, then buttons - obj.GlobalGrid.Contents = child_handles; % Set children to new order - % uistack - - obj.GlobalGrid.ColumnSizes = [180, 200, 40]; % Set column sizes - obj.GlobalGrid.Spacing = 1; - obj.GlobalGrid.RowSizes = repmat(obj.GlobalVSpacing, 1, size(obj.GlobalControls, 1)); + % Populate the trial conditions table + obj.ConditionalUI.fillConditionTable(); + %%% Special parameters + if ismember('randomiseConditions', obj.Parameters.Names) && ~pars.Struct.randomiseConditions + obj.ConditionalUI.ConditionTable.RowName = 'numbered'; + set(obj.ConditionalUI.ContextMenus(2), 'Checked', 'off'); + end + obj.GlobalUI.onResize(); end -% function swapConditions(obj, idx1, idx2) % Function started, never -% finished - MW 2017-02-15 -% % params = obj.Parameters.trial -% end - + function setRandomized(obj, value) + % If randomiseConditions doesn't exist and new value is false, add + % the parameter and set it to false + if ~ismember('randomiseConditions', obj.Parameters.Names) && value == false + description = 'Whether to randomise the conditional paramters or present them in order'; + obj.Parameters.set('randomiseConditions', false, description, 'logical') + elseif ismember('randomiseConditions', obj.Parameters.Names) + obj.update('randomiseConditions', logical(value)); + end + menu = obj.ConditionalUI.ContextMenus(2); + if value == false + obj.ConditionalUI.ConditionTable.RowName = 'numbered'; + menu.Checked = 'off'; + else + obj.ConditionalUI.ConditionTable.RowName = []; + menu.Checked = 'on'; + end + end + function addEmptyConditionToParam(obj, name) + % Add a new trial specific condition to the table + % Adds a new trial condition to each trial specific parameter. That + % is, adds a new column to each parameter. + % See also EUI.CONDITIONPANEL/NEWCONDITION assert(obj.Parameters.isTrialSpecific(name),... 'Tried to add a new condition to global parameter ''%s''', name); % work out what the right 'empty' is for the parameter @@ -183,228 +210,173 @@ function addEmptyConditionToParam(obj, name) obj.Parameters.Struct.(name) = cat(2, obj.Parameters.Struct.(name), newValue); end - function cellSelectionCallback(obj, src, eventData) - obj.SelectedCells = eventData.Indices; - if size(eventData.Indices, 1) > 0 - %cells selected, enable buttons - set(obj.MakeGlobalButton, 'Enable', 'on'); - set(obj.DeleteConditionButton, 'Enable', 'on'); - set(obj.SetValuesButton, 'Enable', 'on'); + function newValue = update(obj, name, value, row) + % UPDATE Updates the exp.Parameters object with new value + % Called when either the Condition table or Global param fields are + % updated by the user. Updated the underlying paramters structure + % and notifies listeners of the change via the Changed event. + % + % See also EUI.FIELDPANEL/ONEDIT, EUI.CONDITIONPANEL/ONEDIT + if nargin < 4; row = 1; end + currValue = obj.Parameters.Struct.(name)(:,row); + if iscell(currValue) + % cell holders are allowed to be different types of value + newValue = obj.controlValue2Param(currValue{1}, value, true); + obj.Parameters.Struct.(name){:,row} = newValue; else - %nothing selected, disable buttons - set(obj.MakeGlobalButton, 'Enable', 'off'); - set(obj.DeleteConditionButton, 'Enable', 'off'); - set(obj.SetValuesButton, 'Enable', 'off'); + newValue = obj.controlValue2Param(currValue, value); + if ischar(newValue) + obj.Parameters.Struct.(name) = newValue; + else + obj.Parameters.Struct.(name)(:,row) = newValue; + end end + notify(obj, 'Changed'); end - function newCondition(obj) - disp('adding new condition row'); - cellfun(@obj.addEmptyConditionToParam, obj.Parameters.TrialSpecificNames); - obj.fillConditionTable(); - end - - function deleteSelectedConditions(obj) - %DELETESELECTEDCONDITIONS Removes the selected conditions from table - % The callback for the 'Delete condition' button. This removes the - % selected conditions from the table and if less than two conditions - % remain, globalizes them. - % TODO: comment function better, index in a clearer fashion + function globaliseParamAtCell(obj, name, row) + % Make parameter 'name' a global parameter and set it's value to be + % that of the specified row. % - % See also EXP.PARAMETERS, GLOBALISESELECTEDPARAMETERS - rows = unique(obj.SelectedCells(:,1)); - % If the number of remaining conditions is 1 or less... - names = obj.Parameters.TrialSpecificNames; - numConditions = size(obj.Parameters.Struct.(names{1}),2); - if numConditions-length(rows) <= 1 - remainingIdx = find(all(1:numConditions~=rows,1)); - if isempty(remainingIdx); remainingIdx = 1; end - % change selected cells to be all fields (except numRepeats which - % is assumed to always be the last column) - obj.SelectedCells =[ones(length(names)-1,1)*remainingIdx, (1:length(names)-1)']; - %... globalize them - obj.globaliseSelectedParameters; - obj.Parameters.removeConditions(rows) -% for i = 1:numel(names) -% newValue = iff(any(remainingIdx), obj.Struct.(names{i})(:,remainingIdx), obj.Struct.(names{i})(1)); -% % If the parameter is Num repeats, set the value -% if strcmp(names{i}, 'numRepeats') -% obj.Struct.(names{i}) = newValue; -% else -% obj.makeGlobal(names{i}, newValue); -% end -% end - else % Otherwise delete the selected conditions as usual - obj.Parameters.removeConditions(rows); - end - obj.fillConditionTable(); %refresh the table of conditions - end - - function globaliseSelectedParameters(obj) - [cols, iu] = unique(obj.SelectedCells(:,2)); - names = obj.TableColumnParamNames(cols); - rows = obj.SelectedCells(iu,1); %get rows of unique selected cols - arrayfun(@obj.globaliseParamAtCell, rows, cols); - obj.fillConditionTable(); %refresh the table of conditions - %now add global controls for parameters - newGlobals = gobjects(length(names),3); % Initialize object array (faster than assigning to end of array which results in two calls to constructor) - for i=length(names):-1:1 % using for loop (sorry Chris!) to initialize and populate object array 2017-02-15 MW - [newGlobals(i,1), newGlobals(i,2), newGlobals(i,3)]... % [editors, labels, buttons] - = obj.addParamUI(names{i}); - end - -% [editors, labels, buttons] = arrayfun(@obj.addParamUI, names); % -% 2017-02-15 MW can no longer use arrayfun with object outputs - idx = size(obj.GlobalControls, 1); % Calculate number of current Global params - new = numel(newGlobals); - obj.GlobalControls = [obj.GlobalControls; newGlobals]; % Add new globals to object - ggHandles = obj.GlobalGrid.Contents; - ggHandles = [ggHandles(1:idx); ggHandles((end-new+2):3:end);... - ggHandles(idx+1:idx*2); ggHandles((end-new+1):3:end);... - ggHandles(idx*2+1:idx*3); ggHandles((end-new+3):3:end)]; % Reorder them so all labels come first, then ctrls, then buttons - obj.GlobalGrid.Contents = ggHandles; % Set children to new order - - % Reset sizes - obj.GlobalGrid.RowSizes = repmat(obj.GlobalVSpacing, 1, size(obj.GlobalControls, 1)); - set(get(obj.GlobalGrid, 'Parent'),... - 'Heights', sum(obj.GlobalGrid.RowSizes)+45); % Reset height of globalPanel - obj.GlobalGrid.ColumnSizes = [180, 200, 40]; - obj.GlobalGrid.Spacing = 1; - end - - function globaliseParamAtCell(obj, row, col) - name = obj.TableColumnParamNames{col}; + % See also EXP.PARAMETERS/MAKEGLOBAL, UI.CONDITIONPANEL/MAKEGLOBAL value = obj.Parameters.Struct.(name)(:,row); obj.Parameters.makeGlobal(name, value); - end - - function setSelectedValues(obj) % Set multiple fields in conditional table - disp('updating table cells'); - cols = obj.SelectedCells(:,2); % selected columns - uCol = unique(obj.SelectedCells(:,2)); - rows = obj.SelectedCells(:,1); % selected rows - % get current values of selected cells - currVals = arrayfun(@(u)obj.ConditionTable.Data(rows(cols==u),u), uCol, 'UniformOutput', 0); - names = obj.TableColumnParamNames(uCol); % selected column names - promt = cellfun(@(a,b) [a ' (' num2str(sum(cols==b)) ')'],... - names, num2cell(uCol), 'UniformOutput', 0); % names of columns & num selected rows - defaultans = cellfun(@(c) c(1), currVals); - answer = inputdlg(promt,'Set values', 1, cellflat(defaultans)); % prompt for input - if isempty(answer) % if user presses cancel - return - end - % set values for each column - cellfun(@(a,b,c) setNewVals(a,b,c), answer, currVals, names, 'UniformOutput', 0); - function newVals = setNewVals(userIn, currVals, paramName) - % check array orientation - currVals = iff(size(currVals,1)>size(currVals,2),currVals',currVals); - if strStartsWith(userIn,'@') % anon function - func_h = str2func(userIn); - % apply function to each cell - currVals = cellfun(@str2double,currVals, 'UniformOutput', 0); % convert from char - newVals = cellfun(func_h, currVals, 'UniformOutput', 0); - elseif any(userIn==':') % array syntax - arr = eval(userIn); - newVals = num2cell(arr); % convert to cell array - elseif any(userIn==','|userIn==';') % 2D arrays - C = strsplit(userIn, ';'); - newVals = cellfun(@(c)textscan(c, '%f',... - 'ReturnOnError', false,... - 'delimiter', {' ', ','}, 'MultipleDelimsAsOne', 1),... - C); - else % single value to copy across all cells - userIn = str2double(userIn); - newVals = num2cell(ones(size(currVals))*userIn); - end - - if length(newVals)>length(currVals) % too many new values - newVals = newVals(1:length(currVals)); % truncate new array - elseif length(newVals)