From 62f16c6f10e7fc549fe61af46e0c239fb7601013 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 15 Dec 2017 23:53:05 +0000 Subject: [PATCH 001/507] Weight recordings more complete Now the rig and user are recorded when a weight is submitted --- +eui/AlyxPanel.m | 5 ++++- +eui/MControl.m | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index f64206d7..b7d48efe 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -368,7 +368,7 @@ function changeWaterText(obj, src, ~) end end - function recordWeight(obj, weight, subject) + function recordWeight(obj, weight, subject, weighingScale) % Post a subject's weight to Alyx. If no inputs are provided, % create an input dialog for the user to input a weight. If no % subject is provided, use this object's currently selected @@ -376,6 +376,7 @@ function recordWeight(obj, weight, subject) % % See also VIEWSUBJECTHISTORY, VIEWALLSUBJECTS ai = obj.AlyxInstance; + if nargin < 4; weighingScale = hostname; end if nargin < 3; subject = obj.Subject; end if nargin < 2 prompt = {sprintf('weight of %s:', subject)}; @@ -391,6 +392,8 @@ function recordWeight(obj, weight, subject) weight = iff(ischar(weight{1}), str2double(weight{1}), weight{1}); d.subject = subject; d.weight = weight; + d.user = ai.user; + d.weighing_scale = weighingScale; 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.'); diff --git a/+eui/MControl.m b/+eui/MControl.m index 45983bba..9571d559 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -525,7 +525,8 @@ 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 From db2716f09e27eaf4f482c816aff7445b3b559e2b Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 18 Dec 2017 16:53:09 +0000 Subject: [PATCH 002/507] Removed weigh scale field * weighing_scale field in weighings is not supposed to be a string, removed for the time being. --- +eui/AlyxPanel.m | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index b7d48efe..e16983fc 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -368,7 +368,7 @@ function changeWaterText(obj, src, ~) end end - function recordWeight(obj, weight, subject, weighingScale) + function recordWeight(obj, weight, subject) % Post a subject's weight to Alyx. If no inputs are provided, % create an input dialog for the user to input a weight. If no % subject is provided, use this object's currently selected @@ -376,7 +376,6 @@ function recordWeight(obj, weight, subject, weighingScale) % % See also VIEWSUBJECTHISTORY, VIEWALLSUBJECTS ai = obj.AlyxInstance; - if nargin < 4; weighingScale = hostname; end if nargin < 3; subject = obj.Subject; end if nargin < 2 prompt = {sprintf('weight of %s:', subject)}; @@ -392,8 +391,7 @@ function recordWeight(obj, weight, subject, weighingScale) weight = iff(ischar(weight{1}), str2double(weight{1}), weight{1}); d.subject = subject; d.weight = weight; - d.user = ai.user; - d.weighing_scale = weighingScale; + d.user = ai.username; 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.'); From 08faf099973fcde10b54d7c949eb9b7ebe8555f1 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 18 Dec 2017 17:09:41 +0000 Subject: [PATCH 003/507] Adjuested water resmaining text WaterRemaining now a property of the class rather than a field within the AlyxInstance --- +eui/AlyxPanel.m | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index e16983fc..5e23a39e 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -49,6 +49,7 @@ 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 + WaterRemaining % Holds the current water required for the selected subject end events (NotifyAccess = 'protected') @@ -342,6 +343,7 @@ function dispWaterReq(obj, src, ~) set(obj.WaterRequiredText, 'String', ... sprintf('Subject %s requires %.2f of %.2f today', ... obj.Subject, s.water_requirement_remaining, s.water_requirement_total)); + obj.WaterRemaining = s.water_requirement_remaining; end catch me d = loadjson(me.message); @@ -361,8 +363,8 @@ function changeWaterText(obj, src, ~) % % 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; + if ~isempty(ai) && ~isempty(obj.WaterRemaining) + rem = obj.WaterRemaining; curr = str2double(src.String); set(obj.WaterRemainingText, 'String', sprintf('(%.2f)', rem-curr)); end From b3f47efd72937ece2caf62b515cc0203a81e8899 Mon Sep 17 00:00:00 2001 From: nsteinme Date: Mon, 18 Dec 2017 19:35:02 +0000 Subject: [PATCH 004/507] add some daq controllers, update daq controls to allow vector commands --- +exp/SignalsExp.m | 2 +- +hw/DaqController.m | 2 +- +hw/DaqLever.m | 129 ++++++++++++++++++++++++++++++++++++++ +hw/DaqLick.m | 131 +++++++++++++++++++++++++++++++++++++++ +hw/DaqPiezo.m | 129 ++++++++++++++++++++++++++++++++++++++ +hw/PulseSwitcher.m | 2 +- +hw/SinePulseGenerator.m | 45 ++++++++++++++ 7 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 +hw/DaqLever.m create mode 100644 +hw/DaqLick.m create mode 100644 +hw/DaqPiezo.m create mode 100644 +hw/SinePulseGenerator.m diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 5ddb002b..81438129 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -215,7 +215,7 @@ function useRig(obj, rig) obj.DaqController.ChannelNames)); % Find matching channel from rig hardware file if id % if the output is present, create callback obj.Listeners = [obj.Listeners - obj.Outputs.(outputNames{m}).onValue(@(v)obj.DaqController.command([zeros(1,id-1) v])) % pad value with zeros in order to output to correct channel + obj.Outputs.(outputNames{m}).onValue(@(v)obj.DaqController.command([zeros(size(v,1),id-1) v])) % pad value with zeros in order to output to correct channel obj.Outputs.(outputNames{m}).onValue(@(v)fprintf('delivering output of %.2f\n',v)) ]; elseif strcmp(outputNames{m}, 'reward') % special case; rewardValve is always first signals generator in list diff --git a/+hw/DaqController.m b/+hw/DaqController.m index 3b0a8c08..cca9f947 100644 --- a/+hw/DaqController.m +++ b/+hw/DaqController.m @@ -154,7 +154,7 @@ function command(obj, varargin) end channelNames = obj.ChannelNames(1:n); analogueChannelsIdx = obj.AnalogueChannelsIdx(1:n); - if any(analogueChannelsIdx)&&any(values(analogueChannelsIdx)~=0) + if any(analogueChannelsIdx)&&any(any(values(:,analogueChannelsIdx)~=0)) queue(obj, channelNames(analogueChannelsIdx), waveforms(analogueChannelsIdx)); if foreground startForeground(obj.DaqSession); diff --git a/+hw/DaqLever.m b/+hw/DaqLever.m new file mode 100644 index 00000000..47c9f3f8 --- /dev/null +++ b/+hw/DaqLever.m @@ -0,0 +1,129 @@ +classdef DaqLever < hw.PositionSensor + %HW.DaqLever Gets output from button + % Adopted from DaqRotaryEncoder + % AP 170629 + + properties + % hardcoded for zgood at the moment, not sure where this is normally changed AP 170629 + DaqSession = [] %DAQ session for input (see session-based interface docs) + DaqId = 'Dev2' %DAQ's device ID, e.g. 'Dev1' + DaqChannelId = 'port0/line3' %DAQ's ID for the counter channel. e.g. 'ctr0' + %DaqChannelId = 'ai3'; + end + + properties (Access = protected) + %Created when listenForAvailableData is called, allowing logging of + %positions during DAQ background acquision + DaqListener + DaqInputChannelIdx %Index into acquired input data matrices for our channel + LastDaqValue %Last value obtained from the DAQ counter + %Accumulated cycle number for position (i.e. when the DAQ's counter has + %over- or underflowed its range, this is incremented or decremented + %accordingly) + Cycle + end + + properties (Dependent) + DaqChannelIdx % index into DaqSession's channels for our data + end + + methods + function value = get.DaqChannelIdx(obj) + inputs = find(strcmpi('input', io.daqSessionChannelDirections(obj.DaqSession))); + value = inputs(obj.DaqInputChannelIdx); + end + + function set.DaqChannelIdx(obj, value) + % get directions of all channels on this session + dirs = io.daqSessionChannelDirections(obj.DaqSession); + % logical array flagging all input channels + inputsUptoChannel = strcmp(dirs(1:value), 'Input'); + % ensure the channel we're setting is an input + assert(inputsUptoChannel(value), 'Channel %i is not an input', value); + % find channel number counting inputs only + obj.DaqInputChannelIdx = sum(inputsUptoChannel); + end + + function createDaqChannel(obj) + % this didn't work - doesn't support timers, and something that I + % don't know never starts on stimserver + [ch, idx] = obj.DaqSession.addDigitalChannel(obj.DaqId, obj.DaqChannelId,'InputOnly'); + %[ch, idx] = obj.DaqSession.addAnalogInputChannel(obj.DaqId, obj.DaqChannelId,'Voltage'); + % quadrature encoding where each pulse from the channel updates + % the counter - ie. maximum resolution (see http://www.ni.com/white-paper/7109/en) + obj.DaqChannelIdx = idx; % record the index of the channel + %initialise LastDaqValue with current counter value + daqValue = obj.DaqSession.inputSingleScan(); + obj.LastDaqValue = daqValue(obj.DaqInputChannelIdx); + %reset cycle number + obj.Cycle = 0; + end + + function msg = wiringInfo(obj) + ch = obj.DaqSession.Channels(obj.DaqChannelIdx); + s1 = sprintf('Terminals: A = %s, B = %s\n', ... + ch.TerminalA, ch.TerminalB); + s2 = sprintf('For K�BLER 2400 series wiring is:\n'); + s3 = sprintf('GREEN -> %s, GREY -> %s, BROWN -> +5V, WHITE -> DGND\n',... + ch.TerminalA, ch.TerminalB); + msg = [s1 s2 s3]; + end + + function listenForAvailableData(obj) + % adds a listener to the DAQ session that will receive and process + % data when the DAQ is acquiring data in the background (i.e. + % startBackground() has been called on the session). + deleteListeners(obj); + obj.DaqListener = obj.DaqSession.addlistener('DataAvailable', ... + @(src, event) daqListener(obj, src, event)); + end + + function delete(obj) + deleteListeners(obj); + end + + function deleteListeners(obj) + if ~isempty(obj.DaqListener) + delete(obj.DaqListener); + end; + end + + function x = decodeDaq(obj, newValue) + %correct for 32-bit overflow/underflow + d = diff([obj.LastDaqValue; newValue]); + %decrement cycle for 'underflows', i.e. below 0 to a large value + %increment cycle for 'overflows', i.e. past max value to small values + cycle = obj.Cycle + cumsum(d < -0.5*obj.DaqCounterPeriod)... + - cumsum(d > 0.5*obj.DaqCounterPeriod); + x = obj.DaqCounterPeriod*cycle + newValue; + obj.Cycle = cycle(end); + obj.LastDaqValue = newValue(end); + end + end + + methods %(Access = protected) + function [x, time] = readAbsolutePosition(obj) + if obj.DaqSession.IsRunning + disp('waiting for session'); + obj.DaqSession.wait; + disp('done waiting'); + end + preTime = obj.Clock.now; + daqVal = inputSingleScan(obj.DaqSession); + x = daqVal; % AP 170629 straight digital read from lever + %x = decodeDaq(obj, daqVal(obj.DaqInputChannelIdx)); + postTime = obj.Clock.now; + time = 0.5*(preTime + postTime); % time is mean of before & after + end + end + + methods (Access = protected) + function daqListener(obj, src, event) + acqStartTime = obj.Clock.fromMatlab(event.TriggerTime); + values = decode(obj, event.Data(:,obj.DaqInputChannelIdx)) - obj.ZeroOffset; + times = acqStartTime + event.TimeStamps(:,obj.DaqInputChannelIdx); + logSamples(obj, values, times); + end + end +end + diff --git a/+hw/DaqLick.m b/+hw/DaqLick.m new file mode 100644 index 00000000..636c9ca9 --- /dev/null +++ b/+hw/DaqLick.m @@ -0,0 +1,131 @@ +classdef DaqLick < hw.PositionSensor + %HW.DaqLick Gets output from button + % Adopted from DaqRotaryEncoder + % AP 170629 + + properties + % hardcoded for zgood at the moment, not sure where this is normally changed AP 170629 + % (I think the protocol is to hardcode whatever, change manually, and + % save) + DaqSession = [] %DAQ session for input (see session-based interface docs) + DaqId = 'Dev2' %DAQ's device ID, e.g. 'Dev1' + DaqChannelId = 'port0/line2' %DAQ's ID for the counter channel. e.g. 'ctr0' + %DaqChannelId = 'ai3'; + end + + properties (Access = protected) + %Created when listenForAvailableData is called, allowing logging of + %positions during DAQ background acquision + DaqListener + DaqInputChannelIdx %Index into acquired input data matrices for our channel + LastDaqValue %Last value obtained from the DAQ counter + %Accumulated cycle number for position (i.e. when the DAQ's counter has + %over- or underflowed its range, this is incremented or decremented + %accordingly) + Cycle + end + + properties (Dependent) + DaqChannelIdx % index into DaqSession's channels for our data + end + + methods + function value = get.DaqChannelIdx(obj) + inputs = find(strcmpi('input', io.daqSessionChannelDirections(obj.DaqSession))); + value = inputs(obj.DaqInputChannelIdx); + end + + function set.DaqChannelIdx(obj, value) + % get directions of all channels on this session + dirs = io.daqSessionChannelDirections(obj.DaqSession); + % logical array flagging all input channels + inputsUptoChannel = strcmp(dirs(1:value), 'Input'); + % ensure the channel we're setting is an input + assert(inputsUptoChannel(value), 'Channel %i is not an input', value); + % find channel number counting inputs only + obj.DaqInputChannelIdx = sum(inputsUptoChannel); + end + + function createDaqChannel(obj) + % this didn't work - doesn't support timers, and something that I + % don't know never starts on stimserver + [ch, idx] = obj.DaqSession.addDigitalChannel(obj.DaqId, obj.DaqChannelId,'InputOnly'); + %[ch, idx] = obj.DaqSession.addAnalogInputChannel(obj.DaqId, obj.DaqChannelId,'Voltage'); + % quadrature encoding where each pulse from the channel updates + % the counter - ie. maximum resolution (see http://www.ni.com/white-paper/7109/en) + obj.DaqChannelIdx = idx; % record the index of the channel + %initialise LastDaqValue with current counter value + daqValue = obj.DaqSession.inputSingleScan(); + obj.LastDaqValue = daqValue(obj.DaqInputChannelIdx); + %reset cycle number + obj.Cycle = 0; + end + + function msg = wiringInfo(obj) + ch = obj.DaqSession.Channels(obj.DaqChannelIdx); + s1 = sprintf('Terminals: A = %s, B = %s\n', ... + ch.TerminalA, ch.TerminalB); + s2 = sprintf('For K�BLER 2400 series wiring is:\n'); + s3 = sprintf('GREEN -> %s, GREY -> %s, BROWN -> +5V, WHITE -> DGND\n',... + ch.TerminalA, ch.TerminalB); + msg = [s1 s2 s3]; + end + + function listenForAvailableData(obj) + % adds a listener to the DAQ session that will receive and process + % data when the DAQ is acquiring data in the background (i.e. + % startBackground() has been called on the session). + deleteListeners(obj); + obj.DaqListener = obj.DaqSession.addlistener('DataAvailable', ... + @(src, event) daqListener(obj, src, event)); + end + + function delete(obj) + deleteListeners(obj); + end + + function deleteListeners(obj) + if ~isempty(obj.DaqListener) + delete(obj.DaqListener); + end; + end + + function x = decodeDaq(obj, newValue) + %correct for 32-bit overflow/underflow + d = diff([obj.LastDaqValue; newValue]); + %decrement cycle for 'underflows', i.e. below 0 to a large value + %increment cycle for 'overflows', i.e. past max value to small values + cycle = obj.Cycle + cumsum(d < -0.5*obj.DaqCounterPeriod)... + - cumsum(d > 0.5*obj.DaqCounterPeriod); + x = obj.DaqCounterPeriod*cycle + newValue; + obj.Cycle = cycle(end); + obj.LastDaqValue = newValue(end); + end + end + + methods %(Access = protected) + function [x, time] = readAbsolutePosition(obj) + if obj.DaqSession.IsRunning + disp('waiting for session'); + obj.DaqSession.wait; + disp('done waiting'); + end + preTime = obj.Clock.now; + daqVal = inputSingleScan(obj.DaqSession); + x = daqVal; % AP 170629 straight digital read from lever + %x = decodeDaq(obj, daqVal(obj.DaqInputChannelIdx)); + postTime = obj.Clock.now; + time = 0.5*(preTime + postTime); % time is mean of before & after + end + end + + methods (Access = protected) + function daqListener(obj, src, event) + acqStartTime = obj.Clock.fromMatlab(event.TriggerTime); + values = decode(obj, event.Data(:,obj.DaqInputChannelIdx)) - obj.ZeroOffset; + times = acqStartTime + event.TimeStamps(:,obj.DaqInputChannelIdx); + logSamples(obj, values, times); + end + end +end + diff --git a/+hw/DaqPiezo.m b/+hw/DaqPiezo.m new file mode 100644 index 00000000..c83500f9 --- /dev/null +++ b/+hw/DaqPiezo.m @@ -0,0 +1,129 @@ +classdef DaqPiezo < hw.PositionSensor + %HW.DaqPiezo Gets output from button + % Adopted from DaqRotaryEncoder + % AP 170629 + + properties + % hardcoded for zgood at the moment, not sure where this is normally changed AP 170629 + DaqSession = [] %DAQ session for input (see session-based interface docs) + DaqId = 'Dev2' %DAQ's device ID, e.g. 'Dev1' + DaqChannelId = 'port0/line3' %DAQ's ID for the counter channel. e.g. 'ctr0' + %DaqChannelId = 'ai3'; + end + + properties (Access = protected) + %Created when listenForAvailableData is called, allowing logging of + %positions during DAQ background acquision + DaqListener + DaqInputChannelIdx %Index into acquired input data matrices for our channel + LastDaqValue %Last value obtained from the DAQ counter + %Accumulated cycle number for position (i.e. when the DAQ's counter has + %over- or underflowed its range, this is incremented or decremented + %accordingly) + Cycle + end + + properties (Dependent) + DaqChannelIdx % index into DaqSession's channels for our data + end + + methods + function value = get.DaqChannelIdx(obj) + inputs = find(strcmpi('input', io.daqSessionChannelDirections(obj.DaqSession))); + value = inputs(obj.DaqInputChannelIdx); + end + + function set.DaqChannelIdx(obj, value) + % get directions of all channels on this session + dirs = io.daqSessionChannelDirections(obj.DaqSession); + % logical array flagging all input channels + inputsUptoChannel = strcmp(dirs(1:value), 'Input'); + % ensure the channel we're setting is an input + assert(inputsUptoChannel(value), 'Channel %i is not an input', value); + % find channel number counting inputs only + obj.DaqInputChannelIdx = sum(inputsUptoChannel); + end + + function createDaqChannel(obj) + % this didn't work - doesn't support timers, and something that I + % don't know never starts on stimserver + [ch, idx] = obj.DaqSession.addDigitalChannel(obj.DaqId, obj.DaqChannelId,'InputOnly'); + %[ch, idx] = obj.DaqSession.addAnalogInputChannel(obj.DaqId, obj.DaqChannelId,'Voltage'); + % quadrature encoding where each pulse from the channel updates + % the counter - ie. maximum resolution (see http://www.ni.com/white-paper/7109/en) + obj.DaqChannelIdx = idx; % record the index of the channel + %initialise LastDaqValue with current counter value + daqValue = obj.DaqSession.inputSingleScan(); + obj.LastDaqValue = daqValue(obj.DaqInputChannelIdx); + %reset cycle number + obj.Cycle = 0; + end + + function msg = wiringInfo(obj) + ch = obj.DaqSession.Channels(obj.DaqChannelIdx); + s1 = sprintf('Terminals: A = %s, B = %s\n', ... + ch.TerminalA, ch.TerminalB); + s2 = sprintf('For K�BLER 2400 series wiring is:\n'); + s3 = sprintf('GREEN -> %s, GREY -> %s, BROWN -> +5V, WHITE -> DGND\n',... + ch.TerminalA, ch.TerminalB); + msg = [s1 s2 s3]; + end + + function listenForAvailableData(obj) + % adds a listener to the DAQ session that will receive and process + % data when the DAQ is acquiring data in the background (i.e. + % startBackground() has been called on the session). + deleteListeners(obj); + obj.DaqListener = obj.DaqSession.addlistener('DataAvailable', ... + @(src, event) daqListener(obj, src, event)); + end + + function delete(obj) + deleteListeners(obj); + end + + function deleteListeners(obj) + if ~isempty(obj.DaqListener) + delete(obj.DaqListener); + end; + end + + function x = decodeDaq(obj, newValue) + %correct for 32-bit overflow/underflow + d = diff([obj.LastDaqValue; newValue]); + %decrement cycle for 'underflows', i.e. below 0 to a large value + %increment cycle for 'overflows', i.e. past max value to small values + cycle = obj.Cycle + cumsum(d < -0.5*obj.DaqCounterPeriod)... + - cumsum(d > 0.5*obj.DaqCounterPeriod); + x = obj.DaqCounterPeriod*cycle + newValue; + obj.Cycle = cycle(end); + obj.LastDaqValue = newValue(end); + end + end + + methods %(Access = protected) + function [x, time] = readAbsolutePosition(obj) + if obj.DaqSession.IsRunning + disp('waiting for session'); + obj.DaqSession.wait; + disp('done waiting'); + end + preTime = obj.Clock.now; + daqVal = inputSingleScan(obj.DaqSession); + x = daqVal; % AP 170629 straight digital read from lever + %x = decodeDaq(obj, daqVal(obj.DaqInputChannelIdx)); + postTime = obj.Clock.now; + time = 0.5*(preTime + postTime); % time is mean of before & after + end + end + + methods (Access = protected) + function daqListener(obj, src, event) + acqStartTime = obj.Clock.fromMatlab(event.TriggerTime); + values = decode(obj, event.Data(:,obj.DaqInputChannelIdx)) - obj.ZeroOffset; + times = acqStartTime + event.TimeStamps(:,obj.DaqInputChannelIdx); + logSamples(obj, values, times); + end + end +end + diff --git a/+hw/PulseSwitcher.m b/+hw/PulseSwitcher.m index e5b0e5f3..4321bf60 100644 --- a/+hw/PulseSwitcher.m +++ b/+hw/PulseSwitcher.m @@ -21,7 +21,7 @@ end function samples = waveform(obj, sampleRate, command) - [dt, npulses, f] = obj.ParamsFun(command); + [dt, npulses, f] = obj.ParamsFun(command(1)); wavelength = 1/f; duty = dt/wavelength; assert(duty <= (1 + 1e-3), 'Pulse width larger than wavelength (duty=%.2f)', duty); diff --git a/+hw/SinePulseGenerator.m b/+hw/SinePulseGenerator.m new file mode 100644 index 00000000..95576183 --- /dev/null +++ b/+hw/SinePulseGenerator.m @@ -0,0 +1,45 @@ +classdef SinePulseGenerator < hw.ControlSignalGenerator + %HW.PULSESWITCHER Generates a train of pulses + % Detailed explanation goes here + + properties + Offset + end + + methods + function obj = SinePulseGenerator(offset) + obj.DefaultValue = 0; + obj.Offset = offset; + end + + function samples = waveform(obj, sampleRate, pars) + dt = pars(1); + + if numel(pars)==3 + f = pars(2); + amp = pars(3); + else + f = 40; + amp = 1; + end + + % first construct one cycle at this frequency + oneCycleDt = 1/f; + t = linspace(0, oneCycleDt - 1/sampleRate, sampleRate*oneCycleDt); + samples = amp/2*(-cos(2*pi*f*t) + 1); + + % if dt is greater than the duration of that cycle, then put zeros in + % the middle + if dt>oneCycleDt + nSamp = round(dt*sampleRate)-numel(samples); + m = round(numel(samples)/2); + samples = [samples(1:m) amp*ones(1,nSamp) samples(m+1:end)]; + end + + % add a zero so it turns off at the end + samples = [samples'; 0]; + end + end + +end + From e78f5e8feb6fea503c3f0c8942de8b7a282c1e89 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 19 Dec 2017 12:21:45 +0000 Subject: [PATCH 005/507] Further Alyx integration and tl fix * Timeline now adds inputs for easier indexing in analysis code * Alyx session created now moved to dat.newExp as this is a more logical place for it * launchSessionURL button should now work on the Alyx panel. If a base session doesn't exist when the button is pressed, one is created. --- +dat/newExp.m | 73 ++++++++++++++++++++++++++++++++++++++++++++---- +eui/AlyxPanel.m | 65 +++++++++++++++++++----------------------- +eui/MControl.m | 64 ++++-------------------------------------- +hw/Timeline.m | 2 +- 4 files changed, 103 insertions(+), 101 deletions(-) diff --git a/+dat/newExp.m b/+dat/newExp.m index 035d351e..0e63070a 100644 --- a/+dat/newExp.m +++ b/+dat/newExp.m @@ -1,6 +1,19 @@ -function [expRef, expSeq] = newExp(subject, expDate, expParams) +function [expRef, expSeq] = newExp(subject, expDate, expParams, AlyxInstance) %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[, AlyxInstance]) +% 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. If an instance of Alyx is passed and a base session for the +% experiment date is not found, one is created in the Alyx database. +% A corresponding subsession is also created. +% +% See also DAT.PATHS % % Part of Rigbox @@ -16,6 +29,11 @@ expParams = []; end +if nargin < 4 + % no instance of Alyx, don't create session on Alyx + AlyxInstance = []; +end + if ischar(expDate) % if the passed expDate is a string, parse it into a datenum expDate = datenum(expDate, 'yyyy-mm-dd'); @@ -29,8 +47,7 @@ [~, 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; @@ -40,7 +57,7 @@ end % expInfo repository is the reference location for which experiments exist -[expPath, expRef] = dat.expPath(subject, expDate, expSeq, 'expInfo'); +[expPath, expRef] = dat.expPath(subject, floor(expDate), expSeq, 'expInfo'); % 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)); @@ -48,6 +65,52 @@ % now make the folder(s) to hold the new experiment assert(all(cellfun(@(p) mkdir(p), expPath)), 'Creating experiment directories failed'); +if ~strcmp(subject,'default') % Ignore fake subject + % if the Alyx Instance is set, find or create BASE session + expDate = alyx.datestr(expDate); % date in Alyx format + if ~isempty(AlyxInstance) + % Get list of base sessions + sessions = alyx.getData(AlyxInstance,... + ['sessions?type=Base&subject=' subject]); + + %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), expDate(1:10)) + d = struct; + d.subject = subject; + d.procedures = {'Behavior training/tasks'}; + d.narrative = 'auto-generated session'; + d.start_time = expDate; + d.type = 'Base'; + + base_submit = alyx.postData(AlyxInstance, 'sessions', d); + assert(isfield(base_submit,'subject'),... + 'Submitted base session did not return appropriate values'); + + %Now retrieve the sessions again + sessions = alyx.getData(AlyxInstance,... + ['sessions?type=Base&subject=' subject]); + end + latest_base = sessions{end}; + else % If not logged in to Alyx... + latest_base.url = []; % set the base url to null + end + + %Now create a new SUBSESSION, using the same experiment number + d = struct; + d.subject = subject; + d.procedures = {'Behavior training/tasks'}; + d.narrative = 'auto-generated session'; + d.start_time = expDate; + d.type = 'Experiment'; + d.parent_session = latest_base.url; + d.number = expSeq; + + subsession = alyx.postData(AlyxInstance, 'sessions', d); + assert(isfield(subsession,'subject'),... + 'Failed to create new sub-session in Alyx for %s', subject); +end + % if the parameters had an experiment definition function, save a copy in % the experiment's folder if isfield(expParams, 'defFunction') diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 5e23a39e..02889e23 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -408,55 +408,46 @@ function recordWeight(obj, weight, subject) 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? + % 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. + % + % 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]); + sessions = alyx.getData(ai, ['sessions?type=Base&subject=' obj.Subject]); - % If the date of this latest session is not the same date as - % today, then create a new session for today + % 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) || ~strcmp(sessions{end}.start_time(1:10), thisDate(1:10)) % 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-mm-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'; + + thisSess = alyx.postData(ai, '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' return end diff --git a/+eui/MControl.m b/+eui/MControl.m index 9571d559..0cde0300 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -544,64 +544,12 @@ 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'); + expRef = dat.newExp(obj.NewExpSubject.Selected, now, obj.Parameters.Struct); % Create new experiment reference + % Add a copy of the AlyxInstance to the rig object for later + % water registration, &c. + rig.AlyxInstance = AlyxInstance; + rig.AlyxInstance.subsessionURL = subsession.url; panel = eui.ExpPanel.live(obj.ActiveExpsGrid, expRef, rig, obj.Parameters.Struct); obj.LastExpPanel = panel; diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 5efa5d2f..9d92d5ca 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -522,7 +522,7 @@ function init(obj) inputSession.NotifyWhenDataAvailableExceeds = obj.DaqSamplesPerNotify; % when to process data obj.Sessions('main') = inputSession; for i = 1:length(use) - in = obj.Inputs(idx(i)); % get channel info, etc. + in = inputOptions(strcmp({obj.Inputs.name}, obj.UseInputs(i))); % get channel info, etc. switch in.measurement case 'Voltage' ch = obj.Sessions('main').addAnalogInputChannel(obj.DaqIds, in.daqChannelID, in.measurement); From c082d3018a4f3e2fa81d6ee578d5ebeb6bd5e4fc Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 19 Dec 2017 15:06:48 +0000 Subject: [PATCH 006/507] subsession url assigned properly subsession url is now assigned properly when starting an experiment --- +dat/newExp.m | 9 ++++++--- +eui/MControl.m | 8 +++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/+dat/newExp.m b/+dat/newExp.m index 0e63070a..df5c6ca1 100644 --- a/+dat/newExp.m +++ b/+dat/newExp.m @@ -1,6 +1,6 @@ -function [expRef, expSeq] = newExp(subject, expDate, expParams, AlyxInstance) +function [expRef, expSeq, url] = newExp(subject, expDate, expParams, AlyxInstance) %DAT.NEWEXP Create a new unique experiment in the database -% [ref, seq] = DAT.NEWEXP(subject, expDate, expParams[, AlyxInstance]) +% [ref, seq, url] = DAT.NEWEXP(subject, expDate, expParams[, AlyxInstance]) % Create a new experiment by creating the relevant folder tree in the % local and main data repositories in the following format: % @@ -82,6 +82,7 @@ d.narrative = 'auto-generated session'; d.start_time = expDate; d.type = 'Base'; +% d.users = {AlyxInstance.username}; base_submit = alyx.postData(AlyxInstance, 'sessions', d); assert(isfield(base_submit,'subject'),... @@ -91,7 +92,7 @@ sessions = alyx.getData(AlyxInstance,... ['sessions?type=Base&subject=' subject]); end - latest_base = sessions{end}; + latest_base = sessions{end}; else % If not logged in to Alyx... latest_base.url = []; % set the base url to null end @@ -105,10 +106,12 @@ d.type = 'Experiment'; d.parent_session = latest_base.url; d.number = expSeq; +% d.users = {AlyxInstance.username}; subsession = alyx.postData(AlyxInstance, 'sessions', d); assert(isfield(subsession,'subject'),... 'Failed to create new sub-session in Alyx for %s', subject); + url = subsession.url; end % if the parameters had an experiment definition function, save a copy in diff --git a/+eui/MControl.m b/+eui/MControl.m index 0cde0300..abf5b918 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -545,11 +545,13 @@ function beginExp(obj) % Add these services to the parameters obj.Parameters.set('services', services(:),... 'List of experiment services to use during the experiment'); - expRef = dat.newExp(obj.NewExpSubject.Selected, now, obj.Parameters.Struct); % Create new experiment reference + % Create new experiment reference + [expRef, ~, url] = dat.newExp(obj.NewExpSubject.Selected, now,... + obj.Parameters.Struct, obj.AlyxPanel.AlyxInstance); % Add a copy of the AlyxInstance to the rig object for later % water registration, &c. - rig.AlyxInstance = AlyxInstance; - rig.AlyxInstance.subsessionURL = subsession.url; + rig.AlyxInstance = obj.AlyxPanel.AlyxInstance; + rig.AlyxInstance.subsessionURL = url; panel = eui.ExpPanel.live(obj.ActiveExpsGrid, expRef, rig, obj.Parameters.Struct); obj.LastExpPanel = panel; From 43aa391e44470bc475c187ce33089623a7ed8dcf Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 19 Dec 2017 16:34:55 +0000 Subject: [PATCH 007/507] Fixed error when running default mouse url output now defined in all circumstances --- +dat/newExp.m | 2 ++ 1 file changed, 2 insertions(+) diff --git a/+dat/newExp.m b/+dat/newExp.m index df5c6ca1..ae7a3e76 100644 --- a/+dat/newExp.m +++ b/+dat/newExp.m @@ -112,6 +112,8 @@ assert(isfield(subsession,'subject'),... 'Failed to create new sub-session in Alyx for %s', subject); url = subsession.url; +else + url = []; end % if the parameters had an experiment definition function, save a copy in From 7e03ec60fbe9a1e000a244f4a1e456dadae0ca7d Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 19 Dec 2017 17:40:12 +0000 Subject: [PATCH 008/507] reverted Timeline as 'fix' didn't work --- +hw/Timeline.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 9d92d5ca..7d514568 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -522,7 +522,7 @@ function init(obj) inputSession.NotifyWhenDataAvailableExceeds = obj.DaqSamplesPerNotify; % when to process data obj.Sessions('main') = inputSession; for i = 1:length(use) - in = inputOptions(strcmp({obj.Inputs.name}, obj.UseInputs(i))); % get channel info, etc. + in = obj.Inputs(idx(i)); % get channel info, etc. switch in.measurement case 'Voltage' ch = obj.Sessions('main').addAnalogInputChannel(obj.DaqIds, in.daqChannelID, in.measurement); From 7882a77ad53a69f6356c60a081e96420f25f5c8a Mon Sep 17 00:00:00 2001 From: nsteinme Date: Sun, 7 Jan 2018 18:27:17 +0000 Subject: [PATCH 009/507] modernize mpep listener - incomplete --- +hw/Timeline.m | 6 +++- cortexlab/+tl/bindMpepServerWithWS.m | 50 ++++++++++++++++++---------- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 7d514568..f6f141e5 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -522,7 +522,9 @@ function init(obj) inputSession.NotifyWhenDataAvailableExceeds = obj.DaqSamplesPerNotify; % when to process data obj.Sessions('main') = inputSession; for i = 1:length(use) - in = obj.Inputs(idx(i)); % get channel info, etc. + in = obj.Inputs(idx(i)); % get channel info, etc. + fprintf(1, 'adding channel %s on %s\n', in.name, in.daqChannelID); + switch in.measurement case 'Voltage' ch = obj.Sessions('main').addAnalogInputChannel(obj.DaqIds, in.daqChannelID, in.measurement); @@ -603,7 +605,9 @@ function process(obj, ~, event) fwrite(obj.DataFID, datToWrite', obj.AquiredDataType); % Write to file end % if plotting the channels live, plot the new data + if obj.LivePlot; obj.livePlot(event.Data); end + end function livePlot(obj, data) diff --git a/cortexlab/+tl/bindMpepServerWithWS.m b/cortexlab/+tl/bindMpepServerWithWS.m index 33d8996b..187583cf 100644 --- a/cortexlab/+tl/bindMpepServerWithWS.m +++ b/cortexlab/+tl/bindMpepServerWithWS.m @@ -45,6 +45,12 @@ communicator.EventMode = false; communicator.open(); +%% initialize timeline + +rig = hw.devices([], false); +tlObj = rig.timeline; +tls.tlObj = tlObj; + %% Helper functions function closeConns() @@ -61,10 +67,10 @@ function process() function processListener(listener) sz = pnet(listener.socket, 'readpacket', 1000, 'noblock'); if sz > 0 - t = tl.time(false); % save the time we got the UDP packet + t = tlObj.time(false); % save the time we got the UDP packet msg = pnet(listener.socket, 'read'); - if tl.running - tl.record([listener.name 'UDP'], msg, t); % record the UDP event in Timeline + if tlObj.IsRunning + tlObj.record([listener.name 'UDP'], msg, t); % record the UDP event in Timeline end listener.callback(listener, msg); % call special handling function end @@ -77,6 +83,10 @@ function processMpep(listener, msg) log('%s: ''%s'' from %s:%i', listener.name, msg, ipstr, port); % parse the message info = dat.mpepMessageParse(msg); + + % !!! Get alyx instance here!! + ai = []; + failed = false; % flag for preventing UDP echo %% Experiment-level events start/stop timeline switch lower(info.instruction) @@ -85,10 +95,10 @@ function processMpep(listener, msg) try % start Timeline communicator.send('status', { 'starting', info.expRef}); - tl.start(info.expRef); + tlObj.start(info.expRef, ai); % re-record the UDP event in Timeline since it wasn't started % when we tried earlier. Treat it as having arrived at time zero. - tl.record('mpepUDP', msg, 0); + tlObj.record('mpepUDP', msg, 0); catch ex % flag up failure so we do not echo the UDP message back below failed = true; @@ -96,11 +106,11 @@ function processMpep(listener, msg) end case 'expend' - tl.stop(); % stop Timeline + tlObj.stop(); % stop Timeline communicator.send('status', { 'completed', info.expRef}); case 'expinterrupt' - tl.stop(); % stop Timeline + tlObj.stop(); % stop Timeline communicator.send('status', { 'completed', info.expRef}); end if ~failed @@ -135,20 +145,26 @@ function listen() if firstPress(quitKey) running = false; end - if firstPress(manualStartKey) && ~tl.running - [mouseName, ~] = dat.subjectSelector(); + if firstPress(manualStartKey) && ~tlObj.IsRunning + + % first get an alyx instance + ai = alyx.loginWindow(); + + [mouseName, ~] = dat.subjectSelector([],ai); + if ~isempty(mouseName) clear expParams; expParams.experimentType = 'timelineManualStart'; - newExpRef = dat.newExp(mouseName, now, expParams); + newExpRef = dat.newExp(mouseName, now, expParams, ai); %[subjectRef, expDate, expSequence] = dat.parseExpRef(newExpRef); %newExpRef = dat.constructExpRef(mouseName, now, expNum); communicator.send('status', { 'starting', newExpRef}); - tl.start(newExpRef); + tlObj.start(newExpRef, ai); end - elseif firstPress(manualStartKey) && tl.running && ~isempty(newExpRef) - - tl.stop(); + KbQueueFlush; + elseif firstPress(manualStartKey) && tlObj.IsRunning && ~isempty(newExpRef) + fprintf(1, 'stopping timeline\n'); + tlObj.stop(); communicator.send('status', { 'completed', newExpRef}); newExpRef = []; end @@ -172,8 +188,8 @@ function handleMessage(id, data, host) % client disconnected log('WS: ''%s'' disconnected', host); else - command = data{1}; - args = data(2:end); + command = data{1} + args = data(2:end) if ~strcmp(command, 'status') % log the command received log('WS: Received ''%s''', command); @@ -181,7 +197,7 @@ function handleMessage(id, data, host) switch command case 'status' % status request - if ~tl.running + if ~tlObj.IsRunning communicator.send(id, {'idle'}); else communicator.send(id, {'running'}); From 3b1fe577bd5155fa97072717d9b059951082ee95 Mon Sep 17 00:00:00 2001 From: nsteinme Date: Sun, 7 Jan 2018 18:36:19 +0000 Subject: [PATCH 010/507] fix channel ordering bug --- +dat/findNextSeqNum.m | 24 +++++++++++ +dat/subjectSelector.m | 98 ++++++++++++++++++++++++++++++++++++++++++ +hw/Timeline.m | 5 ++- 3 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 +dat/findNextSeqNum.m create mode 100644 +dat/subjectSelector.m diff --git a/+dat/findNextSeqNum.m b/+dat/findNextSeqNum.m new file mode 100644 index 00000000..45450073 --- /dev/null +++ b/+dat/findNextSeqNum.m @@ -0,0 +1,24 @@ + + +function expSeq = findNextSeqNum(subject, varargin) +% function expSeq = findNextSeqNum(subject[, date]) +if isempty(varargin) + expDate = now; +else + expDate = varargin{1}; +end + + +% retrieve list of experiments for subject +[~, dateList, seqList] = dat.listExps(subject); + +% filter the list by expdate +expDate = floor(expDate); +filterIdx = dateList == expDate; + +% find the next sequence number +expSeq = max(seqList(filterIdx)) + 1; +if isempty(expSeq) + % if none today, max will have returned [], so override this to 1 + expSeq = 1; +end \ No newline at end of file diff --git a/+dat/subjectSelector.m b/+dat/subjectSelector.m new file mode 100644 index 00000000..8437dad5 --- /dev/null +++ b/+dat/subjectSelector.m @@ -0,0 +1,98 @@ +function [subjectName, expNum] = subjectSelector(varargin) +% function [subjectName, expNum] = subjectSelector([parentFig], [alyxInstance]) +% make a popup window that will allow selection of a subject and expNum +% +% If you provide an alyxInstance, it will populate with a list of subjects +% from alyx; otherwise, from dat.listSubjects + +subjectName = []; +expNum = 1; + +f = figure(); +set(f, 'MenuBar', 'none', 'Name', 'Select subject', 'NumberTitle', 'off','Resize', 'off', ... + 'WindowStyle', 'modal'); +w = 300; +h = 50; + +if nargin>0 && ~isempty(varargin{1}) + parentPos = get(varargin{1}, 'Position'); +else + parentPos = get(f, 'Position'); +end + +newPos = [parentPos(1)+parentPos(3)/2-w/2, parentPos(2)+parentPos(4)/2-h/2, w, h]; +set(f, 'Position', newPos); + +txtChooseSubject = uicontrol('Style', 'text', 'Parent', f, ... + 'Position',[10 h-30 90 25], ... + 'String', 'Choose subject:', 'HorizontalAlignment', 'right'); + +txtChooseExpNum = uicontrol('Style', 'text', 'Parent', f, ... + 'Position',[10 h-55 90 25], ... + 'String', 'Choose exp num:', 'HorizontalAlignment', 'right'); + +subjectDropdown = uicontrol('Style', 'popupmenu', 'Parent', f, ... + 'Position',[110 h-25 90 25], ... + 'Background', [1 1 1], 'Callback', @pickExpNum); + +if nargin>1 + ai = varargin{2}; + + 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, ai.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]; + set(subjectDropdown, 'String', newSubs); +else + set(subjectDropdown, 'String', dat.listSubjects); +end + +edtExpNum = uicontrol('Style', 'text', 'Parent', f, ... + 'Position',[110 h-50 90 25], ... + 'String', num2str(expNum), 'Background', [1 1 1]); + + +uicontrol('Style', 'pushbutton', 'String', 'OK', 'Position', ... + [210 h-25 90 25],'Callback', @ok); +uicontrol('Style', 'pushbutton', 'String', 'Cancel', 'Position', ... + [210 h-50 90 25],'Callback', @cancel); + +uiwait(); + + function ok(~,~) + + subjectList = get(subjectDropdown, 'String'); + subjectName = subjectList{get(subjectDropdown, 'Value')}; + expNum = str2num(get(edtExpNum, 'String')); + delete(f) + + end + + function cancel(~,~) + + subjectName = []; + expNum = []; + delete(f) + + end + + function pickExpNum(~,~) + + subjectList = get(subjectDropdown, 'String'); + subjectName = subjectList{get(subjectDropdown, 'Value')}; + try + expNumSuggestion = dat.findNextSeqNum(subjectName); + set(edtExpNum, 'String', num2str(expNumSuggestion)); + catch + end + + end +end \ No newline at end of file diff --git a/+hw/Timeline.m b/+hw/Timeline.m index f6f141e5..e084c8e0 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -522,7 +522,8 @@ function init(obj) inputSession.NotifyWhenDataAvailableExceeds = obj.DaqSamplesPerNotify; % when to process data obj.Sessions('main') = inputSession; for i = 1:length(use) - in = obj.Inputs(idx(i)); % get channel info, etc. + in = obj.Inputs(strcmp({obj.Inputs.name}, obj.UseInputs(i))); +% in = obj.Inputs(idx(i)); % get channel info, etc. fprintf(1, 'adding channel %s on %s\n', in.name, in.daqChannelID); switch in.measurement @@ -538,7 +539,7 @@ function init(obj) % we assume quadrature encoding (X4) for position measurement ch.EncoderType = 'X4'; end - obj.Inputs(idx(i)).arrayColumn = i; + obj.Inputs(strcmp({obj.Inputs.name}, obj.UseInputs(i))).arrayColumn = i; end end From 06a190c569a81ba497fbd66715bbc9f53852e4b3 Mon Sep 17 00:00:00 2001 From: nsteinme Date: Mon, 8 Jan 2018 17:35:03 +0000 Subject: [PATCH 011/507] remove empty fields when converting alyx instance to string --- +dat/parseAlyxInstance.m | 3 +++ 1 file changed, 3 insertions(+) diff --git a/+dat/parseAlyxInstance.m b/+dat/parseAlyxInstance.m index 4be14277..1fb53e93 100644 --- a/+dat/parseAlyxInstance.m +++ b/+dat/parseAlyxInstance.m @@ -22,6 +22,9 @@ if isfield(ai, 'water_requirement_remaining') ai = rmfield(ai, 'water_requirement_remaining'); end + fnai = fieldnames(ai); + ise = cellfun(@(fn)isempty(ai.(fn)), fnai); + if any(ise); ai = rmfield(ai, fnai(ise)); 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 From 8643d422c5c0ef236eef29b35feb0de7aef3e271 Mon Sep 17 00:00:00 2001 From: nsteinme Date: Mon, 8 Jan 2018 17:39:13 +0000 Subject: [PATCH 012/507] update io.MpepDataHosts for sending alyx info --- +srv/expServer.m | 2 +- cortexlab/+io/MpepUDPDataHosts.m | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/+srv/expServer.m b/+srv/expServer.m index d954527c..e629a017 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -169,7 +169,7 @@ function handleMessage(id, data, host) end case 'run' % exp run request - [expRef, preDelay, postDelay, Alyx] = args{:}; + [expRef, preDelay, postDelay, Alyx] = args{:}; if dat.expExists(expRef) log('Starting experiment ''%s''', expRef); communicator.send(id, []); diff --git a/cortexlab/+io/MpepUDPDataHosts.m b/cortexlab/+io/MpepUDPDataHosts.m index 4b42cd20..821b215f 100644 --- a/cortexlab/+io/MpepUDPDataHosts.m +++ b/cortexlab/+io/MpepUDPDataHosts.m @@ -20,6 +20,7 @@ DigitalOutDaqChannelId Verbose = false % whether to output I/O messages etc Timeline % An instance of timeline for for recording UDP messages + AlyxInstance end properties (SetAccess = protected) @@ -170,7 +171,7 @@ function stimEnded(obj, num) msg = sprintf('StimEnd %s %d %d 1 %d', subject, seriesNum, expNum, num); broadcast(obj, msg); - if ~isempty(obj.Timeline)&&obj.Timeline.IsRunning + if ~isempty(obj.Timeline)&&isfield(obj.Timeline, 'IsRunning')&&obj.Timeline.IsRunning obj.Timeline.record('mpepUDP', msg); % record the UDP event in Timeline end dt = toc; @@ -200,9 +201,15 @@ function expEnded(obj) obj.ExpRef = []; end - function start(obj, expRef) + function start(obj, ref) + [expRef, ai] = dat.parseAlyxInstance(ref); + obj.AlyxInstance = ai; + [subject, seriesNum, expNum] = dat.expRefToMpep(obj.ExpRef); + alyxmsg = sprintf('alyx %s %d %d %s', subject, seriesNum, expNum, ref); + confirmedBroadcast(obj, alyxmsg); % equivalent to startExp(expRef) expStarted(obj, expRef); + end function stop(obj) @@ -230,7 +237,7 @@ function stop(obj) function confirmedBroadcast(obj, msg) broadcast(obj, msg); validateResponses(obj); - if ~isempty(obj.Timeline)&&obj.Timeline.IsRunning + if ~isempty(obj.Timeline)&&isfield(obj.Timeline, 'IsRunning')&&obj.Timeline.IsRunning obj.Timeline.record('mpepUDP', msg); % record the UDP event in Timeline end end From 96dd74b6b74929cc4d080aee047e47a26a4ab94a Mon Sep 17 00:00:00 2001 From: nsteinme Date: Mon, 8 Jan 2018 18:12:15 +0000 Subject: [PATCH 013/507] add other vis.checkers --- cortexlab/+vis/checker4.m | 126 ++++++++++++++++++++++++++++++++++++++ cortexlab/+vis/checker5.m | 126 ++++++++++++++++++++++++++++++++++++++ cortexlab/+vis/checker6.m | 126 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 378 insertions(+) create mode 100644 cortexlab/+vis/checker4.m create mode 100644 cortexlab/+vis/checker5.m create mode 100644 cortexlab/+vis/checker6.m diff --git a/cortexlab/+vis/checker4.m b/cortexlab/+vis/checker4.m new file mode 100644 index 00000000..be837609 --- /dev/null +++ b/cortexlab/+vis/checker4.m @@ -0,0 +1,126 @@ +function elem = checker3(t) +%vis.checker A grid of rectangles +% Detailed explanation goes here + +elem = t.Node.Net.subscriptableOrigin('checker'); + +%% make initial layers to be used as templates +maskTemplate = vis.emptyLayer(); +maskTemplate.isPeriodic = false; +maskTemplate.interpolation = 'nearest'; +maskTemplate.show = true; +maskTemplate.colourMask = [false false false true]; + +maskTemplate.textureId = 'checkerMaskPixel'; +[maskTemplate.rgba, maskTemplate.rgbaSize] = vis.rgba(0, 0); +maskTemplate.blending = '1-source'; % allows us to lay down our zero alpha value + +stencilTemplate = maskTemplate; +stencilTemplate.textureId = 'checkerStencilPixel'; +[stencilTemplate.rgba, stencilTemplate.rgbaSize] = vis.rgba(1, 1); +stencilTemplate.blending = 'none'; + +% pattern layer uses the alpha values laid down by mask layers +patternLayer = vis.emptyLayer(); +patternLayer.textureId = sprintf('~checker%i', randi(2^32)); +patternLayer.isPeriodic = false; +patternLayer.interpolation = 'nearest'; +patternLayer.blending = 'destination'; % use the alpha mask gets laid down before this + +%% construct signals used to assemble layers +% N rows by cols signal is derived from the size of the pattern array but +% we skip repeats so that pattern changes don't update the mask layers +% unless the size has acutally changed +nRowsByCols = elem.pattern.flatten().map(@size).skipRepeats(); +aziRange = elem.azimuthRange.flatten(); +altRange = elem.altitudeRange.flatten(); +sizeFrac = elem.rectSizeFrac.flatten(); +% signal containing the masking layers +gridMaskLayers = mapn(nRowsByCols, aziRange, altRange, sizeFrac, ... + maskTemplate, stencilTemplate, @gridMask); +% signal contain the checker layer +checkerLayer = scan(elem.pattern.flatten(), @updatePattern,... + elem.colour.flatten(), @updateColour,... + elem.azimuthRange.flatten(), @updateAzi,... + elem.altitudeRange.flatten(), @updateAlt,... + elem.show.flatten(), @updateShow,... + patternLayer); % initial value +%% set default attribute values +elem.layers = [gridMaskLayers checkerLayer]; +elem.azimuthRange = [-132 132]; +elem.altitudeRange = [-36 36]; +elem.rectSizeFrac = [1 1]; % horizontal and vertical size of each rectangle +elem.pattern = [ + 1 -1 1 -1 + -1 0 0 0 + 1 0 0 0 + -1 1 -1 1]; + elem.show = true; +end + +%% helper functions +function layer = updatePattern(layer, pattern) +% map pattern from -1 -> 1 range to 0->255, cast to 8 bit integers, then +% convert to RGBA texture format. +[layer.rgba, layer.rgbaSize] = vis.rgbaFromUint8(uint8(127.5*(1 + pattern)), 1); +end + +function layer = updateColour(layer, colour) +layer.maxColour = [colour 1]; +end + +function layer = updateAzi(layer, aziRange) +layer.size(1) = abs(diff(aziRange)); +layer.texOffset(1) = mean(aziRange); +end + +function layer = updateAlt(layer, altRange) +layer.size(2) = abs(diff(altRange)); +layer.texOffset(2) = mean(altRange); +end + +function layer = updateShow(layer, show) +layer.show = show; +end + +function layers = gridMask(nRowsByCols, aziRange, altRange, sizeFrac, mask, stencil) +gridDims = [abs(diff(aziRange)) abs(diff(altRange))]; +cellSize = gridDims./flip(nRowsByCols); +nCols = nRowsByCols(2) + 1; +nRows = nRowsByCols(1) + 1; +midAzi = mean(aziRange); +midAlt = mean(altRange); +%% base layer to imprint area the checker can draw on (by applying an alpha mask) +stencil.texOffset = [midAzi midAlt]; +stencil.size = gridDims; +if any(sizeFrac < 1) + %% layers for lines making up mask grid - masks out margins around each square + % make layers for vertical lines + if nCols > 1 + azi = linspace(aziRange(1), aziRange(2), nCols); + else + azi = midAzi; + end + collayers = repmat(mask, 1, nCols); + for vi = 1:nCols + collayers(vi).texOffset = [azi(vi) midAlt]; + end + [collayers.size] = deal([(1 - sizeFrac(1))*cellSize(1) gridDims(2)]); + % make layers for horizontal lines + if nRows > 1 + alt = linspace(altRange(1), altRange(2), nRows); + else + alt = midAlt; + end + rowlayers = repmat(mask, 1, nRows); + for hi = 1:nRows + rowlayers(hi).texOffset = [midAzi alt(hi)]; + end + [rowlayers.size] = deal([gridDims(1) (1 - sizeFrac(2))*cellSize(2)]); + %% combine the layers and return + layers = [stencil collayers rowlayers]; +else % no mask grid needed as each cell is full size + layers = stencil; +end + +end \ No newline at end of file diff --git a/cortexlab/+vis/checker5.m b/cortexlab/+vis/checker5.m new file mode 100644 index 00000000..be37a1f4 --- /dev/null +++ b/cortexlab/+vis/checker5.m @@ -0,0 +1,126 @@ +function elem = checker5(t) +%vis.checker A grid of rectangles +% Detailed explanation goes here + +elem = t.Node.Net.subscriptableOrigin('checker'); + +%% make initial layers to be used as templates +maskTemplate = vis.emptyLayer(); +maskTemplate.isPeriodic = false; +maskTemplate.interpolation = 'nearest'; +maskTemplate.show = true; +maskTemplate.colourMask = [false false false true]; + +maskTemplate.textureId = 'checkerMaskPixel'; +[maskTemplate.rgba, maskTemplate.rgbaSize] = vis.rgba(0, 0); +maskTemplate.blending = '1-source'; % allows us to lay down our zero alpha value + +stencilTemplate = maskTemplate; +stencilTemplate.textureId = 'checkerStencilPixel'; +[stencilTemplate.rgba, stencilTemplate.rgbaSize] = vis.rgba(1, 1); +stencilTemplate.blending = 'none'; + +% pattern layer uses the alpha values laid down by mask layers +patternLayer = vis.emptyLayer(); +patternLayer.textureId = sprintf('~checker%i', randi(2^32)); +patternLayer.isPeriodic = false; +patternLayer.interpolation = 'nearest'; +patternLayer.blending = 'destination'; % use the alpha mask gets laid down before this + +%% construct signals used to assemble layers +% N rows by cols signal is derived from the size of the pattern array but +% we skip repeats so that pattern changes don't update the mask layers +% unless the size has acutally changed +nRowsByCols = elem.pattern.flatten().map(@size).skipRepeats(); +aziRange = elem.azimuthRange.flatten(); +altRange = elem.altitudeRange.flatten(); +sizeFrac = elem.rectSizeFrac.flatten(); +% signal containing the masking layers +gridMaskLayers = mapn(nRowsByCols, aziRange, altRange, sizeFrac, ... + maskTemplate, stencilTemplate, @gridMask); +% signal contain the checker layer +checkerLayer = scan(elem.pattern.flatten(), @updatePattern,... + elem.colour.flatten(), @updateColour,... + elem.azimuthRange.flatten(), @updateAzi,... + elem.altitudeRange.flatten(), @updateAlt,... + elem.show.flatten(), @updateShow,... + patternLayer); % initial value +%% set default attribute values +elem.layers = [gridMaskLayers checkerLayer]; +elem.azimuthRange = [-132 132]; +elem.altitudeRange = [-36 36]; +elem.rectSizeFrac = [1 1]; % horizontal and vertical size of each rectangle +elem.pattern = [ + 1 -1 1 -1 + -1 0 0 0 + 1 0 0 0 + -1 1 -1 1]; + elem.show = true; +end + +%% helper functions +function layer = updatePattern(layer, pattern) +% map pattern from -1 -> 1 range to 0->255, cast to 8 bit integers, then +% convert to RGBA texture format. +[layer.rgba, layer.rgbaSize] = vis.rgbaFromUint8_PC(uint8(127.5*(1 + pattern)), 1); +end + +function layer = updateColour(layer, colour) +layer.maxColour = [colour 1]; +end + +function layer = updateAzi(layer, aziRange) +layer.size(1) = abs(diff(aziRange)); +layer.texOffset(1) = mean(aziRange); +end + +function layer = updateAlt(layer, altRange) +layer.size(2) = abs(diff(altRange)); +layer.texOffset(2) = mean(altRange); +end + +function layer = updateShow(layer, show) +layer.show = show; +end + +function layers = gridMask(nRowsByCols, aziRange, altRange, sizeFrac, mask, stencil) +gridDims = [abs(diff(aziRange)) abs(diff(altRange))]; +cellSize = gridDims./fliplr(nRowsByCols); +nCols = nRowsByCols(2) + 1; +nRows = nRowsByCols(1) + 1; +midAzi = mean(aziRange); +midAlt = mean(altRange); +%% base layer to imprint area the checker can draw on (by applying an alpha mask) +stencil.texOffset = [midAzi midAlt]; +stencil.size = gridDims; +if any(sizeFrac < 1) + %% layers for lines making up mask grid - masks out margins around each square + % make layers for vertical lines + if nCols > 1 + azi = linspace(aziRange(1), aziRange(2), nCols); + else + azi = midAzi; + end + collayers = repmat(mask, 1, nCols); + for vi = 1:nCols + collayers(vi).texOffset = [azi(vi) midAlt]; + end + [collayers.size] = deal([(1 - sizeFrac(1))*cellSize(1) gridDims(2)]); + % make layers for horizontal lines + if nRows > 1 + alt = linspace(altRange(1), altRange(2), nRows); + else + alt = midAlt; + end + rowlayers = repmat(mask, 1, nRows); + for hi = 1:nRows + rowlayers(hi).texOffset = [midAzi alt(hi)]; + end + [rowlayers.size] = deal([gridDims(1) (1 - sizeFrac(2))*cellSize(2)]); + %% combine the layers and return + layers = [stencil collayers rowlayers]; +else % no mask grid needed as each cell is full size + layers = stencil; +end + +end \ No newline at end of file diff --git a/cortexlab/+vis/checker6.m b/cortexlab/+vis/checker6.m new file mode 100644 index 00000000..4901b88c --- /dev/null +++ b/cortexlab/+vis/checker6.m @@ -0,0 +1,126 @@ +function elem = checker3(t) +%vis.checker A grid of rectangles +% Detailed explanation goes here + +elem = t.Node.Net.subscriptableOrigin('checker'); + +%% make initial layers to be used as templates +maskTemplate = vis.emptyLayer(); +maskTemplate.isPeriodic = false; +maskTemplate.interpolation = 'nearest'; +maskTemplate.show = true; +maskTemplate.colourMask = [false false false true]; + +maskTemplate.textureId = 'checkerMaskPixel'; +[maskTemplate.rgba, maskTemplate.rgbaSize] = vis.rgba(0, 0); +maskTemplate.blending = '1-source'; % allows us to lay down our zero alpha value + +stencilTemplate = maskTemplate; +stencilTemplate.textureId = 'checkerStencilPixel'; +[stencilTemplate.rgba, stencilTemplate.rgbaSize] = vis.rgba(1, 1); +stencilTemplate.blending = 'none'; + +% pattern layer uses the alpha values laid down by mask layers +patternLayer = vis.emptyLayer(); +patternLayer.textureId = sprintf('~checker%i', randi(2^32)); +patternLayer.isPeriodic = false; +patternLayer.interpolation = 'nearest'; +patternLayer.blending = 'destination'; % use the alpha mask gets laid down before this + +%% construct signals used to assemble layers +% N rows by cols signal is derived from the size of the pattern array but +% we skip repeats so that pattern changes don't update the mask layers +% unless the size has acutally changed +nRowsByCols = elem.pattern.flatten().map(@size).skipRepeats(); +aziRange = elem.azimuthRange.flatten(); +altRange = elem.altitudeRange.flatten(); +sizeFrac = elem.rectSizeFrac.flatten(); +% signal containing the masking layers +gridMaskLayers = mapn(nRowsByCols, aziRange, altRange, sizeFrac, ... + maskTemplate, stencilTemplate, @gridMask); +% signal contain the checker layer +checkerLayer = scan(elem.pattern.flatten(), @updatePattern,... + elem.colour.flatten(), @updateColour,... + elem.azimuthRange.flatten(), @updateAzi,... + elem.altitudeRange.flatten(), @updateAlt,... + elem.show.flatten(), @updateShow,... + patternLayer); % initial value +%% set default attribute values +elem.layers = [gridMaskLayers checkerLayer]; +elem.azimuthRange = [-135 135]; +elem.altitudeRange = [-37.5 37.5]; +elem.rectSizeFrac = [1 1]; % horizontal and vertical size of each rectangle +elem.pattern = [ + 1 -1 1 -1 + -1 0 0 0 + 1 0 0 0 + -1 1 -1 1]; + elem.show = true; +end + +%% helper functions +function layer = updatePattern(layer, pattern) +% map pattern from -1 -> 1 range to 0->255, cast to 8 bit integers, then +% convert to RGBA texture format. +[layer.rgba, layer.rgbaSize] = vis.rgbaFromUint8(uint8(127.5*(1 + pattern)), 1); +end + +function layer = updateColour(layer, colour) +layer.maxColour = [colour 1]; +end + +function layer = updateAzi(layer, aziRange) +layer.size(1) = abs(diff(aziRange)); +layer.texOffset(1) = mean(aziRange); +end + +function layer = updateAlt(layer, altRange) +layer.size(2) = abs(diff(altRange)); +layer.texOffset(2) = mean(altRange); +end + +function layer = updateShow(layer, show) +layer.show = show; +end + +function layers = gridMask(nRowsByCols, aziRange, altRange, sizeFrac, mask, stencil) +gridDims = [abs(diff(aziRange)) abs(diff(altRange))]; +cellSize = gridDims./flip(nRowsByCols); +nCols = nRowsByCols(2) + 1; +nRows = nRowsByCols(1) + 1; +midAzi = mean(aziRange); +midAlt = mean(altRange); +%% base layer to imprint area the checker can draw on (by applying an alpha mask) +stencil.texOffset = [midAzi midAlt]; +stencil.size = gridDims; +if any(sizeFrac < 1) + %% layers for lines making up mask grid - masks out margins around each square + % make layers for vertical lines + if nCols > 1 + azi = linspace(aziRange(1), aziRange(2), nCols); + else + azi = midAzi; + end + collayers = repmat(mask, 1, nCols); + for vi = 1:nCols + collayers(vi).texOffset = [azi(vi) midAlt]; + end + [collayers.size] = deal([(1 - sizeFrac(1))*cellSize(1) gridDims(2)]); + % make layers for horizontal lines + if nRows > 1 + alt = linspace(altRange(1), altRange(2), nRows); + else + alt = midAlt; + end + rowlayers = repmat(mask, 1, nRows); + for hi = 1:nRows + rowlayers(hi).texOffset = [midAzi alt(hi)]; + end + [rowlayers.size] = deal([gridDims(1) (1 - sizeFrac(2))*cellSize(2)]); + %% combine the layers and return + layers = [stencil collayers rowlayers]; +else % no mask grid needed as each cell is full size + layers = stencil; +end + +end \ No newline at end of file From 84c9a2e3218f5bd79cb6fbe0c3d0b5b041d6775f Mon Sep 17 00:00:00 2001 From: nsteinme Date: Mon, 8 Jan 2018 20:02:36 +0000 Subject: [PATCH 014/507] update tlserver to send alyx info by websockets --- cortexlab/+tl/bindMpepServerWithWS.m | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/cortexlab/+tl/bindMpepServerWithWS.m b/cortexlab/+tl/bindMpepServerWithWS.m index 187583cf..b6401089 100644 --- a/cortexlab/+tl/bindMpepServerWithWS.m +++ b/cortexlab/+tl/bindMpepServerWithWS.m @@ -37,6 +37,7 @@ tls.close = @closeConns; tls.process = @process; tls.listen = @listen; +tls.AlyxInstance = []; listenPort = io.WSJCommunicator.DefaultListenPort; communicator = io.WSJCommunicator.server(listenPort); @@ -82,20 +83,26 @@ function processMpep(listener, msg) ipstr = sprintf('%i.%i.%i.%i', ip{:}); log('%s: ''%s'' from %s:%i', listener.name, msg, ipstr, port); % parse the message - info = dat.mpepMessageParse(msg); - - % !!! Get alyx instance here!! - ai = []; + info = dat.mpepMessageParse(msg); failed = false; % flag for preventing UDP echo %% Experiment-level events start/stop timeline switch lower(info.instruction) + case 'alyx' + fprintf(1, 'received alyx token message\n'); + idx = find(msg==' ', 1, 'last'); + [expref, ai] = dat.parseAlyxInstance(msg(idx+1:end)); + disp(ai) + + tls.AlyxInstance = ai; case 'expstart' % create a file path & experiment ref based on experiment info try % start Timeline + communicator.send('AlyxSend', {tls.AlyxInstance}); communicator.send('status', { 'starting', info.expRef}); - tlObj.start(info.expRef, ai); + + tlObj.start(info.expRef, tls.AlyxInstance); % re-record the UDP event in Timeline since it wasn't started % when we tried earlier. Treat it as having arrived at time zero. tlObj.record('mpepUDP', msg, 0); From 566884dd8b79cc05f829f551061864cdf9117579 Mon Sep 17 00:00:00 2001 From: nsteinme Date: Tue, 9 Jan 2018 11:58:02 +0000 Subject: [PATCH 015/507] add docs --- +dat/findNextSeqNum.m | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/+dat/findNextSeqNum.m b/+dat/findNextSeqNum.m index 45450073..f5f40796 100644 --- a/+dat/findNextSeqNum.m +++ b/+dat/findNextSeqNum.m @@ -1,9 +1,14 @@ function expSeq = findNextSeqNum(subject, varargin) -% function expSeq = findNextSeqNum(subject[, date]) +% expSeq = findNextSeqNum(subject[, date]) +% +% Returns the next experiment number (aka Sequence number) that should be +% chosen for the given subject. Optionally specify a particular date to +% consider. + if isempty(varargin) - expDate = now; + expDate = now; %default to today else expDate = varargin{1}; end From 6e274b15029924cab9e64b16800d074ab51ae796 Mon Sep 17 00:00:00 2001 From: nsteinme Date: Tue, 9 Jan 2018 12:20:39 +0000 Subject: [PATCH 016/507] move whichExpNums to cortexlab, update dat.listSubjects and .subjectSelector, add docs --- +dat/listSubjects.m | 44 ++++++++++++++++++++----- +dat/subjectSelector.m | 22 +++++-------- {+dat => cortexlab/+dat}/whichExpNums.m | 23 +++++++++++-- 3 files changed, 63 insertions(+), 26 deletions(-) rename {+dat => cortexlab/+dat}/whichExpNums.m (63%) diff --git a/+dat/listSubjects.m b/+dat/listSubjects.m index f3aff6ec..dbac4793 100644 --- a/+dat/listSubjects.m +++ b/+dat/listSubjects.m @@ -1,17 +1,43 @@ -function subjects = listSubjects() +function subjects = listSubjects(varargin) %DAT.LISTSUBJECTS Lists recorded subjects -% subjects = DAT.LISTSUBJECTS() Lists the experimental subjects present +% subjects = DAT.LISTSUBJECTS([alyxInstance]) Lists the experimental subjects present % in experiment info repository ('expInfo'). % +% Optional input argument of an alyx instance will enable generating this +% list from alyx rather than from the directory structure on zserver +% % Part of Rigbox % 2013-03 CB created +% 2018-01 NS added alyx compatibility -% The master 'expInfo' 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 - +if nargin>0 && ~isempty(varargin{1}) % user provided an alyx instance + ai = varargin{1}; % an alyx instance + + % get list of all living, non-stock mice from alyx + s = alyx.getData(ai, 'subjects?stock=False&alive=True'); + + % determine the user for each mouse + respUser = cellfun(@(x)x.responsible_user, s, 'uni', false); + + % get cell array of subject names + subjNames = cellfun(@(x)x.nickname, s, 'uni', false); + + % determine which subjects belong to this user + thisUserSubs = sort(subjNames(strcmp(respUser, ai.username))); + + % all the subjects + otherUserSubs = sort(subjNames(~strcmp(respUser, ai.username))); + + % the full, ordered list + subjects = [{'default'}, thisUserSubs, otherUserSubs]'; +else + + % The master 'expInfo' 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 +end end \ No newline at end of file diff --git a/+dat/subjectSelector.m b/+dat/subjectSelector.m index 8437dad5..23df89b9 100644 --- a/+dat/subjectSelector.m +++ b/+dat/subjectSelector.m @@ -4,6 +4,12 @@ % % If you provide an alyxInstance, it will populate with a list of subjects % from alyx; otherwise, from dat.listSubjects +% +% example usage: +% >> alyxInstance = alyx.loginWindow(); +% >> [subj, expNum] = subjectSelector([], alyxInstance); +% +% Created by NS 2017 subjectName = []; expNum = 1; @@ -37,20 +43,8 @@ if nargin>1 ai = varargin{2}; - - 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, ai.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]; - set(subjectDropdown, 'String', newSubs); + + set(subjectDropdown, 'String', dat.listSubjects(ai)); else set(subjectDropdown, 'String', dat.listSubjects); end diff --git a/+dat/whichExpNums.m b/cortexlab/+dat/whichExpNums.m similarity index 63% rename from +dat/whichExpNums.m rename to cortexlab/+dat/whichExpNums.m index 2cc4f05d..68127da9 100644 --- a/+dat/whichExpNums.m +++ b/cortexlab/+dat/whichExpNums.m @@ -1,6 +1,22 @@ function [expNums, blocks, hasBlock, pars, isMpep, tl, hasTimeline] = ... whichExpNums(mouseName, thisDate) - +% [expNums, blocks, hasBlock, pars, isMpep, tl, hasTimeline] = ... +% whichExpNums(mouseName, thisDate) +% +% Attempt to automatically determine what experiments of what types were +% run for a subject on a given date. +% +% Returns: +% - expNums - list of the experiment numbers that exist +% - blocks - cell array of the Block structs +% - hasBlock - boolean array indicating whether each experiment had a block +% - pars - cell array of parameters structs +% - isMpep - boolean array of whether the experiment was mpep type +% - tl - cell array of Timeline structs +% - hasTimeline - boolean array of whether timeline was present for each +% experiment +% +% Created by NS 2017 rootExp = dat.expFilePath(mouseName, thisDate, 1, 'Timeline', 'master'); expInf = fileparts(fileparts(rootExp)); @@ -26,6 +42,8 @@ hasBlock(e) = true; end + % if there is a parameters file, load it and determine whether it is + % mpep type dPars = dat.expFilePath(mouseName, thisDate, expNums(e), 'parameters', 'master'); if exist(dPars) load(dPars) @@ -37,8 +55,7 @@ end - % if there is a timeline, load it and get photodiode events, mpep UDP - % events. + % if there is a timeline, load it dTL = dat.expFilePath(mouseName, thisDate, expNums(e), 'Timeline', 'master'); if exist(dTL) fprintf(1, 'expNum %d has timeline\n', e); From 2c2c57f5ba82a8da7b1595a5ea7f49e171f1561f Mon Sep 17 00:00:00 2001 From: nsteinme Date: Wed, 10 Jan 2018 10:41:41 +0000 Subject: [PATCH 017/507] update eyetracking path --- +dat/paths.m | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/+dat/paths.m b/+dat/paths.m index 89539761..dc0997ca 100644 --- a/+dat/paths.m +++ b/+dat/paths.m @@ -37,7 +37,8 @@ % for calcium widefield imaging p.widefieldRepository = fullfile(server1Name, 'data', 'GCAMP'); % Repository for storing eye tracking movies -p.eyeTrackingRepository = fullfile(server1Name, 'data', 'EyeCamera'); +% p.eyeTrackingRepository = fullfile(server1Name, 'data', 'EyeCamera'); +p.eyeTrackingRepository = p.mainRepository; % electrophys repositories p.lfpRepository = fullfile(server1Name, 'Data', 'Cerebus'); From fd9e21f44e6f9395dc25bdbbc8ad559006602e13 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 10 Jan 2018 13:46:49 +0000 Subject: [PATCH 018/507] Updated readme Readme is now a little more thorough and better formatted --- +dat/parseAlyxInstance.m | 6 +- +srv/RemoteMPEPService.m | 303 ----------------------------- +srv/expServer.m | 2 +- cortexlab/+srv/RemoteMPEPService.m | 263 +++++++++++++++---------- readme.txt | 102 ++++++++-- 5 files changed, 251 insertions(+), 425 deletions(-) delete mode 100644 +srv/RemoteMPEPService.m diff --git a/+dat/parseAlyxInstance.m b/+dat/parseAlyxInstance.m index 1fb53e93..2eb09711 100644 --- a/+dat/parseAlyxInstance.m +++ b/+dat/parseAlyxInstance.m @@ -22,9 +22,9 @@ if isfield(ai, 'water_requirement_remaining') ai = rmfield(ai, 'water_requirement_remaining'); end - fnai = fieldnames(ai); - ise = cellfun(@(fn)isempty(ai.(fn)), fnai); - if any(ise); ai = rmfield(ai, fnai(ise)); end; + fname = fieldnames(ai); % get fieldnames + emp = structfun(@isempty, ai); % find empty fields + if any(emp); ai = rmfield(ai, fname(emp)); end % remove the empty fields 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 diff --git a/+srv/RemoteMPEPService.m b/+srv/RemoteMPEPService.m deleted file mode 100644 index 01e86853..00000000 --- a/+srv/RemoteMPEPService.m +++ /dev/null @@ -1,303 +0,0 @@ -classdef RemoteMPEPService < srv.Service - %SRV.REMOTETLSERVICE UDP-based service for starting and stopping Timeline - % A UDP interface that uses the udp function of the Instument Control - % Toolbox. Unlike SRV.PRIMITIVEUDPSERVICE, this can send and recieve - % messages asynchronously and can be used both to start remote services - % and, service side, to listen for remote start/stop commands. - % - % To send a message simply use sentUDP(msg). Use confirmedSend(msg) to - % send send a message and await a confirmation (the same message echoed - % back). To receive messaged only, simply use bind() and add a - % listener to the MessageReceived event. - % - % Examples: - % remoteTL = srv.BasicUDPService('tl-host', 10000, 10000); - % remoteTL.start('2017-10-27-1-default'); % Start remote service with - % an experiment reference - % remoteTL.stop; remoteTL.delete; % Clean up after stopping remote - % rig - % - % experimentRig = srv.BasicUDPService('mainRigHostName', 10000, 10000); - % experimentRig.bind(); % Connect to the remote rig - % remoteStatus = requestStatus(experimentRig); % Get the status of - % the experimental rig - % lh = events.listener(experimentRig, 'MessageReceived', - % @(srv, evt)processMessage(srv, evt)); % Add a listener to do - % something when a message is received. - % - % See also SRV.PRIMITIVEUDPSERVICE, UDP. - % - % Part of Rigbox - - % 2017-10 MW created - - properties (GetObservable, SetAccess = protected) - Status % Status of remote service - end - - properties - LocalStatus % Local status to send upon request - ResponseTimeout = Inf % How long to wait for confirmation of receipt - Timeline % Holds an instance of Timeline - Callbacks % Holds callback functions for each instruction - end - - properties (SetObservable, AbortSet = true) - RemoteHost % Host name of the remote service - ListenPort = 10000 % Localhost port number to listen for messages on - RemotePort = 10000 % Which port to send messages to remote service on - EnablePortSharing = 'off' % If set to 'on' other applications can use the listen port - end - - properties (SetAccess = protected) - RemoteIP % The IP address of the remote service - Socket % A handle to the udp object - LastSentMessage = '' % A copy of the message sent from this host - LastReceivedMessage = '' % A copy of the message received by this host - end - - properties (Access = private) - Listener % A listener for the MessageReceived event - ResponseTimer % A timer object set when expecting a confirmation message (if ResponseTimeout < Inf) - AwaitingConfirmation = false % True when awaiting a confirmation message - ConfirmID % A random integer to confirm UDP status response. See requestStatus() - end - - events (NotifyAccess = 'protected') - MessageReceived % Notified by receiveUDP() when a UDP message is received - end - - methods - function delete(obj) - % To be called before destroying BasicUDPService object. Deletes all - % timers, sockets and listeners Tidy up after ourselves by closing - % the listening sockets - if ~isempty(obj.Socket) - fclose(obj.Socket); % Close the connection - delete(obj.Socket); % Delete the socket - obj.Socket = []; % Delete udp object - obj.Listener = []; % Delete any listeners to that object - if ~isempty(obj.ResponseTimer) % If there is a timer object - stop(obj.ResponseTimer) % Stop the timer.. - delete(obj.ResponseTimer) % Delete the timer... - obj.ResponseTimer = []; % ... and remove it - end - end - end - - function obj = RemoteMPEPService(remoteHost, remotePort, listenPort) - % SRV.REMOTETLSERVICE([remoteHost, remotePort, listenPort]) - % remoteHost is the hostname of the service with which to send and - % receive messages. - paths = dat.paths(hostname); % Get list of paths for timeline - obj.Callbacks = struct('Instruction', {'ExpStart', 'BlockStart',... - 'StimStart', 'StimEnd', 'BlockEnd', 'ExpEnd', 'ExpInterrupt'}, 'Callback', @nop); - obj.Timeline = load(fullfile(paths.rigConfig, 'hardware.mat'), 'timeline'); % Load timeline object - if nargin < 1; remoteHost = ''; end % Set local port - obj.RemoteHost = remoteHost; % Set hostname - obj.RemoteIP = ipaddress(remoteHost); % Get IP address - if nargin >= 3; obj.ListenPort = listenPort; end % Set local port - if nargin >= 2; obj.RemotePort = remotePort; end % Set remote port - obj.Socket = udp(obj.RemoteIP,... % Create udp object - 'RemotePort', obj.RemotePort, 'LocalPort', obj.ListenPort); - obj.Socket.ReadAsyncMode = 'continuous'; - obj.Socket.BytesAvailableFcnCount = 10; % Number of bytes in buffer required to trigger BytesAvailableFcn - obj.Socket.BytesAvailableFcn = @obj.receiveUDP; % Add callback to receiveUDP when enough bytes arrive in the buffer - % Add listener to MessageReceived event, notified when receiveUDP is - % called. This event can be listened to by anyone. - obj.Listener = event.listener(obj, 'MessageReceived', @processMsg); - % Add listener for when the observable properties are set - obj.addlistener({'RemoteHost', 'ListenPort', 'RemotePort', 'EnablePortSharing'},... - 'PostSet',@(src,~)obj.update(src)); - % Add listener for when the remote service's status is requested - obj.addlistener('Status', 'PreGet', @obj.requestStatus); - end - - function obj = addListener(obj, name, listenPort, callback) - if nargin<3; callback = @nop; end - if listenPort==obj.ListenPorts - error('Listen port already added'); - end - obj.Sockets(end+1) = udp(obj.RemoteIP, 'RemotePort', obj.RemotePort, 'LocalPort', listenPort); - obj.Sockets(end).BytesAvailableFcn = @(~,~)obj.receiveUDP(src,evt); - obj.Sockets(end).Tag = name; - obj.ListenPorts(end+1) = listenPort; - obj.Callbacks(end+1) = callback; - end - - function update(obj, src) - % Callback for setting udp relevant properties. Some properties can - % only be set when the socket is closed. - % Check if socket is open - isOpen = strcmp(obj.Socket.Status, 'open'); - % Close connection before setting, if required to do so - if any(strcmp(src.name, {'RemoteHost', 'LocalPort', 'EnablePortSharing'}))&&isOpen - fclose(obj.Socket); - end - % Set all the relevant properties - obj.RemoteIP = ipaddress(obj.RemoteHost); - obj.Socket.LocalPort = obj.ListenPort; - obj.Socket.RemotePort = obj.RemotePort; - obj.Socket.RemoteHost = obj.RemoteIP; - if isOpen; bind(obj); end % If socket was open before, re-open - end - - function bind(obj, names) - if isempty(obj.Sockets) - warning('No sockets to bind') - return - end - if nargin<2 - % Close all sockets, in case they are open - arrayfun(@fclose, obj.Sockets) - % Open the connection to allow messages to be sent and received - arrayfun(@fopen, obj.Sockets) - else - names = ensureCell(names); - hosts = arrayfun(@(s)s.Tag, obj.Sockets); - idx = cellfun(@(n)find(strcmp(n,hosts)), names); - arrayfun(@fopen, obj.Sockets(idx)) - end - end - - function start(obj, ref) - % Send start message to remotehost and await confirmation - [expRef, AlyxInstance] = parseAlyxInstance(ref); - % Convert expRef to MPEP style - [subject, seriesNum, expNum] = dat.expRefToMpep(expRef); - % Build start message - msg = sprintf('ExpStart %s %d %d', subject, seriesNum, expNum); - % Send the start message - obj.confirmedSend(msg, obj.RemoteHost); - % Wait for response - while obj.AwaitingConfirmation; pause(0.2); end -% % Start a block (we only use one per experiment) -% msg = sprintf('BlockStart %s %d %d 1', subject, seriesNum, expNum); -% obj.confirmedSend(msg, obj.RemoteHost); -% % Wait for response -% while obj.AwaitingConfirmation; pause(0.2); end - end - - function stop(obj) - % Send stop message to remotehost and await confirmation - obj.confirmedSend(sprintf('STOP*%s', obj.RemoteHost)); - end - - function requestStatus(obj) - % Request a status update from the remote service - obj.ConfirmID = randi(1e6); - obj.sendUDP(sprintf('WHAT%i*%s', obj.ConfirmID, obj.RemoteHost)); - disp('Requested status update from remote service') - end - - function confirmedSend(obj, msg) - sendUDP(obj, msg) - obj.AwaitingConfirmation = true; - % Add timer to impose a response timeout - if ~isinf(obj.ResponseTimeout) - obj.ResponseTimer = timer('StartDelay', obj.ResponseTimout,... - 'TimerFcn', @(~,~)obj.processMsg); - start(obj.ResponseTimer) % start the timer - end - end - - function receiveUDP(obj, src, evt) - obj.LastReceivedMessage = strtrim(fscanf(obj.Socket)); - notify(obj, 'MessageReceived') - hosts = arrayfun(@(s)s.Tag, obj.Sockets); - feval(obj.Callbacks{strcmp(hosts, src.Tag)}, src, evt); - end - - function sendUDP(obj, msg) - % Ensure socket is open before sending message - if strcmp(obj.Socket.Status, 'closed'); bind(obj); end - fprintf(obj.Socket, msg); % Send message - obj.LastSentMessage = msg; % Save a copy of the message - disp(['Sent message to ' obj.RemoteHost]) % Display success - end - - function echo(obj, src, ~) - % Echo message - fclose(src); - src.RemoteHost = src.DatagramAddress; - src.RemotePort = src.DatagramPort; - fopen(src); - fprintf(obj.Socket, obj.LastReceivedMessage); % Send message - obj.LastSentMessage = obj.LastReceivedMessage; % Save a copy of the message - disp(['Echo''d message to ' src.Tag]) % Display success - end - end - - methods (Access = protected) - function processMPEPMsg(obj, src, ~) - % Parse the message into its constituent parts - msg = dat.mpepMessageParse(obj.LastReceivedMessage); - % Check that the message was from the correct host, otherwise ignore - if strcmp(response.host, obj.RemoteHost) - warning('Received message from %s, ignoring', response.host); - return - end - if obj.AwaitingConfirmation - % Check the confirmation message is the same as the sent message - assert(~isempty(response)||... % something received - strcmp(response.status, 'WHAT')||... % status update - strcmp(obj.LastReceivedMessage, obj.LastSentMessage),... % is echo - 'Confirmation failed') - % We no longer need the timer, stop and delete it - if ~isempty(obj.ResponseTimer) - stop(obj.ResponseTimer) - delete(obj.ResponseTimer) - obj.ResponseTimer = []; - end - end - % At the moment we just disply some stuff, other functions listening - % to the MessageReceived event can do their thing - switch response.status - case 'GOGO' - if obj.AwaitingConfirmation - obj.Status = 'running'; - disp(['Service on ' obj.RemoteHost ' running']) - else - disp('Received start request') - obj.LocalStatus = 'starting'; - obj.Timeline.start(dat.parseAlyxInstance(response.body)) - obj.LocalStatus = 'running'; - obj.sendUDP(obj.LastReceivedMessage) - end - case 'STOP' - if obj.AwaitingConfirmation - obj.Status = 'stopped'; - disp(['Service on ' obj.RemoteHost ' stopped']) - else - disp('Received stop request') - obj.LocalStatus = 'stopping'; - obj.Timeline.stop - obj.sendUDP(obj.LastReceivedMessage) - end - case 'WHAT' - % TODO fix status updates so that they're meaningful - parsed = regexp(response.body, '(?\d+)(?[a-z]*)', 'names'); - if obj.AwaitingConfirmation - try - assert(strcmp(parsed.id, int2str(obj.ConfirmID)), 'Rigbox:srv:unexpectedUDPResponse',... - 'Received UDP message ID did not match sent'); - switch parsed.update - case {'running' 'starting'} - obj.Status = 'running'; - otherwise - obj.Status = 'idle'; - end - catch - obj.Status = 'unavailable'; - end - else % Received status request NB: Currently no way of determining status - obj.sendUDP([parsed.status parsed.id obj.LocalStatus]) - end - otherwise - disp(['Received ''' obj.LastReceivedMessage ''' from ' obj.RemoteHost]) - end - % Reset AwaitingConfirmation - obj.AwaitingConfirmation = false; - end - end -end \ No newline at end of file diff --git a/+srv/expServer.m b/+srv/expServer.m index e629a017..d954527c 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -169,7 +169,7 @@ function handleMessage(id, data, host) end case 'run' % exp run request - [expRef, preDelay, postDelay, Alyx] = args{:}; + [expRef, preDelay, postDelay, Alyx] = args{:}; if dat.expExists(expRef) log('Starting experiment ''%s''', expRef); communicator.send(id, []); diff --git a/cortexlab/+srv/RemoteMPEPService.m b/cortexlab/+srv/RemoteMPEPService.m index baa2281e..e71acd0d 100644 --- a/cortexlab/+srv/RemoteMPEPService.m +++ b/cortexlab/+srv/RemoteMPEPService.m @@ -39,19 +39,19 @@ LocalStatus % Local status to send upon request ResponseTimeout = Inf % How long to wait for confirmation of receipt Timeline % Holds an instance of Timeline - Callbacks % Holds callback functions for each instruction + Callbacks = {@obj.processMsg, @nop} % Holds callback functions for each instruction end properties (SetObservable, AbortSet = true) RemoteHost % Host name of the remote service - ListenPort = 10000 % Localhost port number to listen for messages on - RemotePort = 10000 % Which port to send messages to remote service on + ListenPorts % Localhost port number to listen for messages on + RemotePort = 1103 % Which port to send messages to remote service on EnablePortSharing = 'off' % If set to 'on' other applications can use the listen port end properties (SetAccess = protected) RemoteIP % The IP address of the remote service - Socket % A handle to the udp object + Sockets % A handle to the udp object LastSentMessage = '' % A copy of the message sent from this host LastReceivedMessage = '' % A copy of the message received by this host end @@ -72,10 +72,10 @@ function delete(obj) % To be called before destroying BasicUDPService object. Deletes all % timers, sockets and listeners Tidy up after ourselves by closing % the listening sockets - if ~isempty(obj.Socket) - fclose(obj.Socket); % Close the connection - delete(obj.Socket); % Delete the socket - obj.Socket = []; % Delete udp object + if ~isempty(obj.Sockets) + cellfun(@fclose, obj.Sockets); % Close the connection + delete(obj.Sockets); % Delete the socket + obj.Sockets = []; % Delete udp object obj.Listener = []; % Delete any listeners to that object if ~isempty(obj.ResponseTimer) % If there is a timer object stop(obj.ResponseTimer) % Stop the timer.. @@ -85,40 +85,69 @@ function delete(obj) end end - function obj = RemoteTLService(remoteHost, remotePort, listenPort) - % SRV.REMOTETLSERVICE(remoteHost [remotePort, listenPort]) + function obj = RemoteMPEPService(name, listenPort, callback) + % SRV.REMOTETLSERVICE([remoteHost, remotePort, listenPort]) % remoteHost is the hostname of the service with which to send and % receive messages. paths = dat.paths(hostname); % Get list of paths for timeline - obj.Callbacks = struct('Instruction', {'ExpStart', 'BlockStart',... - 'StimStart', 'StimEnd', 'BlockEnd', 'ExpEnd', 'ExpInterrupt'}, 'Callback', @nop); - obj.Timeline = load(fullfile(paths.rigConfig, 'hardware.mat'), 'timeline'); % Load timeline object - obj.RemoteHost = remoteHost; % Set hostname - obj.RemoteIP = ipaddress(remoteHost); % Get IP address - if nargin >= 3; obj.ListenPort = listenPort; end % Set local port - if nargin >= 2; obj.RemotePort = remotePort; end % Set remote port - obj.Socket = udp(obj.RemoteIP,... % Create udp object - 'RemotePort', obj.RemotePort, 'LocalPort', obj.ListenPort); - obj.Socket.ReadAsyncMode = 'continuous'; - obj.Socket.BytesAvailableFcnCount = 10; % Number of bytes in buffer required to trigger BytesAvailableFcn - obj.Socket.BytesAvailableFcn = @obj.receiveUDP; % Add callback to receiveUDP when enough bytes arrive in the buffer +% obj.Callbacks = struct('Instruction', {'ExpStart', 'BlockStart',... +% 'StimStart', 'StimEnd', 'BlockEnd', 'ExpEnd', 'ExpInterrupt'}, 'Callback', @nop); + load(fullfile(paths.rigConfig, 'hardware.mat'), 'timeline'); % Load timeline object + obj.Timeline = timeline; + obj.addListener(name, listenPort, callback); +% if nargin < 1; remoteHost = ''; end % Set local port +% obj.RemoteHost = remotehost; % Set hostname +% obj.RemoteIP = ipaddress(remoteHost); % Get IP address +% if nargin >= 3; obj.ListenPort = listenPort; end % Set local port +% if nargin >= 2; obj.RemotePort = remotePort; end % Set remote port +% obj.Socket = udp(obj.RemoteIP,... % Create udp object +% 'RemotePort', obj.RemotePort, 'LocalPort', obj.ListenPort); +% obj.Socket.BytesAvailableFcn = @obj.receiveUDP; % Add callback to receiveUDP when enough bytes arrive in the buffer % Add listener to MessageReceived event, notified when receiveUDP is % called. This event can be listened to by anyone. - obj.Listener = event.listener(obj, 'MessageReceived', @processMsg); +% obj.Listener = event.listener(obj, 'MessageReceived', @processMsg); % Add listener for when the observable properties are set - obj.addlistener({'RemoteHost', 'ListenPort', 'RemotePort', 'EnablePortSharing'},... - 'PostSet',@obj.update); +% obj.addlistener({'RemoteHost', 'ListenPort', 'RemotePort', 'EnablePortSharing'},... +% 'PostSet',@(src,~)obj.update(src)); % Add listener for when the remote service's status is requested - obj.addlistener('Status', 'PreGet', @obj.requestStatus); +% obj.addlistener('Status', 'PreGet', @obj.requestStatus); end - function update(obj, evt, ~) + function obj = addListener(obj, name, listenPort, callback) + if nargin<3; callback = @nop; end + if listenPort==obj.ListenPorts + error('Listen port already added'); + end + idx = length(obj.Sockets)+1; + obj.Sockets{idx} = udp(name, 'RemotePort', obj.RemotePort,... + 'LocalPort', listenPort, 'ReadAsyncMode', 'continuous'); + obj.Sockets{idx}.BytesAvailableFcnCount = 10; % Number of bytes in buffer required to trigger BytesAvailableFcn + obj.Sockets{idx}.BytesAvailableFcn = @(~,~)obj.receiveUDP(src,evt); + obj.Sockets{idx}.Tag = name; + obj.ListenPorts{idx} = listenPort; + obj.Callbacks{idx} = callback; + end + + function obj = removeHost(obj, name) + %TODO + if nargin<3; callback = @nop; end + if listenPort==obj.ListenPorts + error('Listen port already added'); + end + obj.Sockets(end+1) = udp(obj.RemoteIP, 'RemotePort', obj.RemotePort, 'LocalPort', listenPort); + obj.Sockets(end).BytesAvailableFcn = @(~,~)obj.receiveUDP(src,evt); + obj.Sockets(end).Tag = name; + obj.ListenPorts(end+1) = listenPort; + obj.Callbacks(end+1) = callback; + end + + function update(obj, src) % Callback for setting udp relevant properties. Some properties can % only be set when the socket is closed. % Check if socket is open isOpen = strcmp(obj.Socket.Status, 'open'); % Close connection before setting, if required to do so - if any(strcmp(evt.name, {'RemoteHost', 'LocalPort', 'EnablePortSharing'}))&&isOpen + if any(strcmp(src.name, {'RemoteHost', 'LocalPort', 'EnablePortSharing'}))&&isOpen fclose(obj.Socket); end % Set all the relevant properties @@ -129,15 +158,41 @@ function update(obj, evt, ~) if isOpen; bind(obj); end % If socket was open before, re-open end - function bind(obj) - % Open the connection to allow messages to be sent and received - if ~isempty(obj.Socket); fclose(obj.Socket); end - fopen(obj.Socket); + function bind(obj, names) + if isempty(obj.Sockets) + warning('No sockets to bind') + return + end + if nargin<2 + % Close all sockets, in case they are open + cellfun(@fclose, obj.Sockets) + % Open the connection to allow messages to be sent and received + cellfun(@fopen, obj.Sockets) + else + names = ensureCell(names); + hosts = arrayfun(@(s)s.Tag, obj.Sockets); + idx = cellfun(@(n)find(strcmp(n,hosts)), names); + arrayfun(@fopen, obj.Sockets(idx)) + end + log('Polling for UDP messages'); end - function start(obj, expRef) + function start(obj, ref) % Send start message to remotehost and await confirmation - obj.confirmedSend(sprintf('GOGO%s*%s', expRef, obj.RemoteHost)); + [expRef, AlyxInstance] = parseAlyxInstance(ref); + % Convert expRef to MPEP style + [subject, seriesNum, expNum] = dat.expRefToMpep(expRef); + % Build start message + msg = sprintf('ExpStart %s %d %d', subject, seriesNum, expNum); + % Send the start message + obj.confirmedSend(msg, obj.RemoteHost); + % Wait for response + while obj.AwaitingConfirmation; pause(0.2); end +% % Start a block (we only use one per experiment) +% msg = sprintf('BlockStart %s %d %d 1', subject, seriesNum, expNum); +% obj.confirmedSend(msg, obj.RemoteHost); +% % Wait for response +% while obj.AwaitingConfirmation; pause(0.2); end end function stop(obj) @@ -163,11 +218,18 @@ function confirmedSend(obj, msg) end end - function receiveUDP(obj) - obj.LastReceivedMessage = fscanf(obj.Socket); - % Remove any more accumulated inputs to the listener - obj.Socket.flushinput(); + function receiveUDP(obj, src, evt) + obj.LastReceivedMessage = strtrim(fscanf(obj.Socket)); + % Let everyone know a message was recieved notify(obj, 'MessageReceived') + hosts = arrayfun(@(s)s.Tag, obj.Sockets); + if ~isempty(obj.Timeline)&&obj.Timeline.IsRunning + t = obj.Timeline.time; % Note the time + % record the UDP event in Timeline + obj.Timeline.record([hosts 'UDP'], msg, t); + end + % Pass message to callback function for precessing + feval(obj.Callbacks{strcmp(hosts, src.Tag)}, src, evt); end function sendUDP(obj, msg) @@ -177,78 +239,73 @@ function sendUDP(obj, msg) obj.LastSentMessage = msg; % Save a copy of the message disp(['Sent message to ' obj.RemoteHost]) % Display success end + + function echo(obj, src, ~) + % Echo message + fclose(src); + src.RemoteHost = src.DatagramAddress; + src.RemotePort = src.DatagramPort; + fopen(src); + fprintf(obj.Socket, obj.LastReceivedMessage); % Send message + obj.LastSentMessage = obj.LastReceivedMessage; % Save a copy of the message + disp(['Echo''d message to ' src.Tag]) % Display success + end end methods (Access = protected) - function processMsg(obj, ~, ~) - % Parse the message into its constituent parts - msg = dat.mpepMessageParse; - % Check that the message was from the correct host, otherwise ignore - if strcmp(response.host, obj.RemoteHost) - warning('Received message from %s, ignoring', response.host); - return + function processMsg(obj, src, ~) + %PROCESSMSG Processes messages from expServer and MPEP + % As the remote host me be either expServer or MPEP, we first + % determine the type of message. Parse the message into its + % constituent parts +% if strcmp(obj.LastReceivedMessage(1:4), {'WHAT', 'GOGO', 'ALYX', 'STOP'}) + try % Try to process message as MPEP command + msg = dat.mpepMessageParse(obj.LastReceivedMessage); + catch + msg = regexp(obj.LastReceivedMessage,... + '(?[A-Z]{4})(?.*)\*(?\w*)', 'names'); + % If the message body contains and expRef, explicity set this + if regexp(msg.body,dat.expRefRegExp); msg.expRef = msg.body; end end - if obj.AwaitingConfirmation - % Check the confirmation message is the same as the sent message - assert(~isempty(response)||... % something received - strcmp(response.status, 'WHAT')||... % status update - strcmp(obj.LastReceivedMessage, obj.LastSentMessage),... % is echo - 'Confirmation failed') - % We no longer need the timer, stop and delete it - if ~isempty(obj.ResponseTimer) - stop(obj.ResponseTimer) - delete(obj.ResponseTimer) - obj.ResponseTimer = []; - end - end - % At the moment we just disply some stuff, other functions listening - % to the MessageReceived event can do their thing - switch response.status - case 'GOGO' - if obj.AwaitingConfirmation - obj.Status = 'running'; - disp(['Service on ' obj.RemoteHost ' running']) - else - disp('Received start request') - obj.LocalStatus = 'starting'; - obj.Timeline.start(dat.parseAlyxInstance(response.body)) - obj.LocalStatus = 'running'; - obj.sendUDP(obj.LastReceivedMessage) - end - case 'STOP' - if obj.AwaitingConfirmation - obj.Status = 'stopped'; - disp(['Service on ' obj.RemoteHost ' stopped']) - else - disp('Received stop request') - obj.LocalStatus = 'stopping'; - obj.Timeline.stop - obj.sendUDP(obj.LastReceivedMessage) - end - case 'WHAT' - % TODO fix status updates so that they're meaningful - parsed = regexp(response.body, '(?\d+)(?[a-z]*)', 'names'); - if obj.AwaitingConfirmation + + % Process the instruction + switch lower(msg.instruction) + case {'expstart', 'gogo'} try - assert(strcmp(parsed.id, int2str(obj.ConfirmID)), 'Rigbox:srv:unexpectedUDPResponse',... - 'Received UDP message ID did not match sent'); - switch parsed.update - case {'running' 'starting'} - obj.Status = 'running'; - otherwise - obj.Status = 'idle'; - end - catch - obj.Status = 'unavailable'; + % Start Timeline + log('Received start request') + obj.LocalStatus = 'starting'; + obj.Timeline.start(dat.parseAlyxInstance(msg.expRef)) + obj.LocalStatus = 'running'; + obj.echo(src); + % re-record the UDP event in Timeline since it wasn't started + % when we tried earlier. Treat it as having arrived at time zero. + obj.Timeline.record('mpepUDP', obj.LastReceivedMessage, 0); + catch ex + % flag up failure so we do not echo the UDP message back below + failed = true; + disp(getReport(ex)); end - else % Received status request NB: Currently no way of determining status - obj.sendUDP([parsed.status parsed.id obj.LocalStatus]) - end - otherwise - disp(['Received ''' obj.LastReceivedMessage ''' from ' obj.RemoteHost]) + case {'expend', 'stop', 'expinterrupt'} + obj.Timeline.stop(); % stop Timeline + case 'what' + % TODO fix status updates so that they're meaningful + parsed = regexp(msg.body, '(?\d+)(?[a-z]*)', 'names'); + obj.sendUDP([parsed.status parsed.id obj.LocalStatus]) + case 'alyx' + % TODO Add Alyx token request + obj.sendUDP() + otherwise + % TODO RemoteHost + log(['Received ''' obj.LastReceivedMessage ''' from ' obj.RemoteHost]) end - % Reset AwaitingConfirmation - obj.AwaitingConfirmation = false; end + + function log(varargin) + message = sprintf(varargin{:}); + timestamp = datestr(now, 'dd-mm-yyyy HH:MM:SS'); + fprintf('[%s] %s\n', timestamp, message); + end + end end \ No newline at end of file diff --git a/readme.txt b/readme.txt index 501b335e..0e01d560 100644 --- a/readme.txt +++ b/readme.txt @@ -1,22 +1,94 @@ -In order to install on any computer: +---------- +# Rigbox -- run the Rigbox/addRigboxPaths.m -- install the GUI Layout Toolbox from here: https://uk.mathworks.com/matlabcentral/fileexchange/47982-gui-layout-toolbox -- double check that the added paths (including those to the Toolbox) are above the paths to zserver +Rigbox is a (mostly) object-oriented MATLAB software package for designing and controlling behavioural experiments. Principally, the steering wheel setup we developed to probe mouse behaviour. It requires two computers, one for stimulus presentation ('the stimulus server') and another for controlling and monitoring the experiment ('mc'). -Main changes: +## Getting Started -- handles to objects are no longer numerical -- the UI is now using the most recent version of GUI Layout Toolbox -- all code now works in the latest version of MATLAB +The following is a brief description of how to install Rigbox on your experimental rig. However detailed, step-by-step information can be found [here](https://www.ucl.ac.uk/cortexlab/tools/wheel). -Little fixes: +## Prerequisites +Rigbox has a number of essential and optional software dependencies, listed below: +* Windows 7 or later +* [MATLAB](https://uk.mathworks.com/downloads/web_downloads/?s_iid=hp_ff_t_downloads) 2016a or later + * [Psychophsics Toolbox](https://github.com/Psychtoolbox-3/Psychtoolbox-3/releases) v3 or later + * [NI-DAQmx support package](https://uk.mathworks.com/hardware-support/nidaqmx.html) + * [GUI Layout Toolbox](https://uk.mathworks.com/matlabcentral/fileexchange/47982-gui-layout-toolbox) v2 or later + * Data Acquisition Toolbox + * Signal Processing Toolbox + * Instrument Control Toolbox -- checkbox in param editor now functions correctly (added line 382 +eui.ParamEditor/addParamUI) -- more documentation, particularly for the UI elements -- saved parameters dropdown now ordered in mc +Additionally, Rigbox works with a number of extra repositories: +* [Signals](https://github.com/dendritic/signals) (for running bespoke experiment designs) + * Statistics and Machine Learning Toolbox + * [Microsoft Visual C++ Redistributable for Visual Studio 2015](https://www.microsoft.com/en-us/download/details.aspx?id=48145) +* [Alyx-matlab](https://github.com/cortex-lab/alyx-matlab) (for registering data to, and retrieving from, an Alyx database + * [Missing HTTP v1](https://github.com/psexton/missing-http/releases/tag/missing-http-1.0.0) or later + * [JSONlab](https://uk.mathworks.com/matlabcentral/fileexchange/33381-jsonlab--a-toolbox-to-encode-decode-json-files) -To do: +## Installing +1. To install Rigbox, first ensure that all the above dependencies are installed. +2. Pull the latest Rigbox-lite branch. This branch is currently the 'cleanest' one, however in the future it will likely be merged with the master branch. +3. In MATLAB run 'addRigboxPaths.m' and restart the program. +4. Set the correct paths by following the instructions in Rigbox\+dat\paths.m on both computers. +5. On the stimulus server, load the hardware.mat file in Rigbox\Repositories\code\config\exampleRig and edit according to your specific hardware setup (link to detailed instructions above, under 'Getting started'). -- rename the cortexlab folder and move +exp to ExpDefinitions -- add specific path for ExpDefinitions in dat.paths (see line 115 in MControl) \ No newline at end of file +## Running an experiment + +On the stimulus server, run: +> srv.expServer + +On the mc computer, run: +> mc + +This opens a GUI that will allow you to choose a subject, edit some of the experimental parameters and press 'Start' to begin the basic steering wheel task on the stimulus server. + +# Code organization +Below is a list of the principle directories and their general purpose. +## +dat +The data package contains all the code pertaining to the organization and logging of data. It contains functions that generate and parse unique experiment reference ids, that return the file paths where subject data and rig configuration information is stored. Other functions include those that manage experimental log entries and parameter profiles. This package is akin to a lab notebook. + +## +eui +This package contains the code pertaining to the Rigbox user interface. It contains code for constructing the mc GUI (MControl.m), and for plotting live experiment data or generating tables for viewing experiment parameters and subject logs. This package is exclusively used by the mc computer. + +## +exp +The experiment package is for the initialization and running of behavioural experiments. These files define a framework for event- and state-based experiments. Actions such as visual stimulus presentation or reward delivery can be controlled by experiment phases, and experiment phases are managed by an event-handling system (e.g. ResponseEventInfo). + +The package also triggers auxiliary services (e.g. starting remote acquisition software), and loads parameters for presentation each trail. The principle two base classes that control these experiments are Experiment and its Signals counterpart, SignalsExp. + +This package is almost exclusively used by the stimulus server + +## +hw +The hardware package is for configuring, and interfacing with, hardware such as screens, DAQ devices, weighing scales and lick detectors. Withing this is the +ptb package which contains classes for interacting with PsychToolbox. + +The devices file loads and initializes all the hardware for a specific experimental rig. There are also classes for unifying system and hardware clocks. + +## +psy +This package contains simple functions for processing and plotting psychometric data + +## +srv +This package contains the expServer function as well as classes that manage communications between rig computers. + +The Service base class allows the stimulus server to start and stop auxiliary acquisition systems at the beginning and end of experiments + +The StimulusControl class is used by the mc computer to manage the stimulus server + +NB: Lower-level communication protocol code is found in the +io package + +## cb-tools\burgbox +Burgbox contains many simply helper functions that are used by the main packages. Within this directory are further packages: +* +bui --- Classes for managing graphics objects such as axes +* +aud --- Functions for interacting with PsychoPortAudio +* +file --- Functions for simplifying directory and file management, for instance returning the modified dates for specified folders or filtering an array of directories by those that exist +* +fun --- Convenience functions for working with function handles in MATLAB, e.g. functions similar cellfun that are agnostic of input type, or ones that cache function outputs +* +img --- Classes that deal with image and frame data (DEPRICATED) +* +io --- Lower-level communications classes for managing UDP and TCP/IP Web sockets +* +plt --- A few small plotting functions (DEPRICATED) +* +vis --- Functions for returning various windowed visual stimuli (i.g. gabor gratings) +* +ws --- An early Web socket package using SuperWebSocket (DEPRICATED) + +## cortexlab +The cortexlab directory is intended for functions and classes that are rig or lab specific, for instance code that allows compatibility with other stimulus presentation packages used by cortexlab (i.e. MPEP) + +## Authors +The majority of the Rigbox code was written by [Chris Burgess](https://github.com/dendritic/) in 2013. It is now maintained and developed by a number of people at [CortexLab](https://www.ucl.ac.uk/cortexlab). From 37a80ad70b11a20ff67ffcc80668632c59ba7cd5 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 10 Jan 2018 13:52:36 +0000 Subject: [PATCH 019/507] readme now in markdown converted from text to markdown --- readme.txt => readme.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename readme.txt => readme.md (100%) diff --git a/readme.txt b/readme.md similarity index 100% rename from readme.txt rename to readme.md From adf5c070c617e8e17c94e4179535606d734b3d84 Mon Sep 17 00:00:00 2001 From: Kenneth Harris Date: Wed, 10 Jan 2018 14:29:11 +0000 Subject: [PATCH 020/507] Added UML file --- Rigbox UML.pdf | Bin 0 -> 89221 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 Rigbox UML.pdf diff --git a/Rigbox UML.pdf b/Rigbox UML.pdf new file mode 100644 index 0000000000000000000000000000000000000000..df1cb6cbf11a8abf17c99991fc875a8339236d3e GIT binary patch literal 89221 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 z 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

8sQixM2k`~S4M5Sp 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�apU@!nL XZ);EQf2SJ?C5eIpgoKo|?gRb>(qS3q literal 0 HcmV?d00001 From 71fc6c5d3e626ca4661fc4e65759fea4f2a8c62a Mon Sep 17 00:00:00 2001 From: nsteinme Date: Wed, 10 Jan 2018 16:59:59 +0000 Subject: [PATCH 021/507] added paths for tape backup --- +dat/paths.m | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/+dat/paths.m b/+dat/paths.m index dc0997ca..9d69d38e 100644 --- a/+dat/paths.m +++ b/+dat/paths.m @@ -18,6 +18,8 @@ % server3Name = '\\zserver3.cortexlab.net'; % 2017-02-18 MW - Currently % unused by Rigbox server4Name = '\\zserver4.cortexlab.net'; +basketName = '\\basket.cortexlab.net'; % for working analyses +lugaroName = '\\lugaro.cortexlab.net'; % for tape backup %% defaults % path containing rigbox config folders @@ -52,6 +54,18 @@ % repository for all experiment definitions p.expDefinitions = fullfile(server1Name, 'Code', 'Rigging', 'ExpDefinitions'); +% repository for working analyses that are not meant to be stored +% permanently +p.workingAnalysisRepository = fullfile(basketName, 'data'); + +% for tape backups, first files go here: +p.tapeStagingRepository = fullfile(lugaroName, 'bigdrive', 'staging'); + +% then they go here: +p.tapeArchiveRepository = fullfile(lugaroName, 'bigdrive', 'toarchive'); + + + %% load rig-specific overrides from config file, if any customPathsFile = fullfile(p.rigConfig, 'paths.mat'); if file.exists(customPathsFile) From e03899350f0958d2ff99f2b62d28321c66302d74 Mon Sep 17 00:00:00 2001 From: nsteinme Date: Thu, 11 Jan 2018 11:42:40 +0000 Subject: [PATCH 022/507] update alyx handling for mpepListenerWithWS, add plotting options to Timeline --- +hw/Timeline.m | 39 ++++++++++++++++++++-------- cortexlab/+tl/bindMpepServerWithWS.m | 16 +++++++++--- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index e084c8e0..3739c1bf 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -83,6 +83,7 @@ AquiredDataType = 'double' % default data type for the acquired data array (i.e. Data.rawDAQData) UseTimeline = false % used by expServer. If true, timeline is started by default (otherwise can be toggled with the t key) LivePlot = false % if true the data are plotted as the data are aquired + LivePlotParams = []; WriteBufferToDisk = false % if true the data buffer is written to disk as they're aquired NB: in the future this will happen by default end @@ -616,23 +617,28 @@ function livePlot(obj, data) % TL.LIVEPLOT(source, event) plots the data aquired by the % DAQ while the PlotLive property is true. if isempty(obj.Axes) - figure(); % create a figure for plotting aquired data + f = figure(); % create a figure for plotting aquired data obj.Axes = gca; % store a handle to the axes -% set(figure_handle, 'Position', [21 81 1033 1726]); % set the figure position + if isfield(obj.LivePlotParams, 'figPosition') && ~isempty(obj.LivePlotParams.figPosition) + set(f, 'Position', obj.LivePlotParams.figPosition); % set the figure position + end end % get the names of the inputs being recorded names = pick({obj.Inputs.name}, find([obj.Inputs.arrayColumn] > -1), 'cell'); nSamps = size(data,1); % Get the number of samples in this chunck nChans = size(data,2); % Get the number of channels - traceSep = 7; % ??? + traceSep = 7; % unit is Volts - for most channels the max is 5V so this is a good separation offsets = (1:nChans)*traceSep; - scales = ones(1, nChans); - if length(scales)2^31); data(data(:,t)>2^31,t) = data(data(:,t)>2^31,t)-2^32; end @@ -653,7 +664,13 @@ function livePlot(obj, data) yy = get(traces(end-t+1), 'YData'); % get current data for trace yy(1:end-nSamps) = yy(nSamps+1:end); % add the new chuck for channel % scale and offset the traces - if strcmp(obj.Inputs(t).measurement, 'Position') + if strcmp(meas{t}, 'Position') + % for position-type inputs, plot velocity (take the + % diff, and smooth) rather than absolute. this is + % necessary to prevent the value from wandering way off + % the range and making it impossible to see any of the + % other traces. Plus it is probably more useful, + % anyway. yy(end-nSamps+1:end) = conv(diff([data(1,t); data(:,t)]),... gausswin(50)./sum(gausswin(50)), 'same') * scales(t) + offsets(t); else diff --git a/cortexlab/+tl/bindMpepServerWithWS.m b/cortexlab/+tl/bindMpepServerWithWS.m index b6401089..0adbafca 100644 --- a/cortexlab/+tl/bindMpepServerWithWS.m +++ b/cortexlab/+tl/bindMpepServerWithWS.m @@ -154,17 +154,25 @@ function listen() end if firstPress(manualStartKey) && ~tlObj.IsRunning - % first get an alyx instance - ai = alyx.loginWindow(); - + if isempty(tls.AlyxInstance) + % first get an alyx instance + ai = alyx.loginWindow(); + else + ai = tls.AlyxInstance; + end + [mouseName, ~] = dat.subjectSelector([],ai); if ~isempty(mouseName) clear expParams; expParams.experimentType = 'timelineManualStart'; - newExpRef = dat.newExp(mouseName, now, expParams, ai); + [newExpRef, newExpSeq, subsessionURL] = dat.newExp(mouseName, now, expParams, ai); + ai.subsessionURL = subsessionURL; + tls.AlyxInstance = ai; + %[subjectRef, expDate, expSequence] = dat.parseExpRef(newExpRef); %newExpRef = dat.constructExpRef(mouseName, now, expNum); + communicator.send('AlyxSend', {tls.AlyxInstance}); communicator.send('status', { 'starting', newExpRef}); tlObj.start(newExpRef, ai); end From 5bcf00a5307fa67842b3ba43d810f2ee1a1dddbb Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 11 Jan 2018 17:08:07 +0000 Subject: [PATCH 023/507] Weight record fix + better custom ExpPanels * Water now submitted regardless of experiment panel function * The weight is now properly queued to Alex when recorded while logged out --- +eui/AlyxPanel.m | 2 +- +eui/ExpPanel.m | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 02889e23..a0cea8a2 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -393,11 +393,11 @@ function recordWeight(obj, weight, subject) weight = iff(ischar(weight{1}), str2double(weight{1}), weight{1}); d.subject = subject; d.weight = weight; - d.user = ai.username; 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 + d.user = ai.username; try w = alyx.postData(ai, 'weighings/', d); obj.log('Alyx weight posting succeeded: %.2f for %s', w.weight, w.subject); diff --git a/+eui/ExpPanel.m b/+eui/ExpPanel.m index a3b2c86a..a9a53d01 100644 --- a/+eui/ExpPanel.m +++ b/+eui/ExpPanel.m @@ -225,11 +225,6 @@ function expStopped(obj, rig, evt) subject = obj.SubjectRef; if ~isempty(ai)&&~strcmp(subject,'default') switch class(obj) - case 'eui.SqueakExpPanel' - infoFields = {obj.InfoFields.String}; - inc = cellfun(@(x) any(strfind(x(:)','�l')), {obj.InfoFields.String}); % Find event values ending with 'ul'. - reward = cell2mat(cellfun(@str2num,strsplit(infoFields{find(inc,1)},'�l'),'UniformOutput',0)); - amount = iff(isempty(reward),0,@()reward); case 'eui.ChoiceExpPanel' if ~isfield(obj.Block.trial,'feedbackType'); return; end % No completed trials if any(strcmp(obj.Parameters.TrialSpecificNames,'rewardVolume')) % Reward is trial specific @@ -242,7 +237,10 @@ function expStopped(obj, rig, evt) end if numel(amount)>1; amount = amount(1); end % Take first element (second being laser) otherwise - return + infoFields = {obj.InfoFields.String}; + inc = cellfun(@(x) any(strfind(x(:)','�l')), {obj.InfoFields.String}); % Find event values ending with 'ul'. + reward = cell2mat(cellfun(@str2num,strsplit(infoFields{find(inc,1)},'�l'),'UniformOutput',0)); + amount = iff(isempty(reward),0,@()reward); end if ~any(amount); return; end % Return if no water was given try From e09264b0043ef37ff75a4d96fba3d0083fa1b1bf Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 15 Jan 2018 17:27:57 +0000 Subject: [PATCH 024/507] Fix to plotting Alyx plotting bug * AlyxPanel's use of axes when weights were recorded was interfereing with ExpPanel. This has been fixed by explicitly defining which axes to hold, etc. * Custom ExpPanel ignored now if the field is empty --- +eui/AlyxPanel.m | 40 ++++++++++++++++++++-------------------- +eui/ExpPanel.m | 5 +++-- +eui/MControl.m | 2 +- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index a0cea8a2..2f0852e6 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -506,39 +506,39 @@ function viewSubjectHistory(obj, ax) end plot(ax, dates, [records.weight_measured], '.-'); - hold on; + hold(ax, 'on'); plot(ax, dates, [records.weight_expected]*0.7, 'r', 'LineWidth', 2.0); plot(ax, dates, [records.weight_expected]*0.8, 'LineWidth', 2.0, 'Color', [244, 191, 66]/255); - box off; - xlim([min(dates) max(dates)]); + box(ax, 'off'); + xlim(ax, [min(dates) max(dates)]); if nargin == 1 set(ax, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false)) else - ax.XTickLabel = arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax.Handle, 'XTick'), 'uni', false); + ax.XTickLabel = arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false); end - ylabel('weight (g)'); + ylabel(ax, 'weight (g)'); if nargin==1 ax = axes('Parent', plotBox); - plot(dates, [records.weight_measured]./[records.weight_expected], '.-'); - 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, [records.weight_measured]./[records.weight_expected], '.-'); + 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) max(dates)]); 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, [records.water_given]+[records.hydrogel_given], '.-'); - hold on; - plot(dates, [records.hydrogel_given], '.-'); - plot(dates, [records.water_given], '.-'); - plot(dates, [records.water_expected], 'r', 'LineWidth', 2.0); - box off; - xlim([min(dates) max(dates)]); + plot(axWater, dates, [records.water_given]+[records.hydrogel_given], '.-'); + hold(axWater, 'on'); + plot(axWater, dates, [records.hydrogel_given], '.-'); + plot(axWater, dates, [records.water_given], '.-'); + plot(axWater, dates, [records.water_expected], 'r', 'LineWidth', 2.0); + box(axWater, 'off'); + xlim(axWater, [min(dates) max(dates)]); set(axWater, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(axWater, 'XTick'), 'uni', false)) - ylabel('water/hydrogel (mL)'); + ylabel(axWater, 'water/hydrogel (mL)'); % Create table of useful weight and water information, % sorted by date diff --git a/+eui/ExpPanel.m b/+eui/ExpPanel.m index a9a53d01..5ccb95b6 100644 --- a/+eui/ExpPanel.m +++ b/+eui/ExpPanel.m @@ -61,10 +61,11 @@ warning(ex.getReport()); end params = exp.Parameters(paramsStruct); % Get parameters - if isfield(params.Struct, 'expPanelFun') % Can define your own experiment panel + % Can define your own experiment panel + if isfield(params.Struct, 'expPanelFun')&&~isempty(params.Struct.expPanelFun) if isempty(which(params.Struct.expPanelFun)); addpath(fileparts(params.Struct.defFunction)); end p = feval(params.Struct.expPanelFun, parent, ref, params, logEntry); - else + else % otherwise use the default switch params.Struct.type case {'SingleTargetChoiceWorld' 'ChoiceWorld' 'DiscWorld' 'SurroundChoiceWorld'} p = eui.ChoiceExpPanel(parent, ref, params, logEntry); diff --git a/+eui/MControl.m b/+eui/MControl.m index abf5b918..0f09f9ff 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -570,7 +570,7 @@ function updateWeightPlot(obj) datenums = floor([entries.date]); obj.WeightAxes.clear(); if ~isempty(obj.AlyxPanel.AlyxInstance)&&~strcmp(obj.LogSubject.Selected,'default') - obj.AlyxPanel.viewSubjectHistory(obj.WeightAxes) + obj.AlyxPanel.viewSubjectHistory(obj.WeightAxes.Handle) rotateticklabel(obj.WeightAxes.Handle, 45); else if numel(datenums) > 0 From 0550d5b70b52275eeae812457128de6fdadc0524 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 16 Jan 2018 17:40:27 +0000 Subject: [PATCH 025/507] Fix'd Alyx xlim error Error when setting xlim for new mice resolved. --- +eui/AlyxPanel.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 2f0852e6..6b9704b5 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -510,7 +510,7 @@ function viewSubjectHistory(obj, ax) plot(ax, dates, [records.weight_expected]*0.7, 'r', 'LineWidth', 2.0); plot(ax, dates, [records.weight_expected]*0.8, 'LineWidth', 2.0, 'Color', [244, 191, 66]/255); box(ax, 'off'); - xlim(ax, [min(dates) max(dates)]); + if numel(dates) > 1; xlim(ax, [min(dates) max(dates)]); end if nargin == 1 set(ax, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false)) else From 9049cde114d5cb75dc979dd30b124d754d22b0e8 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 17 Jan 2018 14:27:18 +0000 Subject: [PATCH 026/507] Comments submission change Newline charecters added --- +dat/updateLogEntry.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+dat/updateLogEntry.m b/+dat/updateLogEntry.m index ad512209..1d5a86a7 100644 --- a/+dat/updateLogEntry.m +++ b/+dat/updateLogEntry.m @@ -12,7 +12,7 @@ function updateLogEntry(subject, id, newEntry) if isfield(newEntry, 'AlyxInstance')&&~isempty(newEntry.comments) data = struct('subject', dat.parseExpRef(newEntry.value.ref),... - 'narrative', newEntry.comments); + 'narrative', mat2DStrTo1D(newEntry.comments)); alyx.putData(newEntry.AlyxInstance,... newEntry.AlyxInstance.subsessionURL, data); newEntry = rmfield(newEntry, 'AlyxInstance'); From 46173ff67942bafef6a0d436b87a07a43970b3fc Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 18 Jan 2018 13:03:23 +0000 Subject: [PATCH 027/507] registerFile updated --- +exp/Experiment.m | 3 ++- +exp/SignalsExp.m | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/+exp/Experiment.m b/+exp/Experiment.m index e86502e7..b0229875 100644 --- a/+exp/Experiment.m +++ b/+exp/Experiment.m @@ -780,7 +780,8 @@ function saveData(obj) [subject,~,~] = dat.parseExpRef(obj.Data.expRef); if strcmp(subject,'default'); return; end % Register saved files - alyx.registerFile(subject,[],'Block',savepaths{end},'zserver',obj.AlyxInstance); + alyx.registerFile(savepaths{end}, 'mat',... + obj.AlyxInstance.subsessionURL, 'Block', [], obj.AlyxInstance); % Save the session end time alyx.putData(obj.AlyxInstance, obj.AlyxInstance.subsessionURL,... struct('end_time', alyx.datestr(now), 'subject', subject)); diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 81438129..4932a1e4 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -835,7 +835,8 @@ function saveData(obj) [subject,~,~] = dat.parseExpRef(obj.Data.expRef); if strcmp(subject,'default'); return; end % Register saved files - alyx.registerFile(subject,[],'Block',savepaths{end},'zserver',obj.AlyxInstance); + alyx.registerFile(savepaths{end}, 'mat',... + obj.AlyxInstance.subsessionURL, 'Block', [], obj.AlyxInstance); % Save the session end time alyx.putData(obj.AlyxInstance, obj.AlyxInstance.subsessionURL,... struct('end_time', alyx.datestr(now), 'subject', subject)); From 0f4c94198455b80463d95438b9c2180773f4afbd Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 18 Jan 2018 13:06:09 +0000 Subject: [PATCH 028/507] Parameters now registered to Alyx --- +dat/newExp.m | 13 ++++++++++++- +hw/Timeline.m | 3 ++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/+dat/newExp.m b/+dat/newExp.m index ae7a3e76..9c01f24b 100644 --- a/+dat/newExp.m +++ b/+dat/newExp.m @@ -128,6 +128,17 @@ % now save the experiment parameters variable superSave(dat.expFilePath(expRef, 'parameters'), struct('parameters', expParams)); - +% save a copy in json +% if exist('savejson', 'file') +% % save server copy only +% jsonPath = fullfile(fileparts(dat.expFilePath(expRef, 'parameters', 'master'))); +% savejson('parameters', expParams, jsonPath, 'Parameters.json'); +% else +% warning('JSONlab not found - hardware information not saved to ALF') +% end + +% Register our parameter set to Alyx +alyx.registerFile(dat.expFilePath(expRef, 'parameters', 'master'), 'mat',... + url, 'Parameters', [], AlyxInstance); end \ No newline at end of file diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 3739c1bf..49302b58 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -457,7 +457,8 @@ function stop(obj) [subject,~,~] = dat.parseExpRef(obj.Data.expRef); if ~isempty(obj.AlyxInstance) && ~strcmp(subject,'default') try - alyx.registerFile(subject,[],'Timeline',obj.Data.savePaths{end},'zserver',obj.AlyxInstance); + alyx.registerFile(obj.Data.savePaths{end}, 'alf',... + obj.AlyxInstance.subsessionURL, 'Timeline', [], obj.AlyxInstance); catch warning('couldnt register files to alyx'); end From 9243c5cafcc7157c5ecc94cfcd4197996910874c Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 18 Jan 2018 19:08:02 +0000 Subject: [PATCH 029/507] Fix'd bug with default subject Can continue to run when logged out if subject is default --- +dat/newExp.m | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/+dat/newExp.m b/+dat/newExp.m index 9c01f24b..c5184e88 100644 --- a/+dat/newExp.m +++ b/+dat/newExp.m @@ -138,7 +138,9 @@ % end % Register our parameter set to Alyx -alyx.registerFile(dat.expFilePath(expRef, 'parameters', 'master'), 'mat',... - url, 'Parameters', [], AlyxInstance); +if ~strcmp(subject,'default') + alyx.registerFile(dat.expFilePath(expRef, 'parameters', 'master'), 'mat',... + url, 'Parameters', [], AlyxInstance); +end end \ No newline at end of file From 4758d874701d5648e8d3bfb854efd3653672cc70 Mon Sep 17 00:00:00 2001 From: nsteinme Date: Sat, 20 Jan 2018 18:28:32 +0000 Subject: [PATCH 030/507] add a signal generator for step changes in output level --- +hw/DaqSingleScan.m | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 +hw/DaqSingleScan.m diff --git a/+hw/DaqSingleScan.m b/+hw/DaqSingleScan.m new file mode 100644 index 00000000..2bb53282 --- /dev/null +++ b/+hw/DaqSingleScan.m @@ -0,0 +1,29 @@ +classdef DaqSingleScan < hw.ControlSignalGenerator + %HW.DaqSingleScan Outputs a single value, just changing the level of the + %analog output + % + % + + properties + Scale % multiplicatively scale the output + % for instance, make this a conversion factor between your + % desired output units (like mm of a galvo, or mW of a laser) and + % voltage + end + + methods + function obj = DaqSingleScan(scale) + obj.DefaultValue = 0; + obj.Scale = scale; + end + + function samples = waveform(obj, v) + % just take the first value (if multiple were provided) and output + % it, scaled, as a single number. This will result in the analog + % output channel switching to that value and staying there. + samples = v(1)*obj.Scale; + end + end + +end + From 0be784746a1a9c71df6a1eb375f545471a8be282 Mon Sep 17 00:00:00 2001 From: nsteinme Date: Mon, 22 Jan 2018 17:30:50 +0000 Subject: [PATCH 031/507] improve alyx warning message --- +exp/SignalsExp.m | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 81438129..fb478b62 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -839,8 +839,9 @@ function saveData(obj) % Save the session end time alyx.putData(obj.AlyxInstance, obj.AlyxInstance.subsessionURL,... struct('end_time', alyx.datestr(now), 'subject', subject)); - catch - warning('couldnt register files to alyx because no subsession found'); + catch ex + warning('couldnt register files to alyx'); + disp(ex) end end From ee0498b2c39c39af8cfe8820019ff648e3c8f624 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 24 Jan 2018 11:08:06 +0000 Subject: [PATCH 032/507] Updated newExp to save in JSON * newExp also registers parameters file in JSON is available, otherwise the mat file is registered instead. * updateLogEntry now saves with all newlines replaced with '\n'. --- +dat/newExp.m | 41 ++++++++++++++++++++++++++--------------- +dat/updateLogEntry.m | 2 +- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/+dat/newExp.m b/+dat/newExp.m index c5184e88..6e2c0720 100644 --- a/+dat/newExp.m +++ b/+dat/newExp.m @@ -9,9 +9,10 @@ % |_ expSeq/ % % If experiment parameters are passed into the function, they are saved -% here. If an instance of Alyx is passed and a base session for the -% experiment date is not found, one is created in the Alyx database. -% A corresponding subsession is also created. +% here, as a mat and in JSON (if possible). If an instance of Alyx is +% passed and a base session for the experiment date is not found, one is +% created in the Alyx database. A corresponding subsession is also +% created and the parameters file is registered with the sub-session. % % See also DAT.PATHS % @@ -128,19 +129,29 @@ % now save the experiment parameters variable superSave(dat.expFilePath(expRef, 'parameters'), struct('parameters', expParams)); -% save a copy in json -% if exist('savejson', 'file') -% % save server copy only -% jsonPath = fullfile(fileparts(dat.expFilePath(expRef, 'parameters', 'master'))); -% savejson('parameters', expParams, jsonPath, 'Parameters.json'); -% else -% warning('JSONlab not found - hardware information not saved to ALF') -% end - -% Register our parameter set to Alyx -if ~strcmp(subject,'default') - alyx.registerFile(dat.expFilePath(expRef, 'parameters', 'master'), 'mat',... + +try % save a copy of parameters in json + % First, change all functions to strings + f_idx = structfun(@(s)isa(s, 'function_handle'), expParams); + fields = fieldnames(expParams); + paramCell = struct2cell(expParams); + paramCell(f_idx) = cellfun(@func2str, paramCell(f_idx),'UniformOutput', false); + expParams = cell2struct(paramCell, fields); + % Generate JSON path and save + jsonPath = fullfile(fileparts(dat.expFilePath(expRef, 'parameters', 'master')),... + [expRef, '_parameters.json']); + savejson('parameters', expParams, jsonPath); + % Register our JSON parameter set to Alyx + if ~strcmp(subject,'default') + alyx.registerFile(jsonPath, 'json', url, 'Parameters', [], AlyxInstance); + end +catch ex + warning(ex.identifier, 'Failed to save paramters as JSON: %s.\n Registering mat file instead', ex.message) + % Register our parameter set to Alyx + if ~strcmp(subject,'default') + alyx.registerFile(dat.expFilePath(expRef, 'parameters', 'master'), 'mat',... url, 'Parameters', [], AlyxInstance); + end end end \ No newline at end of file diff --git a/+dat/updateLogEntry.m b/+dat/updateLogEntry.m index 1d5a86a7..fefad909 100644 --- a/+dat/updateLogEntry.m +++ b/+dat/updateLogEntry.m @@ -12,7 +12,7 @@ function updateLogEntry(subject, id, newEntry) if isfield(newEntry, 'AlyxInstance')&&~isempty(newEntry.comments) data = struct('subject', dat.parseExpRef(newEntry.value.ref),... - 'narrative', mat2DStrTo1D(newEntry.comments)); + 'narrative', strrep(mat2DStrTo1D(newEntry.comments),newline,'\n')); alyx.putData(newEntry.AlyxInstance,... newEntry.AlyxInstance.subsessionURL, data); newEntry = rmfield(newEntry, 'AlyxInstance'); From 5b9d1f03c612bdea8b79152bd47313df98eadbdc Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 24 Jan 2018 18:30:53 +0000 Subject: [PATCH 033/507] Cleaned up repo * Removed legacy and unused code * Moved Nick's rig-specific code to cortexlab directory --- +eui/Log.m | 44 - +hw/DaqDataManager.m | 28 - +hw/DaqLaser.m | 89 - +hw/DaqRewardValve.m | 161 - +hw/RewardController.m | 35 - +hw/RewardValveControl.m | 1 - +hw/calibrate.m | 5 +- +hw/devices.m | 8 - addRigboxPaths.m | 5 +- cb-tools/SuperWebSocket/Config/log4net.config | 69 - .../SuperWebSocket/Config/log4net.unix.config | 69 - cb-tools/SuperWebSocket/InstallService.bat | 2 - cb-tools/SuperWebSocket/Newtonsoft.Json.dll | Bin 358400 -> 0 bytes .../SuperWebSocket/SuperSocket.Common.dll | Bin 29696 -> 0 bytes .../SuperWebSocket/SuperSocket.Common.pdb | Bin 89600 -> 0 bytes .../SuperWebSocket/SuperSocket.Common.xml | 1224 ---- .../SuperWebSocket/SuperSocket.Facility.XML | 467 -- .../SuperWebSocket/SuperSocket.Facility.dll | Bin 15872 -> 0 bytes .../SuperWebSocket/SuperSocket.Facility.pdb | Bin 42496 -> 0 bytes .../SuperWebSocket/SuperSocket.SocketBase.dll | Bin 94720 -> 0 bytes .../SuperWebSocket/SuperSocket.SocketBase.pdb | Bin 196096 -> 0 bytes .../SuperWebSocket/SuperSocket.SocketBase.xml | 4974 ----------------- .../SuperSocket.SocketEngine.XML | 1045 ---- .../SuperSocket.SocketEngine.dll | Bin 71680 -> 0 bytes .../SuperSocket.SocketEngine.pdb | Bin 165376 -> 0 bytes .../SuperSocket.SocketService.exe | Bin 11264 -> 0 bytes .../SuperSocket.SocketService.exe.config | 24 - .../SuperSocket.SocketService.pdb | Bin 24064 -> 0 bytes cb-tools/SuperWebSocket/SuperWebSocket.XML | 1762 ------ cb-tools/SuperWebSocket/SuperWebSocket.dll | Bin 62464 -> 0 bytes cb-tools/SuperWebSocket/SuperWebSocket.pdb | Bin 216576 -> 0 bytes cb-tools/SuperWebSocket/UninstallService.bat | 2 - cb-tools/SuperWebSocket/WebSocket4Net.dll | Bin 88576 -> 0 bytes cb-tools/SuperWebSocket/log4net.dll | Bin 286720 -> 0 bytes cb-tools/burgbox/+plt/binoErrorbar.m | 24 - cb-tools/burgbox/+plt/errorbar.m | 23 - cb-tools/burgbox/+plt/hshade.m | 37 - cb-tools/burgbox/+plt/vshade.m | 37 - cb-tools/jsonlab/AUTHORS.txt | 36 - cb-tools/jsonlab/ChangeLog.txt | 47 - cb-tools/jsonlab/LICENSE_BSD.txt | 25 - cb-tools/jsonlab/LICENSE_GPLv3.txt | 674 --- cb-tools/jsonlab/README.txt | 335 -- .../jsonlab/examples/demo_jsonlab_basic.m | 161 - cb-tools/jsonlab/examples/demo_ubjson_basic.m | 161 - cb-tools/jsonlab/examples/example1.json | 23 - cb-tools/jsonlab/examples/example2.json | 22 - cb-tools/jsonlab/examples/example3.json | 11 - cb-tools/jsonlab/examples/example4.json | 34 - .../jsonlab/examples/jsonlab_basictest.matlab | 361 -- cb-tools/jsonlab/examples/jsonlab_selftest.m | 12 - .../jsonlab/examples/jsonlab_selftest.matlab | 121 - cb-tools/jsonlab/examples/jsonlab_speedtest.m | 21 - cb-tools/jsonlab/jsonopt.m | 32 - cb-tools/jsonlab/loadjson.m | 520 -- cb-tools/jsonlab/loadubjson.m | 478 -- cb-tools/jsonlab/mergestruct.m | 33 - cb-tools/jsonlab/origsavejson.m | 386 -- cb-tools/jsonlab/savejson.m | 435 -- cb-tools/jsonlab/saveubjson.m | 490 -- cb-tools/jsonlab/varargin2struct.m | 40 - cb-tools/urlread2/http_createHeader.m | 11 - cb-tools/urlread2/http_paramsToString.m | 62 - cb-tools/urlread2/license.txt | 24 - cb-tools/urlread2/urlread2.m | 371 -- cb-tools/urlread2/urlread_notes.txt | 86 - cb-tools/urlread2/urlread_todos.txt | 13 - cb-tools/urlread2/urlread_versionInfo.txt | 13 - {+dat => cortexlab/+dat}/findNextSeqNum.m | 0 {+dat => cortexlab/+dat}/subjectSelector.m | 0 {+hw => cortexlab/+hw}/DaqLever.m | 0 {+hw => cortexlab/+hw}/DaqLick.m | 0 {+hw => cortexlab/+hw}/DaqPiezo.m | 0 cortexlab/+hw/daqControllerForValve.m | 28 - cortexlab/+srv/RemoteMPEPService.m | 21 +- 75 files changed, 16 insertions(+), 15206 deletions(-) delete mode 100644 +hw/DaqDataManager.m delete mode 100644 +hw/DaqLaser.m delete mode 100644 +hw/DaqRewardValve.m delete mode 100644 +hw/RewardController.m delete mode 100644 cb-tools/SuperWebSocket/Config/log4net.config delete mode 100644 cb-tools/SuperWebSocket/Config/log4net.unix.config delete mode 100644 cb-tools/SuperWebSocket/InstallService.bat delete mode 100644 cb-tools/SuperWebSocket/Newtonsoft.Json.dll delete mode 100644 cb-tools/SuperWebSocket/SuperSocket.Common.dll delete mode 100644 cb-tools/SuperWebSocket/SuperSocket.Common.pdb delete mode 100644 cb-tools/SuperWebSocket/SuperSocket.Common.xml delete mode 100644 cb-tools/SuperWebSocket/SuperSocket.Facility.XML delete mode 100644 cb-tools/SuperWebSocket/SuperSocket.Facility.dll delete mode 100644 cb-tools/SuperWebSocket/SuperSocket.Facility.pdb delete mode 100644 cb-tools/SuperWebSocket/SuperSocket.SocketBase.dll delete mode 100644 cb-tools/SuperWebSocket/SuperSocket.SocketBase.pdb delete mode 100644 cb-tools/SuperWebSocket/SuperSocket.SocketBase.xml delete mode 100644 cb-tools/SuperWebSocket/SuperSocket.SocketEngine.XML delete mode 100644 cb-tools/SuperWebSocket/SuperSocket.SocketEngine.dll delete mode 100644 cb-tools/SuperWebSocket/SuperSocket.SocketEngine.pdb delete mode 100644 cb-tools/SuperWebSocket/SuperSocket.SocketService.exe delete mode 100644 cb-tools/SuperWebSocket/SuperSocket.SocketService.exe.config delete mode 100644 cb-tools/SuperWebSocket/SuperSocket.SocketService.pdb delete mode 100644 cb-tools/SuperWebSocket/SuperWebSocket.XML delete mode 100644 cb-tools/SuperWebSocket/SuperWebSocket.dll delete mode 100644 cb-tools/SuperWebSocket/SuperWebSocket.pdb delete mode 100644 cb-tools/SuperWebSocket/UninstallService.bat delete mode 100644 cb-tools/SuperWebSocket/WebSocket4Net.dll delete mode 100644 cb-tools/SuperWebSocket/log4net.dll delete mode 100644 cb-tools/burgbox/+plt/binoErrorbar.m delete mode 100644 cb-tools/burgbox/+plt/errorbar.m delete mode 100644 cb-tools/burgbox/+plt/hshade.m delete mode 100644 cb-tools/burgbox/+plt/vshade.m delete mode 100644 cb-tools/jsonlab/AUTHORS.txt delete mode 100644 cb-tools/jsonlab/ChangeLog.txt delete mode 100644 cb-tools/jsonlab/LICENSE_BSD.txt delete mode 100644 cb-tools/jsonlab/LICENSE_GPLv3.txt delete mode 100644 cb-tools/jsonlab/README.txt delete mode 100644 cb-tools/jsonlab/examples/demo_jsonlab_basic.m delete mode 100644 cb-tools/jsonlab/examples/demo_ubjson_basic.m delete mode 100644 cb-tools/jsonlab/examples/example1.json delete mode 100644 cb-tools/jsonlab/examples/example2.json delete mode 100644 cb-tools/jsonlab/examples/example3.json delete mode 100644 cb-tools/jsonlab/examples/example4.json delete mode 100644 cb-tools/jsonlab/examples/jsonlab_basictest.matlab delete mode 100644 cb-tools/jsonlab/examples/jsonlab_selftest.m delete mode 100644 cb-tools/jsonlab/examples/jsonlab_selftest.matlab delete mode 100644 cb-tools/jsonlab/examples/jsonlab_speedtest.m delete mode 100644 cb-tools/jsonlab/jsonopt.m delete mode 100644 cb-tools/jsonlab/loadjson.m delete mode 100644 cb-tools/jsonlab/loadubjson.m delete mode 100644 cb-tools/jsonlab/mergestruct.m delete mode 100644 cb-tools/jsonlab/origsavejson.m delete mode 100644 cb-tools/jsonlab/savejson.m delete mode 100644 cb-tools/jsonlab/saveubjson.m delete mode 100644 cb-tools/jsonlab/varargin2struct.m delete mode 100644 cb-tools/urlread2/http_createHeader.m delete mode 100644 cb-tools/urlread2/http_paramsToString.m delete mode 100644 cb-tools/urlread2/license.txt delete mode 100644 cb-tools/urlread2/urlread2.m delete mode 100644 cb-tools/urlread2/urlread_notes.txt delete mode 100644 cb-tools/urlread2/urlread_todos.txt delete mode 100644 cb-tools/urlread2/urlread_versionInfo.txt rename {+dat => cortexlab/+dat}/findNextSeqNum.m (100%) rename {+dat => cortexlab/+dat}/subjectSelector.m (100%) rename {+hw => cortexlab/+hw}/DaqLever.m (100%) rename {+hw => cortexlab/+hw}/DaqLick.m (100%) rename {+hw => cortexlab/+hw}/DaqPiezo.m (100%) delete mode 100644 cortexlab/+hw/daqControllerForValve.m 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/+hw/DaqDataManager.m b/+hw/DaqDataManager.m deleted file mode 100644 index 3a5e9d6c..00000000 --- a/+hw/DaqDataManager.m +++ /dev/null @@ -1,28 +0,0 @@ -classdef DaqDataManager - %HW.DAQDATAMANAGER [Unused] Interface for adding and configuring DAQ channels - % This class was started, presumably by Chris, at an unknown time and - % appears to have been created to manage the channels in the - % HW.DAQCONTROLLER object in a more user-friendly way. - % - % Perhaps this would be useful for creating and configuering a - % hardware.mat file (that used by SRV.EXPSERVER and MC to load and - % configure task-related hardware) in a more automated fashion. - % - % TODO: Finish this class, perhaps ask Chris what his aim was with this - % Part of Rigbox - - % xxxx-xx CB created - - properties - end - - methods - function id = manageAnalogOutputChannel(chan, defaultValue) - end - - function submit - end - end - -end - diff --git a/+hw/DaqLaser.m b/+hw/DaqLaser.m deleted file mode 100644 index fb51b1ba..00000000 --- a/+hw/DaqLaser.m +++ /dev/null @@ -1,89 +0,0 @@ -classdef DaqLaser < hw.RewardController - %DAQLASER Controls a laser via a DAQ to deliver reward - % Must (currently) be sole outputer on DAQ session - - properties - DaqSession % should be a DAQ session containing just one output channel - DaqId = 'Dev1' % the DAQ's device ID, e.g. 'Dev1' - DaqChannelId = 'ao1' % the DAQ's ID for the counter channel. e.g. 'ao0' - DaqOutputChannelIdx = 2 - % for controlling the reward valve - OpenValue = 5 - ClosedValue = 0 - MeasuredDeliveries % - PulseLength = 10e-3 % seconds - StimDuration = 0.5 % seconds - PulseFrequency = 25 %Hz - end - - properties (Access = protected) - CurrValue - end - - methods - function createDaqChannel(obj) - obj.DaqSession.addAnalogOutputChannel(obj.DaqId, obj.DaqChannelId, 'Voltage'); -% obj.DaqSession.outputSingleScan(obj.ClosedValue); - obj.CurrValue = 0; - end - function open(obj) - daqSession = obj.DaqSession; - if daqSession.IsRunning - daqSession.wait(); - end - daqSession.outputSingleScan(obj.OpenValue); - obj.CurrValue = obj.OpenValue; - end - function close(obj) - daqSession = obj.DaqSession; - if daqSession.IsRunning - daqSession.wait(); - end - daqSession.outputSingleScan(obj.ClosedValue); - obj.CurrValue = obj.ClosedValue; - end - function closed = toggle(obj) - if obj.CurrValue == obj.ClosedValue; - open(obj); - closed = false; - else - close(obj); - closed = true; - end - end - function samples = waveformFor(obj, size) - % Returns the waveform that should be sent to the DAQ to control - % reward output given a certain reward size - sampleRate = obj.DaqSession.Rate; - - nCycles = ceil(obj.PulseFrequency*obj.StimDuration); - nSamples = nCycles/obj.PulseFrequency*sampleRate; - samples = zeros(nSamples/nCycles, nCycles); - nPulseSamples = obj.PulseLength*sampleRate; - samples(1:nPulseSamples,:) = obj.OpenValue; - samples = samples(:); - end - - function deliverBackground(obj, size) - % size not implemeneted yet - lasersamples = waveformFor(obj, size); - samples = zeros(numel(lasersamples), numel(obj.DaqSession.Channels)); - samples(:,obj.DaqOutputChannelIdx) = lasersamples; - daqSession = obj.DaqSession; - if daqSession.IsRunning - daqSession.wait(); - end - daqSession.queueOutputData(samples); - daqSession.startBackground(); - time = obj.Clock.now; - obj.CurrValue = obj.ClosedValue; - logSample(obj, size, time); - end - - function deliverMultiple(obj, size, interval, n, sizeIsOpenDuration) - error('not implemented') - end - end - -end - diff --git a/+hw/DaqRewardValve.m b/+hw/DaqRewardValve.m deleted file mode 100644 index e99b9bd7..00000000 --- a/+hw/DaqRewardValve.m +++ /dev/null @@ -1,161 +0,0 @@ -classdef DaqRewardValve < hw.RewardController - %HW.DAQREWARDVALVE Controls a valve via a DAQ to deliver reward - % Must (currently) be sole outputer on DAQ session - % TODO - % - % Part of Rigbox - - % 2013-01 CB created - - properties - DaqSession; % should be a DAQ session containing just one output channel - DaqId = 'Dev1'; % the DAQ's device ID, e.g. 'Dev1' - DaqChannelId = 'ao0'; % the DAQ's ID for the counter channel. e.g. 'ao0' - DaqOutputChannelIdx = 1 - % for controlling the reward valve - OpenValue = 6; - ClosedValue = 0; - MeasuredDeliveries; % deliveries with measured volumes for calibration. - % This should be a struct array with fields 'durationSecs' & - % 'volumeMicroLitres' indicating the duration the valve was open, and the - % measured volume (in ul) for that delivery. These points are interpolated - % to work out how long to open the valve for arbitrary volumes. - end - - properties (Access = protected) - CurrValue; - end - - methods - function createDaqChannel(obj) - obj.DaqSession.addAnalogOutputChannel(obj.DaqId, obj.DaqChannelId, 'Voltage'); - obj.DaqSession.outputSingleScan(obj.ClosedValue); - obj.CurrValue = obj.ClosedValue; - end - function open(obj) - daqSession = obj.DaqSession; - if daqSession.IsRunning - daqSession.wait(); - end - daqSession.outputSingleScan(obj.OpenValue); - obj.CurrValue = obj.OpenValue; - end - function close(obj) - daqSession = obj.DaqSession; - if daqSession.IsRunning - daqSession.wait(); - end - daqSession.outputSingleScan(obj.ClosedValue); - obj.CurrValue = obj.ClosedValue; - end - function closed = toggle(obj) - if obj.CurrValue == obj.ClosedValue; - open(obj); - closed = false; - else - close(obj); - closed = true; - end - end - function duration = openDurationFor(obj, microLitres) - % Returns the duration the valve should be opened for to deliver - % microLitres of reward. Is calibrated using interpolation of the - % measured delivery data. - volumes = [obj.MeasuredDeliveries.volumeMicroLitres]; - durations = [obj.MeasuredDeliveries.durationSecs]; - if microLitres > max(volumes) || microLitres < min(volumes) - fprintf('Warning requested delivery of %.1f is outside calibration range\n',... - microLitres); - end - duration = interp1(volumes, durations, microLitres, 'pchip'); - end - function ul = microLitresFromDuration(obj, duration) - % Returns the amount of reward the valve would delivery by being open - % for the duration specified. Is calibrated using interpolation of the - % measured delivery data. - volumes = [obj.MeasuredDeliveries.volumeMicroLitres]; - durations = [obj.MeasuredDeliveries.durationSecs]; - ul = interp1(durations, volumes, duration, 'pchip'); - end - - function sz = deliverBackground(obj, sz) - % size is the volume to deliver in microlitres (ul). This is turned - % into an open duration for the valve using interpolation of the - % calibration measurements. - if nargin < 2 - sz = obj.DefaultRewardSize; - end - duration = openDurationFor(obj, sz); - daqSession = obj.DaqSession; - sampleRate = daqSession.Rate; - nOpenSamples = round(duration*sampleRate); - samples = zeros(nOpenSamples + 3, numel(obj.DaqSession.Channels)); - samples(:,obj.DaqOutputChannelIdx) = [obj.OpenValue*ones(nOpenSamples, 1) ; ... - obj.ClosedValue*ones(3,1)]; - if daqSession.IsRunning - daqSession.wait(); - end -% fprintf('Delivering %gul by opening valve for %gms\n', size, 1000*duration); - daqSession.queueOutputData(samples); - daqSession.startBackground(); - time = obj.Clock.now; - obj.CurrValue = obj.ClosedValue; - logSample(obj, sz, time); - end - - function deliverMultiple(obj, size, interval, n, sizeIsOpenDuration) - % Delivers n rewards in shots spaced in time by at least interval. - % Useful for example, for obtaining calibration data. - % If sizeIsOpenDuration is true, then specified size is the open - % duration of the valve, if false (default), then specified size is the - % usual micro litres size converted to open duration using the measurement - % data for calibration. - if nargin < 5 || isempty(sizeIsOpenDuration) - sizeIsOpenDuration = false; % defaults to size is in microlitres - end - if isempty(interval) - interval = 0.1; % seconds - good interval given open/close delays - end - daqSession = obj.DaqSession; - if daqSession.IsRunning - daqSession.wait(); - end - if sizeIsOpenDuration - duration = size; - size = microLitresFromDuration(obj, size); - else - duration = openDurationFor(obj, size); - end - sampleRate = daqSession.Rate; - nsamplesOpen = round(sampleRate*duration); - nsamplesClosed = round(sampleRate*interval); - period = 1/sampleRate * (nsamplesOpen + nsamplesClosed); - signal = [obj.OpenValue*ones(nsamplesOpen, 1) ; ... - obj.ClosedValue*ones(nsamplesClosed, 1)]; - blockReps = 20; - blockSignal = repmat(signal, [blockReps 1]); - nBlocks = floor(n/blockReps); - - for i = 1:nBlocks - % use the reward timer controller to open and close the reward valve - daqSession.queueOutputData(blockSignal); - time = obj.Clock.now; - daqSession.startForeground(); - fprintf('rewards %i-%i delivered.\n', blockReps*(i - 1) + 1, blockReps*i); - logSamples(obj, repmat(size, [1 blockReps]), ... - time + cumsum(period*ones(1, blockReps)) - period); - end - remaining = n - blockReps*nBlocks; - for i = 1:remaining - % use the reward timer controller to open and close the reward valve - daqSession.queueOutputData(signal); - time = obj.Clock.now; - daqSession.startForeground(); - logSample(obj, size, time); - end - fprintf('rewards %i-%i delivered.\n', blockReps*nBlocks + 1, blockReps*nBlocks + remaining); - end - end - -end - diff --git a/+hw/RewardController.m b/+hw/RewardController.m deleted file mode 100644 index cefbf257..00000000 --- a/+hw/RewardController.m +++ /dev/null @@ -1,35 +0,0 @@ -classdef RewardController < hw.DataLogging - %HW.REWARDCONTROLLER Abstract interface for controlling reward devices - % Detailed explanation goes here - % - % Part of Rigbox - - % 2012-10 CB created - - properties - DefaultRewardSize = 2.5 % reward size if no size was specified - end - - properties (Dependent = true) - DeliveredSizes - DeliveryTimes - end - - methods (Abstract) - %deliverBackground(size) call deliver to deliver a reward of the - %specified size and return before completion of delivery. - sz = deliverBackground(obj, sz) - deliverMultiple(obj, size, interval, n) %for calibration - end - - methods - function value = get.DeliveredSizes(obj) - value = obj.DataBuffer(1:obj.SampleCount); - end - function value = get.DeliveryTimes(obj) - value = obj.TimesBuffer(1:obj.SampleCount); - end - end - -end - diff --git a/+hw/RewardValveControl.m b/+hw/RewardValveControl.m index c79cebe8..aa94b999 100644 --- a/+hw/RewardValveControl.m +++ b/+hw/RewardValveControl.m @@ -9,7 +9,6 @@ properties Calibrations - % deliveries with measured volumes for calibration. % This should be a struct array with fields 'durationSecs' & % 'volumeMicroLitres' indicating the duration the valve was open, and the diff --git a/+hw/calibrate.m b/+hw/calibrate.m index 6bdbc0e5..50cc778f 100644 --- a/+hw/calibrate.m +++ b/+hw/calibrate.m @@ -1,6 +1,9 @@ function calibration = calibrate(channel, rewardController, scales, tMin, tMax) %HW.CALIBRATE Performs measured reward deliveries for calibration -% TODO. This needs sanitising and incoporating into HW.REWARDCONTROLLER +% This function is used by srv.expServer to return a water calibration. It still requires some scales to be attached to +% the computer. TODO: Sanitize and integrate into HW.REWARDVALVECONTROL +% +% See also HW.REWARDVALVECONTROL % % Part of Rigbox diff --git a/+hw/devices.m b/+hw/devices.m index 0912ce62..82598ab6 100644 --- a/+hw/devices.m +++ b/+hw/devices.m @@ -65,14 +65,6 @@ % end %% Set up controllers -if isfield(rig, 'rewardCalibrations') && isfield(rig, 'rewardController')... - && ~isfield(rig, 'daqController') &&... - ~isa(rig.rewardController, 'hw.DummyFeedback') - % create a daq controller based on legacy rig.rewardController - rig.daqController = hw.daqControllerForValve(... - rig.rewardController, rig.rewardCalibrations); -end - if init if isfield(rig, 'daqController') rig.daqController.createDaqChannels(); diff --git a/addRigboxPaths.m b/addRigboxPaths.m index 301feee0..2fa54a8b 100644 --- a/addRigboxPaths.m +++ b/addRigboxPaths.m @@ -38,10 +38,7 @@ function addRigboxPaths(savePaths) cortexLabAddonsPath,... % add the Rigging cortexlab add-ons rigboxPath,... % add Rigbox itself cbToolsPath,... % add cb-tools root dir - fullfile(cbToolsPath, 'burgbox'),... % Burgbox - fullfile(cbToolsPath, 'jsonlab'),... % jsonlab for JSON encoding - fullfile(cbToolsPath, 'urlread2')... % urlread2 for http requests - ); + fullfile(cbToolsPath, 'burgbox')); % Burgbox % guiLayoutPath,... % add GUI Layout toolbox % fullfile(guiLayoutPath, 'layout'),... % fullfile(guiLayoutPath, 'Patch'),... diff --git a/cb-tools/SuperWebSocket/Config/log4net.config b/cb-tools/SuperWebSocket/Config/log4net.config deleted file mode 100644 index efa786b7..00000000 --- a/cb-tools/SuperWebSocket/Config/log4net.config +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/cb-tools/SuperWebSocket/Config/log4net.unix.config b/cb-tools/SuperWebSocket/Config/log4net.unix.config deleted file mode 100644 index d6f35702..00000000 --- a/cb-tools/SuperWebSocket/Config/log4net.unix.config +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/cb-tools/SuperWebSocket/InstallService.bat b/cb-tools/SuperWebSocket/InstallService.bat deleted file mode 100644 index 50530656..00000000 --- a/cb-tools/SuperWebSocket/InstallService.bat +++ /dev/null @@ -1,2 +0,0 @@ -SuperSocket.SocketService.exe -i -pause \ No newline at end of file diff --git a/cb-tools/SuperWebSocket/Newtonsoft.Json.dll b/cb-tools/SuperWebSocket/Newtonsoft.Json.dll deleted file mode 100644 index 67b9d3511ddbd23596535e262c468dc3b3df7385..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 358400 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™~ zh1;P2&@NfTGRx^~1kk#3nrb&JWlq+@Mr|y%>C)!R=Rr`Gws;JdK18%|vk@24oVv_? zj53E+Y@kw~rEwumm$yPyzppND;Ud6s=rHK$cIE+no^T?Bm+brrIP2_8s|tc>cJ^WA z3u^;K*z)X;&#&WhFAFR}t}yMdg#nS)c3`t;p1my)@V`eBKX)PhmZwCLw1FzF1pDvia#YZ#DvKb!h zV|%cvr7GQvYxNOjc%V2LgGEk_XgTMN>eL%sYw)@f$o#%z7-YW;)BBu2IKMV__d!bj zr9fZ&xizYAlOv-O8F=uXxn6Lq7160&{ZH@kRqcoT&t!^s*NR?XjjfX^-^hA8O-mJNEWg!ik1<014-hlm=w$CH5@S`NE&`{xiYfoOnAT%^FqqHemUSM?Ug#0}f_^WbyZHa47KLK6EtK*!BJg z>advc4%P-Ew++cy2Yf02zwp2BANb>R6!^EF@$dY@T|Ws2?nDPLM^&@as6`Foc7kD2I6Nqx0>Es!5b(e(4#u#%kAJC(-;ut>CSU4+= zM~xrCh`K1i7RFie5uiKpv6w#rtimOPTH1mq!(g2;YU9)j9PNP=8W6=DkzdU0rLP3 z5h5Qw`mY%nG&Kg2G0+JprVc9(3~YfCwUzWOxG{}FC3>r(a~`r-=yiH66Q1&+sBaky^ShflEz%^&w7^KFG)mQV_~KoZit4!Lw}Dc56umNA|yeb;pj!*xY4Yi2e5+jA_a{-U{!l|`g7IcH^oy5f|Gh*{m(Oh+{D55pGG zv^Np4pqkNI0@flS%c5EFD}*(qdx^7%x={T8BJZF7C@<2_Q0F7hNZ&joa8mM&oeBOq z4w4Zh4mxkx;unSfLP(Xjps8Yi^gAGV&&G79&43ibM5E$2griGbo>B386qywzi5AL# zBsmrJSdGh*XVhQ_kfR4vj~?^o1M% zc|eQ^5^gN7GRjO?`sYga%t4oDH4-kHQ@>8w>#Ihz71EPq9?o6Nqg-)tr(7<4U1fyP zhdMNFCkz61!!_gq$b-Zr&9F`(YnFRqgtIT=9F_}uiHD`u3wC_KZ0m(}{CtL=2s?(K zgs-cNFxWhzm(#8qum~jsL)>>&rH~nXDxhPWrWpuX2Bwq4G@tNI}mCEw|S-o>d;YiPdV=<&$_T8 z&m4SRWrWq@UJn+UXHh5#daexTKq)*c44G{x&jppe=L&nOu0lM+hn36g!zm^nnEA!{ zqya4D9?yn!)wqcVj2cz5XCusU&2-4RSp1Qh8E6dX^JCD;VDaP~4qtSX!IQAfGD1yY zUrxm4>iyVp=7 zCUH28d%_GW4?eAYf&zfNif}JJR0y=Z2*7ZLn$e89p1Lp*r!Mr2KlI&UX$Fb`v3IwO zBsj?jSP&l^M@61QQJ-RyIRm9|v8l{J3n17ss987z=h-NE@F*D3a4BHDjd6_3FB@g8 zFdVR-w8wHQrjunFnR$YAmH1*REhY3G3UNH{psD+ zBE~ZxHZbg4JTvm5FTNOZ186~7)JES4DlaQkVX{s4b+yklvkIrSV{NdF+Nf$--LA2D zhD~8jGdlerk->;AD@3@jug;0w!$)cHTvc5twXhL7tT(bxSKC>baqeTaJUSuXu+KZy zt-WD3Fwh!$)A4$>dN_CkLY{+=P2t#f8;>r2+G&$}>d+m-)4m)SU1gZjo`#hRS@fKu zoiDIAU*THJ6a!^k-4QP1_AT02c|C=kio1gKYC3vldds$f&=~20oTxUOI!O`@xdR*T z^`fQ8bIS0lFxeRxhsYP=K>dCyp>Z$^VZ{swk)xdUO{LT%#UQ3v57uDBvT73(jOBYYmX20GHwkXy-1+d6fA@TR$(Rz8^J z1s8|aY%keyx*H2=&J3=;=o>kt4{l7vT&@FG5KQ3LDuCw*k6+H zdT{@c_0$F69>*&`^CKncfwM~zZx6Oh636Nt?D#pOJ^5%^DTK7Tq~ffD>9nIO2Mchd z1dQzDifC{}Y}8PH$LbF=falO(-Z}KQjPdOba>lm@zKCH@eEvay9iRj30urn$@VDt?QI8s=MaP)I@k{U&LIdppsOOR_?<%# zcIad~@H>Y&4w;y>h}Stq`uLSD4S`2anjY9*AXkXw7^=D;3!l?1DGs92ZdH*9EQ_= z6SpE@eJ}EA8m;rdAKS#lLk)*~bOFGE34OwjAF;!(?dR;SW$++1AzMEtqA!!pBjpol zD8C3|qO_D>%!iwP;kA2Uo+5G{FTw0-Iq^ChG@)JNQ4k;Rs04niPG`* z9$(&yGdcB(QrMg_NQWbA!U#P(3DFt2G=|U_s7InRL+c+Uk-J^bA{Y=ufJ@MH|Jsmx zMeZ8YVMby(FiIXHn-4E#8L?5v%s8em@3kckM|}scB-eD-N>nugu{cxY7%D7V!^b$& z8iZgCQ$~D&%V-V^2Fw_O9AOQCiOHv3hXO-7zitN|A3TDD)(->Qq#3rPLN6oSKqiAp zkLV^o*!r%pC!07>ha~$WT$e`(oLV>?duI$&yoG=+{`x9D7z9y?6{iBuZ+LatP@D3#@dDByEs`} zlKM+jU052tYF4${khRFe*tHXBSMAzFo(|VNDrjyZoeP^7h4rI^Ruk64sWBFOT#Us# zgC|ENyW`|2zjnR}k1H-f1@r*xcI#63;EhA~Kx`bp54vaD!ddsjCfKAS_i9zU&8}FQ!(IzkQdE6SxaMR($y`e_kf<$!P#)#+(B5T!Mw< z`#D6J5~(t7q!nuzR2kQh^*LRJ(d9X87RHwcPx-}wi(@lHnf~k%_Z;ln%$B<%Sz`z} zN~Ms?h_4!Tpf^+eNt+Jkk92`+qf7%Afj|nO^&lJllV`HLE=p?`bh1QGEk>rm^&rx3 z#&H~a33S6hn^iP2GMKF9k-Q4M&eQ3Pls^yce>A3nzHnSkhiL>EXGLRpRJ6dwjK)KV zf%Pz>vGL4k+z_R;qY>eL4y}(kliI1Zj+pr{yLX1xM@Fe9poVssb2_7V>H9@lmq;3g zbw4Uf92iaWS)$$;rHExGbwB1VoC?!vY9;fe<{Z`Un5a*=(jY#+tvh;~LHPXGd`gF+ zz_a>NO~L#Y&D3&eEO<;Kg?)F=j$9DrqJ5+~$!V5HILvO@?(O8)j<0x*c;$s#L26a6>q=VYu+uj3uvD15_{keoZG~ zhNd9EIoO|*ZPyl(U0BtM)_K*MXerQ#A))yeYdgb9^`UDEn~HTv-orK(Y6eo<%`mHv z#^EYS>WQ=1u^8)8YqMinjuZ>D(lP8Nge)=wcfra( zn){=9J~#=L)GF|d`uDl2K7w{-u1{|pKrLPYf#NVXkwlW)wlL=AP?-yc$s*s?_P4aiHuy&u zHQ>p8ah>x5+kmkd+u#p#oD~v4jLkoafPry`= zf)~Of$L2K??laYOc9|OVn%<|>C8{9nNv27x1@`nXk$CsdTS%ivn6K>3SC>LOvHtT4@h(bQ6+0#Th~_^G%Um;jX~F2%*ym?B|ia z;nWLWy;Cj5se!*kO{1fnB}&#>=)!u63c+rd``}l<2Qmk~R#PuB8CaMd{B#cbfqKi_ zPcZ8Ju`<^#SFxgNc|L@wSb%Y;vhwkw{U$Hdka@s_8}_gmhisdIAx(MKlV>~PqIQxZqoBP zg-vzF@z3Sr0y^prU9|PeKgu^VgkA#ikH%J8=Gp|~%nG8Qt0%CWiQvw!k0SU0j8Y~O z-B=o8tzp*;Kf-uTe7KOlb?mCxLTa<{7}>^ig(x&5*?0 zFH4o@G--~zKk5$Y#sSk+QpE815WXl%Y9M~-UEoUTociq+X+x8usjZi4;}u00rMT+N z03rsf97#I9jTQqRy)Yle2Ym1tAVkf7VcMW6mx+9uB)<^|SJiKst z6?B#0_7}$+JmVaP#Rq@9fy#XjRE1i{m+$0mO{st6PAgxhS8@9^Jf!35A8dFh9iF8N zdyeWkFVD!SpJ6Rx;niif6QchF%Y@YdGGH`o-Nu~a%t^-ekA^h?Nno{y3z}1G&0PqO zt^hRbi$8|>8JLk$zW42WFASBdYL@EqhJ3LJ#g{i5m5ZUbz3f=A8P??+>2`5=B~o9E zA$H6k#gGZ31auMU(S zJ;`{AFLQ9_Pze;>2yzEc5?hZj@4B>fZ4LBt#dbN>)mBrQ&JfyY$@d~JPKj+ptx(BW zLA$cio$FQ)8^Mn+3U|(|NyuYpTGunHtwtEu2AHHNKmiIp%A(QHz`I^DoyN0S88*&l zS>ss9eHxx*@yx>PDH>GjswUz!l)BQP2w!^J^}YNj5pTAI6bLb+BmtW zorzoM#YOE*a=zD|96W{08Eh~QbaGZfdLj@@dIz|nCr&P%l^kMb$Q`rf5pVJ_J)Ln( zKdjx7ZxWn%&%>jw(d3YaTq#N~i5`E6>B+aPJ%baxz`bGII~eZPWAKpR)gCj3SdRlu z^q?I}!`*n!z!N|NPXeSk-MD=-1$Td(K&Iw;{0PK(E#l1kQwkZEYZ2m<&>9dUE^F}t zm*(K{6pZnvJFpJl1eXUd?5&5XFe$VFpG23(8+aN9FAk*O6K*D7I<6?)3nd$))TCT?^#1Dn8&mv#j~h6%EoR=- zI`R&Zvz`tLJwskDQ?i=1Suz0S=mOgHpbe+wvmi^z&qUGQLMc$aY{j?G4Fi=4`)$OJ z<0^aOv>!JU*jL%JzRnK8LZzb<# zb^8(=IG%YKUyNs7;pepLKlsFsT{x=3 z@`c{p%qH>~$}f^;lzI%mh?Qp+)YNu`7!2y`u&}mzBQsJ6)j;G%rD(U{VRwf1p^rz# zZg*rGV#plbvkWV`0HQsF4p4AIGJHaQmPaytyouMJ@r)6iBH7OFpB$6pWOnZ)+=r{H z8egE>lodwsg>Z(iYL5J<&mP%3y0Xzq$sCvL<7|!iC$e4)9u9jkgIRU#;!=g7qU}4f zXoX!Smhgbu9eJmX=oT+S-W+AkTg53k9jd+D$ITb1VM|Lv2GaTe$v+ka&i0QpxY{zt z|B;yRt0>V~p<-d>EMMU)rOPH%q_!Yka8j`uh_T_;D7yfQn-9AK+%k>B0@9y1_}vP>X$he(5h)yhEhmY$j4+zc8kZq$RU{hTsSL$(P#KqbB}mH+#OEnY z^qzKo1!L$Cl0U%{U-cZim=ZPnbZmH0-);A{xj?T2;qa>tyiP!SYvgIWiA;}z6pcvY zhweno@`A_1`8lT39wTe@k$G5aY){Xnv+mT*Ids;8_Gjp9o7UOa5R^J2iMNa}R%gVu z&LUaIJC&hCbS6lQ&c2D&*(?8(&P2Mf&R*6!qg_O2M6}M>cKnmhBC`HJ(^*e2KbOvW z(LP3J4s;-&qs|gT-y)7|$c=Imr8vv@2}V{tYzwWoNajy*PGwM9ad@_}OfVl>tb<+Qrn@An5xbU&L^jfAi6)nu8R`& zBtS+&YfP^zBl-?9=HlW&*app^at9irOfn zsFD{_;tKPEPlPpK@7UoZia4MekC}+bQ;IL5(V614HldG=XP)yXoK?lPFsxtU$ft$t zB07>|effQiD4|}P_h^;mm!$dfi;QS(q$|~|>Rha?3@_gWS|bgobQ=b*Ur;@@@MaNZ zO^8V%6Vnb=h`OiX5<3}cxh9~4l^u8uwuaPk*7L%+YfO?@e44tT*s!w!pESbMV4}q> z$aZYfToYNV6(s1|O|G5KW0y7ZEPo{up)hH$8GF^E7%#*}U+5Bsstd@;g!0b$_58}WgCa_BLQ=@t$r z+=!>^MjY3sn}OF6>jGy+{4pHR9*^(}KZ$L>(esJ)8FN%_XWfQJB-&1ibl5Er_6~%d zfgKO*8{>*VyQ^T}+$2RuKME&vBn-^+L>G_3eFU${vBL8_hRg%M2!4LM>yenB>9xBa z?!X%&G#*RO;?Y%m^610Z$)oh}yXXd*^M-|I$c?femb*RVt{zS;I~*ql@mf1)1a$n& zfOMbQs~Q!B`#7fcCPXx&?S(lJ9scYByx}u}hVr-I%&Q8lBh~3J(iW##Mj5}{MZ&Kc zc$*#!d#MsV5ZU%2Bv|ef5@+BYO`~r&4Adggq%--vt7-JMg|k9AYP)hLfh+}bbIBQ@ zyjLw0#O55K^wl)hW&Dn_SzyJJtn7_0*86ljOTK=#&!PNQoE7+`+SzGcLBpAx`-`hJ z_*`xSwA%;#Ak8@j<5%xxktjxVJy1wZi!xw_#DiqI97a4LdS-xnONw=j9xBQA(-45R+4Ds z+|b+u(~unBVAz~bo9?tZUu@EG?O%?!xeFc!zXo^Tkep#KFit%Yr=2J_IX+*5WlQGZftKJ?`6z@n$j6&NOO zWYO0eNa#`PgAL;LCv&lC`_F?|DFER z2>m$29AdqKHpdXyVD7iiJowxG4IPJ`mg702C^P4lHb5`Cpf`>ZC7BBNMHLHNs#{;P6(S8o$j@9xEeVzPK~FIZw&W=1jVGl+wEC5#vyg~`Um9sGKXiI^jE6tpmH zd(*ZzwS8@y-7R2e^P^U#b0w^nZjW6tQw0t5!j zp=pe^jQg6xuap5fQK2`!8G+9s@HCFB@rHRqV^a??amrby9AyrHC;gMNRRX#?j^~U* zm+W@5Hz^llqvRrn5d0><1rVCWDB*KVAE#y_+}+f7a8Xw^z5=UDzQ75DigS&a=!Xv_ z?d#XPK8>g+YZx{xI63_gy-!+#DtXK;imxovC1EDu zyO(@wDs=z8hb)|PF=aBUx;^h?cdFK)H#s)WX&YFLRQI{ki<0=Ja6;cvNl zFaXwk*=f=X&s~_95g&CENKI;1G<^=u zV*k57_J{a!WP(|=h1JFooN%%4K#O5K+6X>HI*#Xzz>f&T!ay`%Ze-Kt*$3HMvV7vm znG9@^0E`pb0EOl#Hcn>n#V{{ZpZ>?Gb2*;UF!*bAX*jgTQ7Y8*g$UVRao2w87y(I( zbe$HsI!F&R@LUle!PEEe$N5uT`T2YzYZ=y*g+9YAX@>`rG`-vD?w65xEEU(S4d(DM z1y>uE9Fkk?^cw*t(@y<|aq#75yX3gKnPn|zy2LqM2rPWXrM(mWeH}R8`pVDsrZ^ci zSAN5u+>|ik7>3^n5fS9r^BoBgtMRM@tLf4}#6_29*mJ~e>-ZALr{@hh#?ozCu$jS5QeW-e8o8$4WH?wFjaw>7)$se`<#xNy0&kE!8Q z+?l>PfmrQ;0%T);U5xL+Gi0oa%-n>9Y8MeIeo-}Lp2b!Rg#{b`vVK?rUDhw}teH`1 zxYGLN1<&Bco3!jRSvrE@_VF5)Gif(HNG7(Ay4|d9&-9B^Par;rBtDUi{`SRJGE?W$ zwZVBVsgTelYOx>JeB&PE5bK~dA zNiIstZ)i-IilZC?t5YirgNETqD1m78y6* zOZK0@5KVocI{w(zzF^_vYT1ifLT}^H4{EzYzrf#=;N&n_%rP@5;yh|*`>-p~n5c*6 zIuuT~BzIF*Ybc3%26Gmw>i-f4p9^uI+LQC^*l}G zlNcip(}8u_YYbc7{E28PE5E%C1+6&Rd{^LC@Ybsq`ZXZbu_s~0Tq+cp%-~%Zx?hJh zBkwEE&AUl{FPpb3@Eh62IU{V&8PM-%1jGvzA^~-eE&Wh;1f+;=@fS zMEMmQvQfr?_`Z(#>wcXpc-=kdg8v1=##ryChdJkCm=gF~Y=VrKQG&eg!2f7qgrmLl;Y}VWhY; zp9XLZm|)H4>aE?8F5O6;wjkg#G)n4&2uJfxtN z*ju|mfftj!u$AGQJK%#6ukFR-lTn(AJ%I@@S-U|O=noc^5OLg+*H3}czaP>s>U6Cx(_L}z+b@|VFZJ+G}*RtP=l&h>FbemY4q5#JSS3#Nf zk$Ab(2csZ%l~@Q|(plnLLgAh{nri9H1d<>{f@j_+X&C&=tOQ7l$kl)~tuoLRw_hA$ zzD`E?vZjs6T|@-yczWHJ=OvE7jekb)YbLAI_4*Ox+WHOSXpu7IovH~_kZ&>gu7L`x z05tr@&{)ZkAxwiwVxi$TmL?^J2Az4K;Ww71W(-Z4&4=Gun$##wjCCL_V!g52_@Op) zFGqq#;CARgkPf{J=d1A0Y8cl?}IZBWXrdnLHb3_o7}F)Ox)^L8GyA=f@DX(jef zSyR${O}S%N`u-x6q+g7@&fsI=SYW8OfMIY22ea{Ufeb_(qc7|qlOv`WyUXY(vQF*C z_NbV_kZJviZmaZb`Xeqw*D02G*od@#~91nKo`U0UX{&cscDc`OT7vkdjOL zm=o9|K;y&&Tlb-|_BhG#Wq zn&h{{%^&1Y@myH$0?NIFS&3F+FPmkhzK=KD)=OY+%Fv-vvcn!vZ42kK>k!V-vt+DI z8S8{)Y=RJwVlA*sr!MSJ|MHqLLr-C1HwQ6K%;SJA)WSI+w722ZgRx`}uQN5THj8jD z+AxP9vX-<`cw>_a#5p`PD`M155X;td$o5`X2jx*641*3Rp?<0ELV2k!KJ*);O!T_T z$;iO=g5Rl^T##|D7mY@GX_Dd9!PJLsexl*A(FQ=PJ!<#Vnd+K{$@1XA%fc0h;gtQp zT8Bn-pCVSuLa2uHnA-2lPs3yc^SCu0vuo1FLT<)jU-IoI_Pm{5r!raYs`=MstoG03 zB#lTcC$!hxtAWXLJ?98M>>g`5xcjYXVi?n0^={FUp11^qvS@UO>DPvAt06Q}$dpP8 zG=kBG4W9o_XUe=A)-Nn%cs+$=HA4dE?xalTl>*Jf)sZA9#O9xxsF)gqc^-RqLO6U|k<9?iSQsbQ> z@mfu<9%1Bfo*dlA>i$mv`(}<1HYEFeQul|3!H8XPxYP9yFbs78uv!A(PIxFx_tiMQ z`2b;N#OJ5GdCi{%;qncCJT|lix`G(fD6nbdEBq)Dos*_9^%96Cp;)M)d|83KNSuPH z_n~VV6Fuy1B{lZ8@`K27EYSnu9%EepL>LKY3kd#sSiMMlsjDXdTVO!sE7fu(%tng_v0F5T4f6wcv?O zI#^eZ59Y$j&YMhzWsOa#ny0n~B>WO)Fx zy#S%BBqvj*5eVyJh82vw324R!YsLg?#sq6df;Dok2yidGkuy<$UHXHwL$vkik5@jC zY#GF@w#bEMpa2ke)B%3%0=cnItO%YHy=Hkce7(+cR7}`6 z8M}wTTSg~X)mjqH2&}M+VHqj`;CdZed%mEVpV1t11P5TqGxV;p(l8aCkRV;WTRV#! z`u&YhsXx0SLLKJUb#M!Y$vVx(nIWw6V99f9g0uwU)9Ig4oa8bxWSv8=D1_$)&qFxU zk3|0}Ja@_rQ&>NE9%3Vm&YA->b9yKQLhVN53$)bs&+@`8kenR04pQ;s*q02)l@m|6@+iXWf9KVQ zbwM>=Ji{Bqt0)={ri1*=(Ll&f(_r>e#8S?`qwDH#o@CoQZM&fcZnd&W2S!PI8--_5 zBF-$&1^9l3JW>O@=Ix!Y_M}`Z)!98(Eh94yS_NxX)w ztPZ^M#?;DxHsR~lfM1vs;{1$-0+$O z#`6cXCsV9qAr6Y0&3$*Y;Wo6#bf@vcYUbGmq|@$Zx}xFlGC5RHS_PoT|eEn@TP0(_uV=& z>({wuFTXsdY47#}Zflfr=#xjg{&?Tyn2ooJlXt$`k8!956-qsh?=SBt)u8``;HYsUP}kw^ zA}otOzZ7-@`>Vmn^;|-O9s_$_0Jo9Azk$C7UB(31Zu1XWIQg=iXXCw`BL1UMsq!-) z<6c1kH-49*1N&dEQrPgS=D{8XLf*;3Y!S;EB+?_gvG`(-gxU{=kHUN#%=niXXMX$O z_auI(dE_lKRIPR_UCLcUVJ0L&V5-2jHF98cd5xZcqr@fz=u=B;%!18kvDqc?Re?tZ z#wC9N*ILP{Mv`ii+yFBheUi!N8rZnhUCEu7B=u|Z z5m4gUW!PkH7%077#u26!M%Slq*ucfP@=UW&!RM_@=r^iX@|d}lsz6{jfnx;D6}Um*I|9EKShGIq+X@`p_|p`h zI@pA<=xi~>o=sb#|9he-&DZBq=NW}GzbWuY5nX>RqRq{vebKJhXi3=~YE7AcYxi+G z=(GLD?P{ve+8=FSQ*G)%xS}KBQGwk$9c}MdH+34;$*-mhTqtm}z&8bcA@E0mtvi#l zA7D)tI{#>Um%5=VwRClN!VW#YKTU#~d00yZmY z(|>K7wJtSgEx9dPdppeA)*hRUQ{7_olfaax=$a?6tH7%SP8N9gQ{?ujz|CTFNZ?mb zk$>tswQiNsWF7CtX}OM^2Lbxj1To*SjxzLEr@fFBEvGz`+8q2CS=wZ>Y1tr2=AezrfXE|H_6G zycPbMz#jz0KTVqk0y_%4MBq(;xIyk|hWHSmPkr(j1RfOl*(U1tuT5$lV!D|$%buZm<1_S?@hoi$pJlAuKFb>Ek!P6$pAq}L0uMb) z%3HUn8a36rtt?Z|34C9ma~o}H2+R=JMBsS>F9!6h5!+arZWlO9;HQ8!)%QYkN?_;b zj<)~a)^L~YEYZUSZrM%=UlI6`zzr|3Om*DBQgiVRma1^y{8^&n}+9Apl^8PKKfI7t0$J4pGmaWt3iJ!i6_^j`Qmx*k-}l}k^dF($7mhH$y#t8)=WV9Qi|@!KBX(;oeG@e3byXqS ze%(_^#wo-??4|YqpN7?o{y4zx#YiSeIYBuNEq)xj6?ppwZWBP6j7~FlK*8-+aVy2{ zbQ8Aofq8L@+{LOA*n`5ev+4`mWb_>h;j4?R`6a7o1nVj@&&ldV!7h;Te6o54Saa1~ zv4@ka-W0cUr=74LN?}e zkju^J2R(=|G8{RezeW8ZT-rJq_gmDD;;$#%1_S$Du)%^&R;L9UWn+eeI-6=^Zo%%g zu|x+$SPKj<)2SL*xJ$+^e)62GQUyCA*c4Suuy1WFS1{8c~CU9zXM!YS462gO5RXOwGE$YN@rr zsL%6&eS=-ZLxr-ZP(H1$7Pl*f@>v_3Ae39wwG#TBz`Sa!8lmL`maJY=cNo;*W{fwz z>Rq)-+;%#dDj%zz62jY{j03huu&)LCj2QeGaXF4L>Ps8T5bRrZP$)}*#UWQ8HXekQ z`Txc#CaCj8waZol9WGV**(+vd zvCe`uw=wSNOjZ}$SZ~3u0Y)zUP~4N%cwmFoAL??!rn#tVmt(TYx&Rd=^qK5<*ko!h zgTS~w|L8*wXgw04rRr<*C=!7<#)eAXY>BdVigvXgn@DmU{)SI2AO zuL5q%p_#XwUih00Y!pUypNZR2aqH>$M*KYlf8A9dN0T^m*#@jPu>3fNen==Uax@q0 zO<>TY<3hnc0(JpP`xSzHCzJymx5cqM{0=Nm4RYKUr=>Qr{c032>Ltr#db$By7ROX6 z0_Me=;kXn7SVzGs95_Aw>!-e+uRAz`Vfz5-i#4GXHX{bmY4{ z&|oWIqi~mAp^JLC5ZGPnAxE(*5pFkNbUX_8NVOEVAl$sT?We6^w*uRWJ2<)uwotHj zj$VR2E|kwWE*0z)cY>|?=RwXt6WdyN=m!8UYg@GW4}M7m(V zI{xFTgk((wJLUL5DBB5^Z5%_SQDgd;U+NjZNC-pW7N_bPr-X784x(>GU3$t@3CgL! zyr?g6?g`j^;;#jse`I)D;MT`zVI;YsH1#Ij;!t0<7PrsDt&@!z@h)?*<9uTPCKi&F zAJ|rPfpN2&HA7Qi3fL`fO5GWl7uaOME*0z&V~+3~Em%Keo^ZKKu>QsZH_PTjz-nP` zZjn%KjAzR=z*y|&Hjkaa=AsT>;%1qCHy&?~q1N3DELnXFY$33%65h|^?`q>Y_YAmk z8paE^7u=`=R1&a-XcKn})(F@=YP7Leu=c=~svC{{68gXdhCar4MR<-BY^;H2K*8l^ z!NwbSz!TUU!6q2*3FQjGCK|`x)cjL|-C}$$lrI6>iuU+>#8#;<5?tmCHQ6{V{{9lm z$p&83fxnuGq?~NH*(p=!0h^(w7|G(+1(+9DnulBlBr>0s8yVs@T-LsB=v7CI34+~%g&*`1j0(X%7VJyo z4#5s$FB#f*W1(Pyoy2}Kmc(Gc8_NW{V>jJS8OsHGdk?Wcj1_{_-An9G<3ZGixR*w- zzpycv@zXs2u7;g99*J@Lukom07rsm`run#FLBZn8Ck0z7n8#czn2xQ_TqoEc;+9}; z5bT;)$fbt4NwC#|)ij?G?0{hD<`%(f{)d#A<~G4D6D-TzF4zLW>XsLf_*Gl zOY>d9ba<`I_XNBC4f1SlejwN^!P=O|1zRUrd-Ef~jtbVn{6w(fZ_;09^E1J;oL$Tl zf@xh}V16l>PU-IE*MiM>i~f3=-+Eb__B%qXxA}dHzdq)#g6TYQk@=foTI!3V=vmj$HLY%~q(sKC9Ut z@MO)-fV)$t1OA;lAJCb$7%(~QKEUj><$#UTRsyz2TLm~Itur_bPkRdHXVV)R481eL z_4!u21D z-#^cw42#e&e-$O(RlLe*=%of;4V4lEYf$areAn;;={RIvMELOv6mqV)9lx>`LJ8Z_(z5{S@-PlyV z9dg$46V?})P=n?oF}D$TiNIk3XVj%@Cozu&WZwG({pi_sskem!m)1Q2n*-VHk?vh{ zPB{BjE(6R}ed@h~Zqa@98X75T{KjGNDeAY4-V4GhxVPR(ETJ6)%vHnclf$(7)bslK zPoWpKu@9F?elpB+1+Ei#P~g}3>9CJ4V2ZaWAk8I(R{)MF92TFeHWp5S`L3ex zVE(Acm*7y}7S#YeU6cvfrx{D%jmBi6_GI(2gh7?-1#S_z0}%UQ ziYZ%4G4;?$V0(e%ilb?_skjn;w+q}Y@BkoEq?n;tC36${SB@=N4LGahNx;WS)&uS= zsRVq#WHaC&C1rrMOHao2uWVJi4d!{JI}%FO+R{Ccym!mhfH$>#5^zb&&44>wt_S?2 zWhJ1u)%}T(tyLM!{aS4U47J(;cz>&tNadZaR>Ae-R*wV5wSEe)PV0?;En4qMV0o{b z#PVK0$)TQTT>$uW>tevIty=;f_O!!-Q>`sky;IcSEnoD`Ra4roLV29gb{l-nYr7jX z#qFq>LG3y_Qq;X$n3rDJGPF;MYPj`Nz!6*3MPUtZZa0@&0u0;iYPT6S`vsoYaC1Vx z%7enq)qZphhw`^4=aTl=k!+{Yv+bAF7*zSZz?}jQ0_Lji9o_`}lO1a(qwek43~)fF zcFDPFa;F~24Pfq{%zXaf#e@y}_NyG%nPuT-fwv2sD{#5MX9eyDbf|l}5bo;2{PsB@ zIXu@lMV)@`Iqj8vT`P4z-ImbkR;qNVOn z`&a|yq&n2-?hOGacW3)DqdR5LZOz@?DMO$KZDs?uR8RCc;cTha2~4g{^RL;2o5g0I zz$1XA>S&Lvz;jJE=B}Z=n7e|#nBS&$W6qq{i#c;qH|EU#-Iz0P=*FD6x)*ccbG>rY z9cq6s=EHY-F&}>2i}~>9Ud-EL{TmaRyN(Y)4(vU|?@-_M9uC;AG@CBzf)P8={n7sc44htl{NfYK!y^|bx$^5g?2Wf24NdN;V^-<1U48> zoA$K{^TqsKHqEBkBn$jiY~saSEHpFflBTJ^RsxFvV^atDMAEBlM1w3yID%=tLEzyL z7Hqx|7&nsUx+9sgodsSyl9ZJKSBz{9*R3PBp`2b)*b3&uBME;R$ugcfioUvzx*i%E zGK!&e9vuYiG5Th}?H#5A>U!^u(S5VYVF=*ik%U`EUJv-w$mvy2B*W=$7vCNo?>6US4%cH0_K56+WVDFaS0WN9zJ7B+721@0LNpFF(=Vn6d=1kP;^Rw=Y zv}@aopF|xy*8gOr*4^boHX(U^s!y{?dxwLRHIro^%{H$~A z`0TxK9WsjH4x7xfJY_O{EfUIbyT;teeCSX=!gWyP&r?n~u|24aFk4`uz%Bx>5jaiY zy#m(=d{N*V0)G&gIF%e43+yDYzre8q?+~~|;97yZ1im5gYk{W))+{F5*5EAzGWkr)84BnNQ#!e23hO_6Xp*m zY+swu6qrzh<~Dx9WU=|Vf_1{8Tj!#d*1fE)x3aHw{MOP2=!aJl1}Zzib$Vq-nE$9` z?`z+*=`g=L?XCtan{Qsn{=gT2J$L>WaKzS*!&B6}{R4(arP|$)ArhWW-`z0G!J7V* zD_1=@V|OF;>t?(}h~C|xO0AP`W>9ZBHUAQG(o9Jaf$ahNRbB*GtZtk+r%AE8ZRXM@ zDQe}+`_M}{|4wIov6^&eD+?0dd7+i7p1yN2N;i9q{VJcolWqC|z+Cm-oy(1ItNQt! z91Z;{_StuhhOhc}H8hIVpLhA2vaLxPnWCB;STkx+<=e9u`d0!^2~3+!n-+j6>f+gp zA;V~aGiRsNO;PsDpj55EXK-52(Va97dUIqpG7HZ_Z-zMS2dW+ z8mZIVqGqgPe;&hHQH{-2o95mOo8LClrnD+^T#6c7b>28XYQnzFS<}AS9JL&{p)}3^ z3FeFE|J^*A7TZt*tZz=ZMc}S>H1B{pS6x28c5$v6J-=~rvATEuP?#@XKp8FWI6>g80x#`On+C%Piv_l?O>@4OzssiC6q{s$zlzNQ@w-gmDuMChI-~CVNIf~$ ze^DvNQ-ldM2-}Exn7~>BeSlF3@4)zL<6@5EZtBW0^7h3XS?^oSJhSlL`_E&Fe10?2 z!gouGy5sG#$;|&um)Uy;ga%c-1d>~ufz`vRvFdId z`Ko%Ju;ogO@6`Lx6MptQIs7Q_l)$(bIt+BFx-WDGhkxgD=L__E59rS={}H(UA~b)! zK(liP=@WKz2meNZ^i_yH9(6)(bQ$bYBVOzQsA+E7iIJL`{2=4CYg9B&?{Lgq=6yYvCh;8q=EX7QH8B6aVndA0S7+HcMx9BM37cIjV)>WCCLC68nE$TEb6CWtx>U0x z_Fq1ra}isI*j&{5LB@T-VZ!ACR}0*A*ciujq;3x$_Q3qcL!~&xl>Nx;fJ;qylxD5B zYahSRg2o>I2KE|i8}A#`w)q6}iG~9oS1zN&GaOuc7G!pomrmh3#RP`EZY+C9t)awHTo2llej&*p{N4DGY)R4oczOb<;Q*Uu# z5vZ~Dw>-7Nk*Kb>h%L7Jfxk2{joWWi>7#-wclHqFM$(zXMeCD?xN zthAku8tQJr_Ia12?FDwf?e=KeE0{%HWn-Ju4g*_bV>{B`0Jho24yL^e?0vzeLob&B zb2im-9!dKEm}O({rG1L_ZmNwP7mRe+l^gddW{M#S*W!hIU*xza2$6#6Me*l{< zTxP0f>6Za}Ru8alfWx96=8@+#)d6m4s(C)K>8cm}rK`sUTcoZ@ zPczch4>ooaFuxjEp#4>(XB!!6n_wR~7QtVp+AY{#$GY_Gz>WyE-@$aRrG61?re}D@ z7GQtb*vyRWz~TzYbEfApxMitC8`}xDEakT`=DKWE*T$IZvK1cuqd&=YwN*(3TWi$8 zd>7rOsdqEB81>Ww!S*X^u%1dQ(r%w2yn1S@VEYyISr2nyblb0}&wA<|8)G`uQxlqN zH^!)*S|Zpq^`~%|P)xUJxaUkLOEi`#*vq9FYmiBQ^;&4GD04d`JI}`2X6{0(*vG~e zxb^|NhDS@lvuoy8j`|qUXzb$5Ek=E{wUx$(2=3woHnza^0kHFJY+vTbzy{ga z>zOAoQ$0p7UH+P=j|F2I?K7H?J^uD5uCDcgVW}Urn`d3EZ-Je%v2C?3cUa13N6ML= zy|vzTG*vzud%M=hMpKn4m@ZX$s;KA?;7fW`UZiF>txuQK0S@Y`<_RR4W7{m!@W++8~(bQmD3Sf5N3u9f)A1p!`T< z9@Z1h)R%&3p3T&cw%a{f@51erjjhW1*l4BL8)I$SUcF~y ztWAYInC_2bd(>WiCvLi4YOhY(*rD2Vb9H3s`yIywOSZ9Z1*>Oce+!mpWASxXn(b9v z8_TV8nWKZcz{bvlTL*QKjrD+AM|Fi@Gu7bKN6e1ualvi}_N3WKE$PhAr>RToJYt@& znxC(+;dP!iyQnK{?56am&8}*?jrFMWf#U-8hK-F!2;I~dHpUvdn=0s{xm4EKYIaxS zZS0;paYheySy#I4b@Tw%Qw_1P$H1kRy4=P#33jE8y(HK*g59p(fxlkr+6%~KzvDBw z^;TnS?0dl`+1P1lqPM!m#@uyRnis0OY%CpEAGIigeQaE$mPfE%=EZ7t1lw=+RqGP_V={z3zAB5ao+tzhVziS_J#cyj<0dV5aj5WkoQb^Gb#D;5L^O?DOjv!LprK ztML)6k@FfgKY}%L4pZwQSR3cH>QDsh>by>U6TvQWUau0mheNo`Ib2;4!G<|+P-`RD zjm{D3uLw5LIa2lM5%zbhbCg;d!R~U7R$C+3JBd(W0WFqT(JTDtR|U~3ZdBYGN8O`!Rev|WMt}Up`}MipUGgM(GA?-zD}zzAspHBJ6mRORG89c~ zE~fjQt`o!P(;q_7RKw}DZzgf9k&L!+r66{!k&Fts#COgJR1(8ViX+t#=pKk2GmJp5 zgy>$X4gLw0Z^=p)@*~xe$b+i@vF%|bY6=`bKrJ!~0U8rt?{&0`Q6VNNre)pR41f;ZLy-r0( zXvs-v6;~l*+g}RW4r1G13d#quZ7cQD5oMHHej1B2}k!rISth_b)1e> zN;Gv^PDhhXotLMgg{Jo4X=t;l%XFOGVN*Bc8R)910z4DFHFZ~>h03=1()vSrHmYjs zsXPZon0h16MeR*}l;@!VrVc9^Xsjt&`57%UWu;`IKTRE1ve0F&badMNurePRZCUH- z=(>9gIU9W^M8CBk;f1IsR|@&P;$dYWiVzuHdk!m$*hKV?9+$g6!ixo{UbsI~zUqVf zJ7t-fDIS{o3+l&fDa2(xY}Hk0ppfC}k%w2IK_G9gp-*4N z5SxbstP1*9s7~~VQwxw1$HnL2Fme(iY$+8YS1z%X3X$cTl7(o(H`x(hBdyam-6K!> zsyQAjNt& z%u0%RKZhc?#Jm@yD9Biy#i$90&HF{vhARbK_E@i8L3crSsji}+F09TJ^n~gfTF)g~ zxQ=!U@#S&@?c);d-9Sg7Bx~;m3h63(#@f4$Dt8lNpL8io;1bi_M?)ZE)7?kwL2SB5 z=;JrjJwDU}0+_fj7f1*7ZNA_Y$LDMQfRCS!zTV!mx5d0gLXu-fUd$246_1N~<-oW8}x(sxC zn-pb*_kL5-8W)IK@RQ0PRBK!Wa;>6jHuxNl|9)(`Y|`25 ziS|8?;!3Ayk*}y?xKhb3*LPHLkPY@iI(rg^{pIVU{!gi1nwhaP)4RB?+4>x>JgOAY z9!mAH)$8JgQ0G!wOMSc&+FR@8O!XUNn`st_|A1@{%_8w`A->;jfD1%Pbkyst(g36W ztY_)yJXJ#+K0xS>m%H8w#}5>G!Zj;V=wGglT!luFr!Ose6C_yz^(gF+#7Y+8PmZ

B!yEj4B5*A4>0R(mQ)~6EIDF`rk{k7IILFj(y*qXo_9Z*2|A^5B_YSx7$|`O}vyi1f#M zO+}Ca*kR^AyK$$?}DPBt}+48=vJ zMw4OKZHy>+#*#vYV~eS2BpC;r${-_fJyXlbPq>MxHDn}iZ)zJEg?pGfKt|&vQzyw7 zJjT>zG8U(rx<$s}S*9M4@p!(eXJi6iW$FW&h&Py0=!ow;Q+7rQE->Y0OvYzSc^j$t zx~X6z4gYPbt}z9_HPy^W$LiQG%cY|+72BHXYD~jUrg|IGvAd}O#tiIZYN#<22bvmX z%)+&}#C56JI1+TtGRc^2?)d_}FX7p^i^z=n-ut!LIG!sVP4%8{%)yCVnJCM9F;xLq zHd^Ps#+ZlCm|ARP;L9-GHt*k!O#HyqA5{M1`23+g-n)$~d>2&U-P@RttBe=fF`8v# zKdw}C%KMP905>!Bm$4A{=DLNic<158c#@EzR=q^E9#n(tBv(4>S@o2$7?+s3U@XC} zxeD;8s@IKWn4Uh-zgsw+YB{z6?XP-@J~8CLm5#1fMfwWt#+6F<*P7J|Ttj43WvqU| zO-vQat8hnC$K};H-qd+{4IXaljl348n%aYZ#hIp7$m{SjQ?jxiZ!~pW`3>iqvQjqS zqo&R(zvIVT`4amc+lX!0!?~!Cuy3wS*p4d$<@q>TZNlqQSeAkQ^l`V^if@9h_*ksA z;g@MF%a_=9V=jKrm5uKC1X|@{dJ0JYve7H5JZ!_2fsk*Q)lTe?&aw<->)Y6BH*U_w zt|Z>YdvH6hbbkGF5AF(LN7Q?8jwp%Px5ZvOZW^m4Ut;U$e!Pt<16B5IZ?zxa}BJ44X zW#V}0H1;vI%IY)@GIdrtgCn@I(L|5mtj^*!Tw=+e!y83OGR^lKK8JI-QpghDT&r_< zA7qtR{+hX*(K}+`h{kf@pqtYG`oyLK>0Mg zf+IlZX?6uS20it=Zgmy61mSA;sXBois^#HpxH~AQ+8(ODpqOfBtghq1ptx!msYY?7 zq7l_zSlz%mpy^b%@N>{osyo?gu(j?VZ(qyjF;A{kc|;a3NPZdFNlp z`fq%aD}|8ikyIBUbFbdi`Z1d2K&Vgs&N%ThPckbt{X z2||3C(f3k*BUKvpjWmh%gdKkt1bnvU@A%?65unmvt4=XXRqq{OT z53HoEw2~{GXo0728EHLN3ULWMtCW%cfUH`euT5EL7i8ZD22dRm8SQ7Njh$3tDw_Uy z`)95Uv^KDfjf15B%-SnN+XCaQ93>kNYu!om2OXg8IZHtx)=w8H0d$;BS3&A8L~aJ2 z!xg0wpqGJpRHHyRh-H(w3P|~&OH{0Fwv;*r#oByT-=JnpAXcZpl*}dekpa>;A#x}vPYRII zKz{|Tpzo!f4LVJ;KxsbcGR*>|<)FJX3zB{Xy{1`^v=wyHHxCC(7eLp2_fXyC64!}p zNRLgekZVXEpyY($JX}+fv)CM^lZ@a!R5n8NvyH_el7p!|R9>c5$f1%yO!qkW91fF0 z%q-U`Op4>mM4y7E*wm8NL&>@|ezpmhwt_m=SV*Zgc z01Xa#LsbbhCFG+`eaRcNJVdgsF9m?|LJZqTDGXE?Ql6?l=zNHqZ3C$(=yixERU43f zsGn^^DGuZt8c5X>R5vutwvp5y6c-vnH4HR7w2^I;GzK&?v>8<@Xmw~C+s4vN(9Y0K zR9T=Cq1|koNJ~L?LVHuK1-%O$VB1vM1X99=Q00Q$!$#UhOZ!2-VUwwjfNF%zv27-u z0@Vx4qAC&MYfp2jb~anq;!Z_#DVi&t6oyBZX)a9y-J)tC?cgfpM^7=*ArKo^5+j`g zv9T&K(p9d*gpE~+k*-6=#;UZG{sytpDJ`Y9Ttrt1_*m7DD86 zcn7LDQ2E+Sc8f*H?`n6angj}~olvHY^eY$Jrq{@ArQ<>*y0)#_PC5r_UpuKxJLw-# z_uBJ~_7Ykm>g-=TxlDVh7&NW+*fJfY9!ps!#%pwtCV|)pjSkXE5F4SfDwRq~@Tvb&i+mE42eD z-=C-I4EmMcWA7{V6QXOu4Vr!AN8e4um>M0S?M zBucM9Eq1<+Ns{6>vN>W${x)R?NlBm(U#GG|q%mCLn`EdoS%`cekz97DGy~KsVhmL# zsAoi;G)!6pN{*m+7^Q8XsSzngboFJXw`nBZbFW;%QFCtcy{i;v2XJ2JgckMS>#s5>ax?8@N*W^N=ptZDVzo8f+fYrAi<+59yM> z5N7i*RcgS%uq^%cP0)Aso|m0#Dze_YvOk;ZS`XW0eIsHw-_!tF za=|xxUUtzJ*^a4aXSY;J67xs5oGMh~VcuD)gadE6;dvU^?IfB7l`%x z7wIa9^?H@`0>pZ~R-(7^=wB&jy^#-Yfspn-kO5=nK*6S@_=0VM= z=lhKg*XwV!Ra(YNvaKV;?hk1z)WS+`ldgbR$?aciv3*)EPs%a%{{N)cUm<_Z9O~yu zJIs<^G}|FPG~0``%az`V77SKro}}gQxiwgwd6GSd)tM(%0kJxFN&z5N=T0eti_Jrf z-7cvGWNho$Ew$$=G}zX$Tj~K~+rw_DH;8QyyQKjjwgv2#l0a+=*ewkKu`OV?G#tdX zfZfs-=6Rjf?dx&Tg-iB{IVLvQJvbRm$gmpR^Xl=6#>E4aDYspL77k z=6%0(0>tKhzjTEwg)FWAqurm<11{EHAF6j;g@n!D0jc^9wsZ>#o1+6#8?H>c^(WdL zke-PQv90)^^ny>9ffnst(CUyR=d$Ti$o~3c?eZl%F4jT{l^d66p+M@$Rf^g6P$2aJ zvF+ioG#JFTheBx_h;0u?r0F2GJsg#?L2P^YOIizJ+e4AG9mKYWlhRRB#O{>z1jM$7 zGZM;Uy)MNAXf5ZYszQ8wxFAJy74o^gAdTiqA(!jVvAZaxbFul$qM9p8(&M^3sYF@| zVxN17v;oAH=OyVl7pvtLyGv3D7pr9*)h#H=YPl>u1+iK#OCLe3mMc=Fonl#&$Q-*X zQZ+7C%WkTgT!n$jz`AlCYAiTxe``!v~}=(c3bm4Qy}s@VFDw2~{GT#U?< z?n>)H|3t2k?@E7w92?}}d(tjY^#*&W4uKletW-J%ilbSnRLmug67EaYcC*$~(7*;q z%RQ3fK~o!?D)+Y(w?|}48(b>)L@EGnqv;v_^&U$%SEha7cM~ z8J`fE(r{$?%5qgMaTmXe+?I=d?y=ZY&gFBPfokmD-r7@s#%swy52(E4A4EyQj@>Ns z01!KNv&h5ETGo{Jme+HopxuoMa8`eeOF$@U zh5Vg-;H)TV6?Fjn%Pr3d*+=D3^#E0jxq2v*4wtVX-vup;ip8Pw${Qlv992LSbW3PA&BEm8pd(Qy%7@7z zw?%d&>RkD7c@*d@ZQ*;_=8nj&)9E7QexL_j2S6{mT<(gJxG^i446@-W0r_%8+!H0E zxR!Gj@~2B8A^U>Z zRgSuHD-gTNQCIE?Vpm-1$&*3sic39t9*A94sW0b(*j1JKash~4<%pCYf!I}!Nck;@ zU9)N+yWbamVb`o0$o`;$#+PtIxgF?ayymfIc+YLsdXRAT|QJm8?Hv)1@GzX_$R0*^x_(z-%K|1F;d9ZRGkOHd3^W+yTVK zW44u3Ox>2+$qP&!l-tXjOclx<W!l@-tKCgv>FNp0lby(>k+dTf#`bW8^ z>}5(;ddYQ6St-5cwx*6N@p3;98*AG~UIt?0eiP(vT;dy{ue{sT3c0VGZz{^ZpM1hp z3;O}`MIpkr{zUl(R|;v}^tO~JKY)zw3zFpLkaee7lKf6&eCrq_<0ov6SnDg~L9&&p zDEq;(gQ*twLuGd^cE7lz{cyRKnZ@FfawJzay%*Wjexy9lR6qMs@=dNx^rq=x`_b}S zQ={$2%FCXz)-#Y1J>7nye8AK^`$@9hGnQqd3eofIC(GZNnr@#ahnQMyKSl1!m4UvG zUS&U39tT<1=ymqfU?oVA}PJDR#^pCMaJUA6yNu4n3&eWo1Cm4U`bm)d8^vrIj<&z65R zOTMsQAn!2skNrZqz|;r(MRJKL$ziel$&}%+M6UeeOV8{amdX*P9@{UIo0xKTST46Q zRmowc9Q}$-mx1O*dpi6gujk4{>!bY~R>>i+S(b_RMh7{pmgjP1qoU|a4r}B^ra~Ro z%BxM)byz2FHPzVRH~FTiHVzx*s5h+6Y;-lci^CSVm8l*MTje-Y{T#N*y-W>p$dLzv zo<@&!*dY%$vq=tl@=Q}R9CpcTOl3Ljk&8{OaM&-0|07!0n{9A7C`Xyv>X0w52RSv{ zJe7xl*j<`u@+c6yOY=<50_BBNb$l*w0kL~F&*ftvcF*Ryd=A9!ue^}2 zfY|+&7xG;$@oB!4qd%~IvauEZj#M9+($S&dP{&tttf{(=Z{&EceA&KvUB`drY{)Fl z*Q@X3m8PN`-^*J}wQ~F@A2HR*@jtnkD_;(3-pTQ^`~kA4=07@O#p*xNdQ|hijF8{P3*>}ah#0gY(B*wIFD z{lr?xMn6~I=xD370{K|>JC;$pnmXfXr}Qy($FZC;$kZE0du1f(lcVP3piDAl@8qa# zrB8RW{h5!&!^uf0=E|mHLl--`C~r&!Ik_rF>3ben$)f5(P8E~~rXrmxDjyVwOa*MY|-{ZUQw~kd5 z3$LZr*wMVZlc&PoX3efAAExqBvbfU8+vWqDEJ_hbYcasdTT$p+AXv$kEq-#UsziW# zQ~4+~pnnCn<64Y&@>PcNjD4fWVqayPsdt*MQX+&_w1~xiii6JTEWn3aOm*^8LO^#~ z%yFuwB!kMw%y;@u$>9>?v;36;u2fVtW~r0E;!RjBg@ldKt*+Dnu`#;UmHI+-$=9Qb z0kKiJ)s+(Z9wfGwk7({r^@b}Ig~Y^LRaYw0H_ot8EsbJUI|V5HK^agjuV(55p zS30^AL&r)h+yfR;qzK zThcQdC4?&#`L|pnH&J$i>QFURUek9*v1Qi07nF>S|N z#VS^gVvhQ^jm2?FdC;`B@m6sPdlMa-+YPkj4@wZ|c-s?Bos~!-tsuar&~ue1VrcVKD@>c!PT z2X8%m$;6Vock$#L75$6aerkHlq~94)49LG z-pa>Xx9?P&Dj5{lDUX&M3EIUq21H_6Hl9n|?d-3lgV^29fyxRl@g0|_WO9k4nM7qB zR{>%BtVCstD9P{DB`SHKRrG3TqH=&sjLu9_@3YA;O+j_(}11VRaS~_N>B4Wg?dt z|2bO8;>t$Hyi=S(-8Z$u}UW{F;aA# zG77{-ijG&NaET>2UdiSXOK`lhlB?7>*Lkz^c%=k%pK5|)N8k6wzB~N8>~@~0M1vYo zO;W~liO(oSnQ!W#bBeOr)KTZjN}j2+N~%)AC6-{C@)pGQ$x{?N`kpen|CHEXajJ5K zD;53F<)rg;rJNhfQqfSV8Hxv&7)3f$@!=Am^h~81S2{gPIPW}D2?x14-Jlu^n$e}6 zK1DNVU<;YD3^mnBMnAw%wJ z#KhmMMi$a(LIOE-RE0A$+B4ZK_wG&fQ}1O6B91tXpH3m5P65 z*7_~{WB0Z$zbFrcj9cCF@M`66$Oit{*k!e%S79Z^+PX$z@0DY{?oi=Jmo>^Ht^)kz z#})D#Whv-0)mmjI*DYMW$6%LVl>)8|)Vaq9D&i$tFW+N=%Q|HUD7D8_m-Wg>(D5F5 z(t2eKs9aB`@t~xhvuR28W;r$w`8_kKrh>}$s_nd9nE~qAYZ1+6gZA`V>GGSBW$HJV z4aySGhhAH#mUD^G(Z4Ih`8q*Il<(gVyHVN1Gq%p3blIfjLdl8o7hN_h1t7L2Z&4Hr z>sdOP9e>^B55$nyTq~Kyj(crW0%VAthOe?*|K%qae189abv( zu##*W%W*oQSU{3xqtj6(KnO>)aw>aFsRf!&k2Q`f4L}#^vBn9dIjCU;XZ@to9)#M& z;!{c&P%vPvb+$0E?!dRmO0Mb^e?(nTvhy&iXlJ4VSp5 zQLJ?JW%H1N*!_-T1^J12XLmS?l`Igu!%?hMt|l^epQKnB!j*!y#%p@9atX96K9Bwu z&Q&3j&-ECx;`q9*=asncM0+>mmpWZg=7AnkT~vxeA&$;^i88OcDEU18i1j69C6^dk zby+DE;v=grDJb#|4zH2{0U-D`(IVwK}mM~`l@0RD3%htetlJ`!X>U>UsL=bW7n^* zEA_bO7;^VEt~ZpHT&YxXuD6sKP?EhR;Eu8c#I8}_Q8t3uHR?OcArQM3dsis|u`Aa1 zlvk#1OQnhtBzorPeo(%zc$g}bA1I-wj>`{~7N$bUY&$>I|Go_ESpu5^CX^-fs>%5gyYJEZ{hdqrm|T!Zz6?=hX< zD^Sg%uxGti9Lh0 zI**I(BNJV%RZA$FE*pjRndWMv#)9hh`PsFM8gFW;tDSn3E1h4hw^vVdiL3SY>Ltk7 z)p`f@7G&&dy@UElWVE03&1LFKQ!8Xg^@FJ>dnZ*5`!WwL>|NBdT;ghd1=Uw%-w9khuQ$tq|X+r=AhPna$PH{?La;Ilv66JT|mQWRz>X%nnJTG zY9eSspFdqa)nw4RK8+Mlbv!7KW?t$PP(IDP)H$G2G_$DLper=9s4GDC`{ZG7bsgwq zpFLDtLFE$ia8-3DsA|FWJA;1c z`cMH`(A--k>tFyS+bw!&B!Rl463^cs2YlRwWacw?FsQrNH6+%>pIzm4T z__;!;YT=Kvr=#8jmRG2yP67=bu&qLEbvYWS0^GDn_ebpqB|DRQ^JAj*`kWQ>TgPs7_R9u9mMamc;PB-&c%LeYvvHi2+d+ zTdEbgGSJckTROK=y+G>^JhpGG&Joj*#{;q}wo$XW(#WR)E2&n9j4#2q>S-?a2A1C| zwpHcGFH84i#dfNND+M(jm{g{NI!k1zJ5@(Dx-p-3G+^Mxik;NGpy@RGK}~ETvaErn z6+5fZO@)>Zd|t7e+Jk2q=*fYZU3#cpc*gcU31xb!Be>GZ^?{OGFLgZV@xVN(mr55j z{f$+&7cN6Hp+fp&9u98traTkf+!9O$Bvx}9z(tQZ)$qh%w}I*mUS}q0msrOwQO$rB z4kqT|M0FwPL1IgqtpsUF?WukPS(5T_lKKazdD0%L-9mhM4pQ???ZJc8%UmgFNK$9F z!Rm9+=%jvBB$_RkLd<$LL@nSdz*CY&x(!uPbFs{pCQWf0rgr5jz&n#>xeZr4v=G^~ zr1@^i>Q2xbsuAi5iF=}nD6f|bgZnv>& zG-%eKLbq{h<<_Ew6@yN=jaQ36xq~jcO;oqG5w)Bi^wMpLdK2_;&Gds=lDXgM-~>sdnvImWd_| z_H&r6&f>~I8H0l>%~5kYitLxcO)6!o{+&cEIfGkP%2H2*4iD~DDO-I2x-mGR(gL+d zEGwCU^dZA4Em9Z$AQU=eYNeH`Z)c&_LozGZN@*N4Aki0uU*%v_4((Jfd)~nR~6oR21?Gi>;9X1lh-M(xNJ}py0G@xdL4^5 zsDrr)e_dZ*GhHJ3}YzC+!>C3?0)J#S_$i8@*9d8$7b zYyD}ZooXj8woSjOv`Zb%l}_Bl{;RZC9m|zMe8VL7y=od{nL|(Eed;X8ejR#N*{9Bj zY!}V;tIHrOpxJ))SIABc&BK4Hn?YBG?xD)#N+q3!ox=yzKViD0VYyZZ)T5##?OCqX zL3LwyHV>)j<**9whtzRDGNm9iJQnAxF+GIbhHGlRx{`~|1AUUUKwS@FPqG%Me{iLe zdu~D`clRHs5+{bX#Ev! z{V(-bu5`l2HXTzpgV@-nV`?6#pmDD2arIBo$;RcB@k)dV|W1*h3WnszkH1Y8a>*&CaUzLA7XhPHhT`qS-mM4XEviQ}p*$ z<3PPeoK=d|o**{n>%7_@#KwG`SBHVv^WhiNF(CGQ_ysi;^g~Ff`$csoXmCg*RTgMU zNQ`@lx)iiLB$jF|Xiv@F?w8a}pz}2cQRRZXLdLjXR`-MIg-oG30U+?^5qsRPt5Ur9jEYAbrn2S|@1nV( z+KY_udv2%|P32nMP(!)ckyUk%n`)%U(9i)P9=Ft;pkpJy_qeTI1-%;C(BqC8(nqw% zo@BkJMu6B+*FCi{h&{<#sYYdP<*&}r^h&?&`x4H_%o}B$#{T;N{b!?f( z>UL0*J~1AT)x97e`UKDuwE$F;-c@*_o&d3Ppr`725IYBYs$K`NbD(ExDTti|JyV~7 z*g4R1^2YRle1hG8XInWE0zVL=`aqJxEg<1~8&VgR4t{`>}^iuT%-S4x{>Xljz z^s!G%<&|0!R4!qk)ob;8P}PK%%4;>TJHH376J zA-m$g>LAeSgq2hyLGS$IJl?7kL8N+bs%fB03AXAxbsp$tLQLSplggB4)>Mx-D zgx*veK52-IiPQP=;}V$iTrr>U-i*!j^X z^&W_wAAM4vg6dZs?D1Ls2h^hC7^=^pANr0hgEYOb_>29jXl;RvLYI5?jxxPFA$mBDlm}!&V#2#h#o^ zDr2iH1YL2kRm*4>ga{hLbe)TR$362XqrK%yW%oin%4%-?SqmwsJS}Ob?HwTGK4yh% zr)?iB6f%asyIng9YQ)uJh{#%Vg%1Rc&&nK^2qLTu@JI%@quR~(W(owVa(I^;EB4Ao^*sh-Z-JyUZ% zUA5Py7I{|GEThF7`A_)O(_L!=3Z?SU=7FLnZ1SwEDPu&*Rugu3dTKEfg?^lH$kSVk z16eAc^z_ksfCf$|@$}P%OcEt0O}Op(oi>RplfL2bv1fIS{`@)rq?b?l;2Ef$=MqPp z!P)~MdZ*$X{U!3}q9ke3C)X-idj}bNe!7N+C-Yhedw#lxX2r#7vGuB{g$WV%27yqm z9*Dg`AXIA#Vs9u3(^_+hHwNCRyNw$`c)Ovv4Pp)VgtrdaFrFvP$YEc=YWcAeQ zmI>N{OrglsI+n?rO_orn)F{goEgCd1wWVdM7BgRDqf$FtW@>A)g=VG3Tjppv3xyV^ z4zgru#fyZtrH-;>X}-&ZN>Zm-7HV0`g+8V(v@FrutPs+ht+p)JhO81IX`3ypv@DQY z+6H;8_9w411Km7)y!Sfo5Xh$RR`2!NVNiJCtKPq9&3K&|D84YX&ju|9G`g@wpWn4y zQ6~vco2PBm&Tz5yY@+o>?XnPJWBoR1AE0%%zHHH)SBsuKNjYNKqJ@B3q@A>E)s}*~ zr(L(~(3G{JWK!Bw%WiEAXmZ*+%RVjbSCK7F)4UIAHP#8`r1fqW_;=xW|Y+WOywO4I6ipV2OY*ynyuyJ4!4_j&EUsg~XswdbI>X|djy zw5}UOEw)p7dtcLrY!vdFGQ#_|)?t%S^pyv-}~n zu|baaD{U;uEp3nYYb^`3bYw-#KiWpnlqnT0Z?yuhLc+%3zSquz*f`t|+D#A}hx<`` z0%GHEKWZO9Y`p1z8rjBLFC=V4>wlUfh>d9dPxAt?Z>~>T5Qu$qebVZI*a+IsS_=>x zLHk+z0mMeoB0T}bM$ppnFd#Nc7V9Y>HcA%jb3tsxuB0yov8Re8eZv>gk-oB?2Vx_A zW&JRSjUHC?vmiElSkZ5S*yv$Ze*$8ohgJOph>gnCbh2G62{tNM)15$Uw6d;yf!Jte zT@MDaakxaU2V&!Ji5|n1ivCL}@;3C|Tx{z&OEulpC2uQz3s=6x#>`sl#UM5g*H(WA zVxwfs>h3wLXRMZ7t8zO1m;M#n7EC#Z%j-To%SH#%fAuM^2ZAoAZ}G9$BS0_GcltQ! zF`!CQ5BfOj<3P=)9`kY1UxRv1J?G=Bd+cECWuOUDull&?{-8`MS3QO+8||2S&!>W( z11g;Q+{aDd3ned3edkk2F9N-wa@XB*MV-#mBwr7`D=1_dp}J|RoNr}4E02}TMj_KG z_*T(Ngvh}}FJDi+$4;J+xM@CA$z0+X%u`<_GCEx&nr-SALL@!$cFm{>UTv(pMy^Gt*KYt%ce^~q0@Djn))QJLey~jbl(si z?PFOXYBT+3-%x!asQdKgzO{6R{URGYeVuQ(z7aHb`c~iC`lLTaHf#Dm-|uz$U>dJw z#q?vob@lb24bw0B*4HBsifsS%JH8F{1E4e0ANw}cyB-qR_37_?qjV)-=^_n(Gft`S`Wag9_Mm8K}XGP`{RXSFRM)d`3OLR(clb#~Gdd+Uo}n zvyv%j^o(JCar)jup*d7t^#MnOcFdUS*F#^yRfwKco=x?FFV75A^{C#jhn@_ryBuHD zzo)*9mlW$uFTNd_cNIXinZ(6@@uoZzSNrub6_B{qub=)WuZ4YnyZrj==b-h&GY#dg*~D&@G4Gu2OeQ!cjdo(>qL$8!}T)|b)xS~I&EFjhYRS-`A20poP~ zqDIFJF2^&53}3?C+iL;S(bsy&bA9o)sJ#z(`&I+0;lN4DW37)ElbxuxLD6>2Bz!L zLi8NyqSZ9r|Fo#3%j~*=)Ab0h6qGbODsYBA8#H!yyTIA{UMM+b_K$&c^#`Ekvy%cd z^ia_rI(_0%Vx~@CY{7eW<3y86Svo!=ba3|M!1=lxS32r!nH9J|Zv#3pdtu-reU1=c zf=l(iT-oUS?6rYQ^~kfVmTXiydwbvtotzhXJ$rxPFM1G2nsYpGl^)JZW}woOj}lkw z-aN}dRZbrdT%%V9bvf-fXstevD;?R+DG6MsKLnZ^_O|OE zx#;IGr%zCh-l2p|SBO~axq1#4eXIPO!9jU?_$86CzU50~k@K9$phJ2IWbfuI2+G%kudN7wARHyY#poqBzL1*<65Lm@{NKEP5u5jOLD$T=^-R1Pbi-6Y z;-jED`cGz^B>1krQV7M)bqv0zmvFH$SdPJ^`ly>Cwep&eShTKfx*x8VyRH+m0KS;7D6Tkf&xvQeEXD}vwaZ%wTa{-{Tne#vrzKkJi0*XC9U zl*wFEGlCVe-c(kwMqYsap1UI0AmR5#Eq~8lA8bvgnaT++LkhSu(8sw4gUgec2cl%e zyrN)7f*vwuqF;MH_i-YGD;q`43vzHK&A7zp=R$gNrIWt%&S4kQA2gC{s1V&RZK>o! zQnAodK0H+ctQ&tg<16&|w|ZejLBhYyJn z;!kw=ktE32GaP;-4KnuBMm4e)#Gcytj$DM2?5U0Fq!cptoJJsd2x8A^1QMGkti23$ zciun2fusZIHT^V$NIF-k!IpI}S;>`+^o*YN!Q?r}CF4VI4T7GsTC!2Y45da*^E2v{ zaSDf!dOTyFO8FWgB+is;jZiX-i;XCc3=Si|fd1`*ox{mrLPqzl4lcFHYslD=uT4Up zvG(X2O}j=1*CF$SjMH6%Tq4LGP?4u&a0Gb(k}Q$Ib&2zHFNfB1sl#U7NZ!8W8^%d^)~PG$JiQY@KLCh6vGhBDh8);_y6H++ zqVG;=P0$;$%vNW{QrU8`rxS)av?flbcGPG?dVumW_tj`eazF<%kJRW$ZvMk+$wsF# z&(-KeUYNQ@rTqIPdqDLT^l#=rHDZa|Tao>niE74@0uau!t{F#ensT6;^iGtt$|Al$ zkafKE3^cuX_OQ;xo|nu(%ZpnM??TRl9#VBBr{A;b#OTv*qy)sytGbb!LUegntJ#hG z!^Q4z2G{(Nd@{3G+=Gx0qV?dch?+gf)Q>`~vL4&VlPptBYW5+|xzfqmtV_5bc?-Ic zbzAC3@PE87v_1M{6mc+hTk21Ih0tSq!ZU#M0kN%PAQ{J%f?m;D29j-D;@(RlIS3^i zTiVr3B&R{2vf^qEA|+hn8(|Q6&XtYq=69o+>nAY}TPOFgIha%dRhU1r<`5EWYJAP1 zqyflx{^DfCb=6A>7>{ECL@wH(su)R%b@D9?()auY&GLWm3 zK5y~BDUZwnO>gzoX(!nZV$WObCTE1`QyXub_K?>g$@0``FLAYHEzm0&^eACJsRd%| z{GX&fh#e*TNrr;hQNjU|Aw<}dkcY@75c@_rM2es!`>r}fq%xufc9f7$+__2(Hh=jf zlq(x8&)y!IPdb9uXYULxATvRKWbX?-Ojd$+XCDeJB(FiovyX%xA(pb@b2vry7wG^x zo_#g+I7u_JtDz@I4(MF=i_lZ#G#6XCvG@#M@?Xw0zNnBMZKwI3gN&VN{2TgJ0f{*5 zJSl-XZ)O`|7f65o3+1 zrD0YEa$(ca>pcsXhS?f1T-oRh?U|kN9CUx-{;+b!JXg_zwCJ6WgRvguO66!A00k_X zR@2ECRY8=DT67}J*?0izy68ffi{Zjwa-WU5E@~0xY6O7>ExHy~!FUZCzvy0=n=!;q z)G}+)(=ZRC2()6+KVemjQ2uiKZ1l&X&tVp0y}Ky+$D%T|yp5y$Rr&M`Y>`JTAESO{ zk+EyIenzw@zgpFdc+kEcHEa1BO{$2Jw-z<26ksHS-Y=?GE6^wcSuJi+E7*8%Dz;XL zVeu5xSuO5ctCrChLiyzi% zWeniUB?H-9yfLz^F$@%X@x{n?#$!GYbj19{!K2$7-FbT%X!FHKi5-oK(B83&Er)k9 zcJey2k+Ot@#~P?`+sr6}|SP>SDx!0+%?4cQvkq>d~yb@dng$Nu}@~jn7;e zsOJ*j@ScXdkEmtXlG@?%Mh8>%!xN07Tp4Kek|yE(jYwZna?X;+_K8L&Xfaiiu@ba_ zwm!(X2ii^R9AdmS)joWf5$`8zIlZKF_y}Vf=+2VtT0a?EK~I)!w;yS^RTCxuThcRp zlu-<_TiQQ-jM4Qwk$t!HvHf_1E_QQd_yiE!n@$LyWXdx!J$$k$HZnZj*bB7`TG}pR zs)77Pol~f$8}mlJ{~>QMq!lZ?)UfCL<0?vi3F`D?zNi&Bl5VYj3mRH}GqFn~h*0-ri;-9K_n& zY}A30ti8=fLoU(YW+M$svi7zZMIhGR7NZ!%+S_8ZPyE{67Nd(0Z*Pmy3&h&nV#Gs9 z*4`E)kxR6<#pto0)gr#bw;KI~Q1{(;%561v6@JbBFb)c#b-OFH{loZh>}$5okdJer zvozag*zN#UM5h z+l?43O0s#_Zv4O{=3%=rii_Tb+82v+=pP6DM6}L6%^k)X5c@QD z7#l&X^&Q4*L$uD;)*Z%2A>R59L$MOIu-11Nql66B`VM0fmuP*5v4SfFIq#3fxyBw) z5SLvQQ44D?*SIQV+}vL)F4wpV8QVH?>Aw7Hd%1?cr|21LFW0EeCECk1qPawSc}BTV zR+440IL~kuLema7xaAq!>VM648M}qhwF4hJ?=nK#e$93p5khF~pHl4p_dkqI6)U+VpYOfJ6@@uxw2ople;Tt{n84pH(&GsA5g;0aTgM02b zj{f{L`_nirgk~J>*85MR_ujABL1Um0>V9;w-$CR0H`yVh6tXEaJ7kRA_jS5_BUK1( zq*=c4=9{d*_z$uYniUws_J5u3urWpmmHBJ2-(e#Qi~)R=PM>vVq^vxU$Nn*C*14t~v!8U8}3>aoRs$BgCQWXFwPAsa}u<3{kI zuhSJ7wS~|cniUy0zR6A)_aVDRvlGTIXTDB%()djXojSQA>7=psEX!`;GN=AcI%T{C zvF+%TfzFAHt=FfFsA4Xo&8c%#F+zMhI%RYKvF+%Tu@_3R_4<@iz$Mn}Q^p-G`fKZ_ zV)1F?G3cVu)vx_LZM+sjFVD;yeAzQx93PzSE^ z*T2?TY*Y|J)6NzRE;bUr$<7;tAlpQ<^TzIPvJ1u`$j+X%9&*7bbK`5BC5Dp_dP}ns z!{yf3?6Of=2$eaf+FUmF@p}bgE52eJ6+)xWrTJblEc_0@m&{)XEjxD#Uok%aUq<*H zfOM44r;8WjOX7-=2-y=pT|<5y>`R@^h0vH{)}Fn@jy1ky6@`$)d6p$8-)2LEP~`cQ zBd!=r`Lzo%-8Ey45K5xiHRH7Qb-Eiyi4fXEvl~WJejdQ4iwwSHbQ7ZI=xM%pj6R^> zE{tk+#~8vTu3+6Uib3p-!yUto9~q0g5qFJVT%s>`jVVItf9$;pSXR~gKD^f6`yFvW z!I=`197;*jObg*faK-^e5k&>T0mXqF2(yBufutO=L9>L^%CekDQqcp6rAeAo4p~9k z;K@2!G$|w#`+oMlp7pG?*Is+=wFmb8*9Q*)s4^)D}bihz@8P12N{nte#ltOc*X~nk8k92NHv6S z9VpchS9>b`8;<51;WGPM&)2r0Z>q0NWl5p14*L@tBH(vhUQrWOhetV=vV+-EDPe3! z`=D=G|6jHH^7;;B^zA3AZ>W}0KB}F4+eaxwI4`x2cPp!0|L?ZgwR+Jg)K={;rbywM zt3Hzf+Z0(qOY5Xaakal%uWRKZ9Ve65$a}3^5B`V#i`f4^&82GPi@w|F>NQohUCw2Z zErn7j!ryDFYx&nkQ!)SZ7Na`++19yMiP!R{amElY`b2y6D*IY7Nx!oGsy(mOl2nH) z+f9w_s&0)}Q?z622sd$LLPKolT;Y)Yash)u#d7;oJaY1k;SQmhRFVpY$;S;7484W|Fi|vmQpSM!0#=uTGbFrQKt zskQsKZ=4U=M=4}`=a#8*J-w~{KUY*ue6btK z^^QnnOA*O=9e72RckZpx3b-7QfZ-oE0{Ti3M1!#yJY`?|m+w+zF_fgfk z*543nmQ|}rQOPBq;<6f-(3Vx`yS_9JG{ooZqw+3e&#Unpj`l5ks{P$Jjt7QxoSLA=&N7<3ay=*`KWUZg_k&&uOfB_d+J5*V< zy0YEO1lzufs;9!Soa(Dg@ljNJUhSjK#Z(I`AJw*MO?_MP zzrCiabXB5S3txM-&$_4Cus&DwVhO)2jhf1dwz*Pt?<*#h-83g&5>~D#s zoJ&Q%TBp4pS7v}$&g7L&?d*+1d$dig;b^|8q*%Pa+jI>n)q8HGB2Zq7^Ll-#WelOV zql%{1?$0AjLHmO8H^oLS@oIlkR4G#IDl*_-2}Oz{jOy%CwY+cptJ)r8AB8l>FmT>M zc|T-bZR^k3R`?y;YU?Ol>6f8nkA8vX{|#TD2r8A(d+eySO%cRCs)kqhhrgq!k@i}i zJvhF)vhgU6t={WPa?KNfhEPXp-W_~z1DS0I!{zHLqpV_En;Ngk>qOSC7;MtA4AkCzH?D;%#FYMUm5?8 zxy&m{r`noVMDE6Gp-Lp%n@S^_6ojhIzP2eUelOiypCi93h9KJ%->~w(73Z(}=il9e z|M^^9wWk_IRa^a)?H#2vejl-~R@Bi!3K~hgBbb3}JAY=Y-bKB|o2xEg1*-q2Xb5#K zT$_5O;@e8(b7g96F7H)wUnR7Z`h&ubd=2XFkFn}}P}STQ)oesvfl*ht6t>{H)!LZZ z^7@i4g>Pyr_PmL8<hf7zf*E)|-Yp2TfZFyCS@AmcGY705CLRG7)>!6O^ zgjd!|9g!*8mDh!Q*T^@QitLR>GaprFwe1wD_E$YkDPDgn40%8H8MyYAkrE45cXy$X%4sy^zBUd6dqd#dg7cUs`;_N4k;+3T*Zr}9y{!mh|= z2;Y`bt)Z@Ft8v2@RjOJeH6|$h>sc20UpeFYzd5G==SKup&ugLD3cl^;+cMYERnJ%J zOZaDdqHkYS+fX&1R$J{C)IsW6sa#5@ z5yKSrHMOZ~6rxm9-xK%sRP~(A*Fzd`u0SA;0&L&FwmQPr+NNj^-4f&}_5Gl$XRWt# zY7a)0s^&22dZV&^F&dtR7|j03Y-h2pd>&+b$?s?gweKH%j>ggzYiLY+8EA<&fHtuQ zXctF)BKVf|*S4xHwSXZ`15NP-&=NlYZNj#e)>`=xqK9yAX~H_8S5ZgL>!FzZ%z!02 z!M4Gl>K3uq5Dqcy_gw0jYY2*9B9%%}<9|pw*$RC}IWsy=UV-oNhE!Q2je*;BR8)&9!oy}$IiI@f#X!0+++WGD+K%asCzo!yg3e96sg&0gwYk)}o>K6MIBXFcKY1eA2Xg~%XH`~h-YY#x zH+*7VU6!xEUEk}WcJ^+y;V8uvW4J7}rfP(wa@D$cJ=OMAslIKZT38(iX$&#=ZlHI) zl&;o7-4*g}0d>5XjMBZi$j9KIW=8z9s&(G8>UCotJWXOUdxrYE%bP;#_vTflsO?+Go=Q>AnEBpkQFmv2j}EGL`JVf$)>B7(vb}Ak`oI>B@0+?Gig!Dz zRLW)W`JlH)DgTr3G+Fnyq4NBUb=C5L;q)t8Eqk z64!@(y!BMN?WO;7YpCsVt+rC@SO;;uJ%>Ck;oGX$Dy0>2c}HEfWmRwQ#-oj|O*OxA z?E2qQ+HxJbF{(c1+gDXheEX6b303=FjlT0bn)66ei-zdOrCgm$DJrk>SBknvbFF*R zv<0OYgP7`ig(=iCqG?wtw%@NOm|_Z2Eisd8qUN{TIS!%se7shX{2Y|`E`#z>cO0&^ z%YTpK&841wQl4ac&#!A0sqbH?n1ol>;Q?M3@~0=PsOFEod5-sS)l0ofZ3~bh1??B=PKWp2 zfjUc7_YsbAD)m4^sK~wwMK-mr&bg|%+;2G7FN{i2J=XRzl|`wB@Mrrv#GdRElcVZ0M>)OXRVS%BxC$loj8yl%gUK;;IPy)W*NIa&<1> z9_8EH=`8Zf9z~^i?@j!*y^QOlYV~*8!M6^oU#XhZ+JAOdyNXMxy^1BpOI*X9tRH3j zl(y-tO4+AgCYw^FK1;1;Pn!9d;%nCFs3e8S*S!f1L9-*JDBC+zGS^Ww|0_J{7<#2b zk;lA9=fAbOZ!}-~e-@ecK10wNdE>O?x~ch<+UslA7yCG0nN(+^>fBJFI_FZ@6?4}s zukWcizUX_U>#ud+Xuh86E`vgK_fVaODx|IK9f5tfc-K|b{whwb?p6MJEv_Atw)GXM z+yZJH#~-ECx;&r1h1-_U5D{0{)CYXmt{l{;|8WU7rRsT^K$<-Q}VkqKR zB8j8b#y42Uy;mQ7nvZ=q+w@->==ne?)OC9`&OgYh3Tyj-A;{kpi#e5aU&Rv3SXWo% zmb1MUXb2U@*H+4F?6ZY^=&40hY=@0!m|0go^jraYFV}D{UkOp`s7gPMea+jdzV$rL z5tPq)w!dS%#L?7q3$^2?_nfXaf)uKcQ7)9gSKCzmRW23TwgwJ zYkU1^O)+9{DSsVV-M=6oDHN*yK>hH4b~maymmb8Zo(NKF7Yqf@!Lt5$?3>uXBm4V~ z;dJaUu9%JZKI7VnbKL^qRPDtA46Q?eMI&7%N#+W>OXV_B>4)1 z_Z}iC-fJDTws$X6idySh+Z2AUQk>?DYVDLwGap0izULQh*pn1PC|ezmLZKTXhEtV2 z7`B>GkCseh&rqXp(Db%*6W4)oDtyQ+Fe>#__ zW&%p_O(n&9#4q^6t!MU z;qN^|U(R{gutGkD*!TzA6s4@Z12p)l15+IS-AC%PdzDw+Nmuqqzxx=1Y*T#2%6E*a zR_gk;Ivy!ojeeKd-?g6VscP;!8mK290ywqS{<9gKTHmJZ>1)$*(-2Bm1eN%mHb(cr=ZPm`oR!0hj>bld& zJygnzKtsIDwwkg0Z=ssitGZFShJIf6kGA)}U6igy<3HM0?k~Rkj;}p>B$e{U2i|?b zd(TDfUp-Dxs_tVxwrZvB&sn0baO1v?TQm}WKtFH;Xl+G3{2Gb&q9J~b@gutszw7X8 zC2kVU@oSG?NBpiAoy2YUdtY=HRU!(0(V~s$FGA!1{9;81{6geF{08Ae&x62&@f#w- z@e7f0_zi_U6dVuVcyJ>862YSpYmx}X?^gU8<2M|?MEu6!mx5m-{3eLWC~u1BieEka z8i}dm4*VkU>xmyN&%rMnzk9`W#G8(I(-Chv(q^EH`^C>PR}95>3rC1$Vyt*hOg5g! z`n)N|i)|=t8_L=S-|b=|e)aG>Yix(U9r||YrN~hVyA<|L)LUfAbR31XUL)bQlp&f>H zScJ;M&<;a80__O2BhZdOI|A*vxXCyU?YIa9uLai?C!n2xb^_W7XeXeZMBbCoP9pC~ zXeXha661|iuus9hAjVr4kbXf-1g{0x70tx!c!xoI5sx<@bO0`Ng^0=fkMCmOF4X9s@LI7!gE?vez(<1R_iyX}&Y>j&8MF1s{-hh3VW zch{u}dS_jlpm)`!33@ACnxMDQZG+Ff?rrR|4L)hG!v~R)Kj^*2+eF~d61k22x559g zwT(;JhEkd$uMzTI<8s8@1MGtLhL($2cwguyv3}TfO*V;7*lrSE0%bVklZG(HVB15#50+Mo{W^jFAZ?a<-s1!Oj-+2H4qx-tIb|*Kt1A ze?G6{d|t=-T<7_`ju-GItQk!S?*r~kei&$_lo%JpAjTI{9)t2*$`imrQy0rVUad*6-_BX%ol3J|0jBh^r*6xzps=FMUlmh#G z#>yLPb-GI<>V%!7|Zrh#^TpMHG<^RPvo^rl*O-~ zGyCy7S;;GxTZ_-5?77EUf;Alg|2i8=WQe3Up~lJUH_VdRw$KeHWt{B3 zAtr+2jDp^M!=Q*b*>l5~h$5`Z4jGP85`gr^#&AjRUksPDFN90l7aHI#_($Y&Ueg%% zY#@4tlo;8z)D0&ivTZXqIGX0$MCjKMv0U3Fz#q(bwwFt46RKMRYO-j11|$aV6$4Y85rKQb~-ZrE@?@XZYmLg^hazFj%+Q={B=e%a#4 zbo1bb70~lXePJz^M>lMbOqXW^%OcmoXO$Tz&!7dC%NLs-GnUIuO;5t!(exzjpEsO} zTrLkc{lZA%(v#2we~e7xwaZ{7gOvf!w%5zL8}r(&XQh~xVpfV-DP^UUl~PtJSgBy8 zf|W{EDp{#yC6<*~wvV!Xlx$#aQ_gL$jj#02b z>ev@}bLe1TK>Ti|X@Q0310!8Ta zQ73tO?ZX~XVl=Q92mVtwv){e(V%vP%qZ`|HZDpso`(?|Ljc22>QJ*U+M=xFO%!&|LJp1Zzo&oKLE8*l0vWv637KkRV_Wj}kPO(jMcawW*__MXk7d-k*6 zwrP0JM0@W|qhU|jlmff*hDkj$?Y}mf-ZS040RH{pzse*`K!l~63VNp4hYfKwO^4FA zP72~IMJ^-co+gR*<(m>@9F*lfvpJV#+~0Df4RzjR2kzapv}?Bf05P*|dpDhopfy?u zWmL$!Jp&DjA7s$pm&;{^81!!KZ25hbGdf#d*wiArgF$;@wzO?7k=e2lW82L~@X?CM z79*o+PifLE+veFk2K!^|=AoSl_oJ*4n^U6?p_L}X=jqKQh6{Ucx_h5(<>m#^T?~5j zeztAx=Ec$Bs6)M=eEVCQS4HRB&t1P0{`)o`fDgTo--S0ve-XU|+oi;aG3X89%k8&s z35hAP$8NbfrVH|RLTw9HgvDeU%kjO`Ok@0(ZZY}xxm(6`j@6Ox*)kHI^j7j1Uc2S? zge(EYkN@W zwB7|?9q(vO!&)qKUEoqecn_V;>pmYit6jdF*6!2Z+icU?o$H;Cec_wl*|5h%ECK%5 zdmU0Q^iJ%yBQ{`ka&Wy~b*}aBFd&7~U@ZjJ=!Q>yu)M zjN^UsZOgY-_n|#^0FGJ9x1R5Vhs82HVe5g7!ZO)E8#tn`bWpF5j)t91%3|(y#oVuo zxj(oZv_;l%YtV=q6Hv-+(!eom(kFeJIZkfHbsa+VuO+J#E zG$O^AwB2J(>b)y@O(J9yuCjw)X@l8aDvCsH7qazaYJtHuZl-95h@mdt1o_NnP@6jjO|0MgL zG|2xX`=4b0YGc~A*9I-)^*Us{-t3frHG0Bf>zpxbTjijYCe8mZVvDabFED1KPf~>5 z0cQtEM5*A$?hydGhL*KlncuxA7IY`~uR zsL7DQ4a_|e34@pLzS9hzV+R+QbOb3d?b{P%0s7mIk?Xm?H8bl$Ddrwns+FeO2bk+k z8b6zvH*QaN(-CC-;0i7=-+s@QgM*74A9ZZltrALtT*-SxrAZ@yB}(yx9p%1ViI`%@ zX~b+Xw_FbLs9g=S!J#z7obG&D5W6Z>UogmM1oEnaEnR8v{ zXwA&KP`4Ug&j+@b$RbCtki!9l)r_wPtdk43N3-sXvoMC!zBypy!NKRb=I6P#G)H|n zF5h4y<%wr2{MNO*(UF&!x0Ci(g-55?084 zj$=Ev4m-rXi^k*xNjM-No^A4Z+uX+MzD@qL18a@ZY|Cw=e`;)#vh*a*R(b~<7)CR? zEdg|5xfeW>0hTL6&S0MBW0l#(qLDn@qH);5tBO{}$3U+a6w4TI(U_cM(U_cOybm=mrc3nkC?{E#`D-3CtZD)_+89Kser61bUtmxwI!_%zm zclHKeV1?egnr2;Oo8HHoX3^Ug=c5jv8uPi7`5Zrw>yXEF$m3Kfs6!stA&<+=vz|nW zdDbI4KeXp@S$P({S2d4g7I3Zt&UKPUuqabx#E$3=95tdJaPo*WL9LN*-?MWhW@tx& zY1k82G)ct%^5BRR;A4z0Fs>hwZqoT*srWdt3IT?ui#XT_NryAERJFmIQu>l&1s zATOZA0SW7^MWdEBS`RT$^8Rub zb<1map4X^`YZYkHo*2$0UWR9av}|;BDBu26SxMqbzKXIES9ltQ)v?i-9B89{7-XZC z53y0N>tLf^*TqKb-o-}i9&V$S3Aa%VW7so>bH#9k80gYKc+F>s!gW z2%Ftn$?JZU^PXn?G;;kId6si!8o!i{NIK2+InAY<<+W>O?cMZLQZq|-I)XE!vE{px z&U1u(`}gsTY(jzkJJXTW+@4ZMUUZ z?RJ;Qen!{bl&e4NY)QGY<;2}|-uFqieQY4-qIT{uwt+>ZH?TItvw>C7>!i%Y_YMxi z|DMP8jqPWoZg{Ux5c_AsK07vBrd511wv=bE+0MBY&T-k!)D884qe7Yi>FhGZPTyq9 zmXB84Ij#fe4FNjC8elZW6&U$+MTzVJ<@2y|9tYCRuoa`n(HhMh7X!WFt;O71in$LL zb03bi({;{RoZDV%9E)@6g|2uz_1iMRFhoi*-mJA*>+EfQObF#4hgb?*P@cw;wU2H?Xz~;7TI$2 zo)USQea_nNzkAa7VvLQQ!j?F@w96XbKu*g1@H#r4{xY79#lMWFA9q2{qE#-)rEe`4*D{09%H7__nnU?7O=exGv`KOwE+0z{JtYiE0`$Nw<{NIS2+)C2-R?k9Dkd+Sla&I$9U+k@PT*_|@ zo!)$Xo^w??=u5XX$a_4rhV#}qZhCI~592rCZ~mH)B^i=o`3~ zk?Y>cmpPX-cJD2NLM1kn^abF{4th^^rGvhYYdN<+zihJQq`IAx|AeQ-+pC$RuLE07 zdTVwa_`Eu~4*S$`{`~yL$<2)LeY=2hz&hHWz8_r2NpI4wbkNs)1EFujy3jXr>p1D1 z+LaFaT5k}d{c~~8hdAl2 z+a2K9G`$0Rc5s$IKYntx{P(^yp&hg*eX+QMlitDI1)iPLyRc__!}D$#6xA~H9pf%e zdKY&%eEO${vroA57p$+#xc}WPI`lPGdXzwo9SUjWyf9Vm5#Ri zUj@?FiR0l}FDT5&-2Yj6w!CkDi3~HM9u-rjD`A1BlBSkOddVhZ`@Yw!wz<=$}oKoqauO)|BKkR>CN`{lh!!V;x1`%&tUMyatj?9+ObN4kSN?m-QODQ8mu$`WDnT=2Ol65|g*sSM zXFKn2*&LWt*#=+#5*fEn&0{5xl>#W!r#6#cw6`9w zlo?_2iOPBz?UlljqpanX)K(Y5i~B@bYhg!Oud{M&e|PxP*Nem8i7TzN}0H2ER8hBi+^Yy{Q@5&i?P+2ORfaUPdE~Uuz{(hz8B6WvOT{5khzin0IDu+_!+NbrRX{Am& zCYEALH3}=mHu3=J=vm4=fo343(<->Om0a7S+&4~hP0p(N*uO2AHa#74q4m?V<&*E1 zPtU}>^c}|IK&oe^RrvnJ>50}G@7JG!I=s(yU_8k90g%?B5mHaV&X&KuZ@_=mftFC* zhbYzmQ2QAvR`Ww8XsZsuB3F0lDOL>ILttlG35RybL~GWe!N`>ZrO5RN=UsJR%Gw9eZtv4oRx4Fy>H%vQZFdRHRI*;s5yNdK9+rAS&4Pgd*|bIYDMU8eo35~#7dHj z-aKE2Bc$n4Dnh%=%wV4kRx@wFalcij%Qf<>)@hhOz3#w#1%KB;cIjwDa1O8bT%>?poEjMOmo@dXqKxbHuIJEd< z;Qqy@!YcXRbS2-LuH+eMCEuH_%N!?p7yB04ALMzeL*Q4y;oxcZql<$%s<(cqNK7TyT zrJUwkb(i#Pz-cb!G}p~?(>d&EuES}rVRuPS3!LVdmYZgDmYZgDbk*I}^(=k@Fv#Ph~<>%*h?ZVdaEa+=v~nDLs?F?Hj{U&%T!%C+ zy#}cX@-oM_{HRUp_|g7SlNh3Gi~LJGxzxgh2Z8zcO7w?wD=_}h8Q*OoU(R*8dxdoDnu{%nd+WgW4I0%CxoHeP^gPKj#`S1D?0vSlCs#B% z&1d~|CE-(}m|Hd9-s{Q8yGz-w;66~vTYNpX9qzgN(RVEJ?SXAa#?ZMyl628olV7#;3XGoaI`b=l)Z}Iz4INnU`&AcOrJ)WkkM&XAgP@B+hGZ zdQK$ZI&=RMCAg|E;lzH!s&nbp!g+P-P@lKzxYmDR1=gYV4`e-1EA+jKWn4;N9s1hE zKHG*9bVa)4MD;wnPCMOQ2lo)aY#fG`UuD{aQ?!vbQAe}``iq-@fp`|qCYs`@ESm@t zoupGFiN1zYB#VK-G%*x7StI~6L=teONCD0k6M=V&slfSSCNM|L0p^K$z(>UWzygs6 zTrBc|?{mzf9PG8GsvCjdvvNx&pr-?T)soC-{n(}0uZ3}A-L1kRMRfV1Ts;N3C{IA3N1 zbL2gyQxr;Sfi;p^;AKfIuwGINY?Raj#gba!O-U_KDyap^B(*?=q!!pK=Na`xl?<@e z6K8S#&M7W)G+|J*E(S&J#&)mm7Yf*`@7DXFk(RwA>NKdwr zo@OI`vWEa+IoNW)=ec6s>dx(SD7-UIbW&TbaW-jg}g>`XN-H3H@#UOH1*B7ucLk9-CeglTM>b1eTxH88{qPEBS#jeRD&skdafskh8@Q*W8=ruDkpO}%Bln|e!*n|e#0n|jM5Zt5)sZt5+I z-PBu_xv2%7aZ_(8$$9rTvjoc^(L29#-0`MMBi}#`*!pVH$7us4ZmeT%dcH`^bNmzfpz=_gf|rZuIp-FBy09vY+_^bAY*p`BjVy1>b3o?vX3ia+PL0^3Cb^w;$#OmJq4#C&+?Lgbq9cZM2Q{7p87ieCs_1(O@qVE~zoqcf#;&`nzmus%pEL}QXbCl*3 z&3M3^^9O78XinC=+K=jS{jlB4D~EaNDF0;5IhqSKmuub)ruMDU%XmOpm7}>enAY2) zm!tJ^s^&timucRu^(wtA>Zy1h&B>Zm!BlRwUQVg6{BkuHX-@H1x@@SpmFCu9YX2y` zoT52bbCKq9&DEM^fR3X%N^>$ez#QdC(aX7-3$WZniqg+ z{R(w@P9x=41SY@LU`j93db!rCHMef8(v!hduhn2$-!i?N+C-(V2GjP5YO3Ot)$o#ci!QT63!A1)5iD z-mSS>^BK*ug^FWp4hBs+SjNUafhz<};ej>va2Qj@F#2d4c9a&8sz+Y2K~*jArwC9Zz$# z=2XoKG_TgYTk{#svZY>6&8;-I)*P)lRr3PPt2OV|d`7bwtm3uO+*)&#=4j0+no~9B zYF?nZNb_pVyEUKDoZDKJQ=~aJL@#Uhv{B2+nuFV_Wsl|}%^~fDcml^C516)hvgQ=c zX*xYuFBje<#1z&edF`xmSM`;=?0(t5RC#=larzXX$CuwM3PF4B6LUary{ z)m8bYXwKDKq`6#kwPty{ULVa-no~6AYA({e22A~{TrXE^mfdt5%~6_DH0NqA(p;{& zTC?n~^J~rlQ+t))q4a9a@=ooiIZAVi=3LE1n#(m;YnI_Uzvd{-DVlRN7ilinT&-F5 z(D^k-X-?6ctGP&Xx#nukGD7Fq9Hlu$bFStx%~hI3q{7|x) zH5Y0w(p;{&TC?n}{8KcSX)f1XrMX(O=%dTk9IUyO<|xhh*G09ynmwA6HRot9)Lb+` z`Il?1idD;EppK*2qd8e~j^;wmWtyuri$OZSW{>75&B>ZmH0Nk8)Lf*wOmn&BD$UiJ z#b8~IW{>7%%{iJ2HJ53w(kzDP{F*(QlQrjPE{{`w_*YME-&UG~N2q0w=48z|nhQ0T zX)f1Xtyw1Mc$%X$=V&g}T&B6o%SkF9{^64A6EZ;`KfyHaMy2CC?3VReDVjY~m7c6Q zM{}X(GR;+*abb?*R83cme>LT0YmO=>O|zJ*^kB^%&B>arEaksObMQPhJ|=4x_vmHK z!S||o9?i*`bG*wr%0EYQnRhu?>4gPqURA{*knv*r>XfD)TrnyS7%%{iJ2!L%LLfGJ;@UarzC7OC`@#VXx;LUGK~iqoD^Tv4d?BE@BERKBXU zipA?{y*-+%yvrMOyKYqTq#Vs+vs$hK)A@tgqSr^W2Tb)&)|{ic(5n}#^c>BFn#(j- zX%=rNzhKQC&B>Z`G#6?v(_E!lY}NTSdo(9&&e2?`xlD7FW>KQ^YxZbP)|{icP;;5) zD$V%!X>NyL%^uCknsYQ4YA(}Ur5XQn&H2l=E3VQkcIdd8J(`m>=V&g}T&B57GyY9m z)k|}k<|@tjS8bK9IeC{_F8hb#D$Sxy`)Mw$(0-b$G>bjjPjm7+YB@)9q2@BpRhsed z;i?{*J(`m>=XlfiDL;?qWX(C63pJN%_Pne7lQrjPF4SD6xk|Ixuj`@Nqd8e~j^;wm zWtyuri%Okevqy8X<{Zt1n#(j-X%_G4{F*(QlQrjPF4SD6xk|G*pz~|?XinCgqq$IX zndU0Z;-Jp2*`qnx%kQi79L==_>Jnv*r>XfD)TrnyQpUV)(2U$aMZvgRDkg__GWS82xU5;%X^ zhl;B-=X|V|3pJN%uF@R*iAwiqPS%{GxlnVN<|@siO63dI?9rT~xlnWR=gKcfbD`!k z%~hI1&Z*;H4!Cv8fkxUl`uO<0Vyi}R@Q=E_Uj9iP-;y=wXg>S1_P?b4HCKS?cwGhN zyN^@{W?XNjUbD?)RRHYYcuF@=S(fKqdYcA7Vr8&5h z_S2lBxlnVJW)Y_SG$(5=)LiCGzg77qYcAAW=GD8Y^c>A)nyWMi0$io^VgBXNl)E&pyv*9vSsg)Yhm|QC~*|^z78Lf6rMx3wxII zEbn=!=NCQy-Lro5P0`WO{i26NkBy!geP8sk=x?LbVz$KWi8&VYTTEcD=DmV@h4zZ; zHM&CI_gd9!L$7yweb(z@ue!Zk_3qI-vG<(b`MsC*ey;c0-mmrE+WUC#PJJfz zxx3HfeV*>~LZ6L&w)ZLT6WMoA-?4o&`Y!2P-1pbM0sS8A_g=pb`hC?;^sm>yS^tFo zVFRKE+%@320iO)`*MP3EJ!AXD#>WmA_}IYZ1J@1wbl~-aIu1HD$Thgp;0c2t8GL4N zmm$v%**WCgkcM$#aed=b<7UP^6Zb~ksW|u08-}(YI%H_(&;>)E8@hVvOG7sfeRJsU zp?inQVGV~h85T8c_^|Y0_YPY!Z2PeCVebz6VAz+#ej4`6F#q_L@i)hJjgO2U5kEJ6 ze*6RRPsOi}A2)o#@aKlVGyKT#PlsO^UT;M3h+9WQjEEmGe#FcXb4T1i;;9kaM(iDN zd_>gHss5Ob zV^YUtjCo|tiZO*_UL3Q2%&{?_jrnTKg)u*k88~+4*u1eHj{RZmuVaJ94IDRN+`Ms5 zk6S;kVqE38v*Rv}lgW`-_TO_wi9+o^UIX8KE^19^X$@Nm&r1VXROBs_gG3EJ` zUs76*?>9bk{QU6+jRda^}*^b(!yE_P8tgt_62}cvt6HIkQ&I+BfUl zS(j!7%`3;Yc(Aw_7LS9`jVZN9!3p$0Ni+ zXN?qMTH^;`uj~ID>~H!P0q-2J7PxyJ?e z$6^sDp1>Qjo)yEyD&&3vxz{51%gFtz7>QHpM6pGT5^svpVh1j$>=NU|J9vv#CEDiz z+UK-L$D5<3h#$~SKjOm3FJij*6&FNIIaAnWrf}eGNq%yUs4MRl!7>|9L(La$<^7_) z%o82ugBZ#m61U2S#ci@c^p}r`DRPmRDi@3Caw(n}dP2;RPm3)144xTUAs&*?iihQM zB3~AXN98MGq1-GA;6EF60@gI@ z0{pen9l&}GB7j!o7~tuqYe5n6TMRda zSi-1MKV|#ZMighx_2iR#J^2r)cia+UM7@uIs>EL#{S)@BEy?E&AAG81vdvp(rEd); zpSN##!!E?J8{P%lTT{#ijGwpp!bLUz8u(M2?}2`S7lD`CP%e90Rv3@n@Ei0%wwp7y zY)jEn+fuXwMwLrNP-{J~1GQTc8cmC>qBEy=RQ=HZ+zc==-XFS@Ad8HzBN&GQ2lvew`GmIy;}9*VF?jUykmmz z2rxdOFZ8tuREHYIW+Tb&F>)~U(Ict0DXc6SNjXrH%O@-)}S_&*otpHw3rD!YD&cXgO|Jt?qC5=3N_rAah zGzR+C;nzmBA`VqBC(rKrBGjtif<=-H3H z);GW!*G1r0merER+xozjS3#(&+IyR|eU&kCe0!HU_gMR{J#53smSWMruKKWr>k+`T1PZ>6jS^DsR#KeIkRIsAzrH!22{$gjh<|e z)-Gsw3-u_{A6iVFOBuH=rtPan0Cike|WKXeb{#3%A#`$ki7);+NS`co|_raJy^c!F#-hNzmaY(}Hg15c5D?1tBF zyRs~$w|#@Q-K;mr=UVpdJBJMS`uN(u{&W8kJJRd(*+FV06?61RYQr}^7&6>nJYpC) z<9HZo;4Fg9B;W3$Yn(gb`#eAc=QL5kAMw^>d>2Ue0@jy( zkm}FaQ1*k;fU%_<0KKyu2<2AB{&Fys0YI#~j057L25^`h4vfdGZG2fpCc|}EE)c!WSOmKfV-sTu zl*T~xDB}s(9e{Xew(%tFu8iG`r=fHQ8ltDM92jjp3yd+I1NJgj0f!hx@QGs_h7X6K z{ee;pH`c*?6yr0-N3b^-p8z)+)xa&r8Tb?f@m6xyEb%E7JeJSxYe*@T`GJa+R z!2aB51gtTd0HxUs=rCIVo#u7$cLC8e&6cqJfQG1Rwt`*XYz?~}qrZ70lmwV%`F5YKFnHx!DzU^QR271gmU{7-x^k^XV z2y-~FmzeWM;x14a9h5&VoG#C`Gb42lhC|Y%>eWJfI;8&1_(ic@OXf^Iqty zfd>97>VDX38DBDUp}Yu`Vx74F_RByiUNs+t{R&Wu*UX1uuLnx8!OVyKI^#xjA(Tx( zLu@u5gS`cazG^Olz0F(#`%T8}<`YnM01Z)UJ_&m#<3G%&q3mKjXD)~SPoN>bGM|Ng z9%zU=tmk0g$=JhM1tpv@!YYCu3B(q$R>O`0V!K#tVMhb8U96X2_hRg0t%K4Vi0xv% z3cD|3f9o|U0~iNd8=%KB4ze~u9}L7cvbMk;YP|tF4k$&uRRVh$;xL(AJ%?gne`s9+&T!XunqzDSVw?w zTgQO=tmD8+>m=|!>qFoH>m%Sn>l5JnRyFXLbp|nyTc5%HfboR&1(Z*%b5N=ozqG!B zect*Sc**(~@HguMe1z=+Y-#%*wgJR`YWo3L*Y*>zq3t5@Cfg-oC)+Q;FxzkNjIc?} zo_a7w+Dt!82L__D1f^p_ad*;+w=1!#y@ZLMLiXMEjuBb3*G zQf#!fg}nhN#b#T3*qa!)*lvbW48+)L>j--*|q}RC4$jo9|t{>G0L6-y(bW}0(&a#7{>ng2~hBk zYACVxNw5bn4z#C38N@iuJ{9^E@AZEGtMX<9Nv+YZu+|4-8{si=U zfavq~Ct>F?K4gCy%7cs#+m}Os1c=eY{w(ZA84K*sL0Jft;&J;b*pD$TvKK*F%(&FP z8u}8(C+usXFJpYl{u1;j8K1VVgZ>N+L(Cyvq2R{U6X@2V(ED zm&4x3xY@oN$|lAw_C3&x8MoTsf&K<#iG3gRHyO9v_e0;txWoP)^im-9KKnt~yMP!` z?T28O0kIF-j{qy}$Dn*-KMwn2Am-}!ld!88Kec}dlBeuq6;PcE?w+Ek=jqYuMc!{{oJ1TtI39 z5XX1N_rOHQ4^Tz{Fo5OZmV1NKzLX$}{Z>5MZSe$Zzy-tDLhJKs?s_^6`+aFHVb zxZKeQ_^zV~(BW(b?Br|#40B!w?CNX@?BQ$$jBvIF_Ho__?B{HYQu+gNgmtzD4s_lO z9OCQCQ;t3}+NPX9BTT zI-`NJoV|dvoqd2=&VInVodbZ`&Vj&r&cVO}XB_Y`=P=+h=Ws-L5{RR%GXeH{&P3Ri zjE9}0VIOgh0e;{d2TXLO0Fzv)z!cX6V47}3T4E5cNVvsDT!rux+kp+l0}@}k z5;^7zMthNFI$%#WUBL0C4fue$26~?PBCx>pgYvjp7grEYne{~!j=wJ$QDUrZ4KUgE zqA>~o#T2Qfcf0UY7F4mi@)5;)4$3Yg?-4IJyb5t!_1 z3mori51i<_893S15je$l3oyeK2AuBd44mn@4S1L9cHnG$=Vp?a>$($ox2p$mo+}dA z-_-z^~7xGN60*fk8e)HNKq%#{Fq%0=Hf zdd4*xxWYx>J6h=)2YlX@0xWc;;_Ai$mksw*8@eX|1KpE=&E4t1Aoo<@_3mlFVD}8* z4em@}hH3#tc#HUIX0fe$iMa8rNHdFDf;yw*=e%QU5!E zdHy#8AM(Ei_=tZP@Bw@wdb=p_?+kq0|2E)a|L(x0{yl)p{3C%+`9}et@s9?s@b863 zbPpicPVv6~6Trj%PXdqnKMnlAe>w1k|Fgi;{`BpokNsBxtNe?ApCZmqaTalQiq8?} zkXYZ)0eroo4Y;vkHE?soO*jKCZny=wwV?^+&4zx)VevNX!(taa4~sH*9u{xG^RTFZ z=V4KbIETevcpedlp&tf_5~)YTQFtB^A0W;VaRQ#l#g`4!ZO6qw;dxw~ zhv#weO~a{BzJ=#;@f|#miyC;Iz@zBZcmVNNc%BfKk?Vvo0;U43fGtS12bj>E0e(0~ zZ_el+V8geZ8Y0(85g4!udSiH=6iwlIQq&7@;OkCppqvsR0pHtBi5uW~O0+}jDRC1b zTo8{S!UgeAz-re8@hDO+h=PE%updXR3t}-mFNme^bYq`ximP<(@Ex#j!h1W? ztDB5#YuVx<@dUnr^&&=q|A)Fa0h9AO%S4aVc4A|UtoRzolM8kh@Q}o9Yx9z{6}MU~ zskF7M?zSW^X;*hux5}!nYE@NBY6r)1GCW{-LSQn1;b9oyxyj|i+`s@c5QcRw55qhe zVi*GnBmr(9K-e-Y!!iS0?)!erf6l*Dt6O$pke=%QKl?e~`L^?&@0|a`;itk!!Y`no zzY#tQtJ&5qH*In6Tt;+rS_xBUnAzp($>$=#FtCV%X}&m4H+z)c6g>)@{+yzkI~LybcpKlBB) z+iI_?y`{FVcBD2_tJk`Nz3JE+kNxbi zzdiQ#$A9?vi^tzO)tS0D_3YHoO#R-}Url}Ci7!9#;)ze3_{|e{K79Z5(dj3r2h(3O z{WUZ1o%zw3Kc2Z`_RiVa*>khsF#EpQAD{h|+0V^}N51sb$x}ad>XoN&KmF~ef9mw- zPTziJ_RJG!{?(c9J@boaK6&Q1&iwV6>mL2$NAG*|z@w)hoqx3b=!HlB9H3c``BZD^Vsgk_dkB@@&Ekz=N|uy$A9377oK?C+kffp zch8N_&CI=b?gMi_HuvJ({ZBshcm>Ut_BAM5Eof+<3lmbF+^HeWdjzPks4Q-~H4VoV)Yf!{_GD{ZRWS+P5r^FMrGOzghmB< zA9wb4pX$D=`!BoS(fxt$f9Ss0{Y3Xqy8om5s+E7T^5&JXmE$X?SC&@3YUMpE?_c@$ zmG54;{`~gyx1E2}`TNg5bpG`Dcb@}Mfe+TiY#9q$hBg69u8`1`^)h4;hj=R3pyf!_zi2SCvu2;YU@|ApU&!wi%Tt!z*Yl{91T6{5pQWf#0X$ zYxJ9F@3+v_XVA`XE;MLanVi(9_vRjl~z zmM?x4!)Ld=M#Akauaj_l%U4KvVC9~+d{0}xue9)2B8-(U?n!zO(t^*UDEDgIn8g0P z8rqij>z~{6o9y|$c(UE^-SXUFJmB@D;eTu4|7`g_V$Ywm=L`1y1$(||&tJ0V$L#ql z_WXoBKV{Efx94Z<`8)RftUZ6m%R9=Pz#6e4n)Eui3LdqVcQt+;feF-*%lo$L%>`&q;g!h&}&{JwIyCU%pP^ z_;Gvwsy#o6boT3$7XCF0|C)t=!^(f!;y-Qizh&XyvhZ(P__rvcRgZqw&Bdv2HKHQ_rg{84-UygmP`J%7=jziiKs+w)iL`AK_z+Md5{&)>7> zAKCLy?D^;R+1@#-*Q&uh1T`h8mvzIOZXzK`**lxGOnOL_=5+H?E%JO2gKZ?^E;>^W}F zNqZi&XU(2R?0IbavG)@%j%}ZMKk;Q}=j>Ux=Yl<(_I!su zpRwn&+m$Y!v+%p^`BnD(YD<6Ko?mD2KX1=lU#0VQr#xU=KZ~e%`^IOOFceg&g|2=sB%ynNr`IB28oE(fiIQil0 z9-Q2L&0ikeea&Cv_jmY>9NK-&_4sYW?`HgN#qTxv-HzYu@%zZc?rZME^G*2OedvX4 zyAJ&ven&?B>d<2&yKDQm@2!+BNN{~^7Ti*6VDHh zym<66Jf9i4{aAPF1IM1<`XBN9#beKodiTCS|cenmDeg~(X-}mE5W@=x%4 z^CLfg?Wbx#iQjLc%-3K{pSteRQ;&_zpE@$Kgx}h!=eJ%z+uizer@CAJ>h!yi{`B^b zOx*p7kDhsU zukrZt?H_*p2Ve2xS z9s0>@KYHf-uNkTTzJb?mqkM$QzeGbp87~zl1U$!|xOL{R(~$-Spt(0sLOr&fl&>NAZ3V zzvs8kpL!NBdVcGVoPT!YN6tUL^-s@ZK6`(Iw7EAe|Le(%EXYw+8@ zee~jcui5+f^IP{mzU>R=E_JuQ>(Xn!aPQ;yppJk3sy8mb2l3BuZS{|D-~0GK+y3gO zpFw-?z2?Em_n_^6fjlG7v97`IT6jv_8d~5wE%-@%0R9mlfcHWRo(dlb6Zq{9|7y!2 zJP+e{6u)EmP2u-2e$)8P;5UmrkKlO*bv=sTWB9G1j;HZ^2Yz3P-!u5V6TfHiyNutv zv0MIbtmOU~-v4#@T9m#GI^b>40dEW6gShWOn*19()@`h|n|s6Y=EXs`)9)@0Mi2M9 zozX_S9rod6U#nB^t<9{hG)q~Soa8yxYYm#cz|tq1^#)!Gb%yCdebAH=mVq_st0ql! zJA-uUdbt5o$W>;s8d`tqZ8E+3U+NvtZEtF}%M%s82Ts`G}eNe7hN= zCRY1{?(#&hSs%2zoh<&z9Ra1AGp*&AcVE5V47JJ5pfy-K(p)>%T%HGb3*oGWFnMXE z-f47oI58R}1z0EQEdp3aB0%zi$Jmoi05Ap>w3D6Hz2*7<$Phuo=vr1IUA%)NI=$XK60|x z>-HuuG&_Uw-Vy<37{0&RU+A?~Fdd;%be4>1 zOfQ^mF4w0QK-V_wljm2FX|OiYU0$j8FjyrJuTZDasKLlB>twCpEsdZ!Wa!Jsk@x zh(XVFT8EegtFjQ&gu}*M)~hUH28=fg0Ho|w76!5{H2ZzbM$M=nyemu1CNzsF48)+4 zrx-S9bIV7@q#d!dT7?vAOPy}7nUB*hXW~a$`sAgB<_cM>6w?OsaalJey6rY3i|4g? zDVLk#d~r|0^iEzHG&_Bi2hL_7Htfs@tAwV^>MS(mncHEV%Z$3gyPJ))o0^{Ia4Hm) z6BPlWZcA>Pwpe2sD-cIsJc;9NPO|C@(*IGykX{U1uNxqCr#iG{b z*-oRm2p-?i3TuswaGfu7JCPR_;v_GLk5fWUpS5HBsuVv_Uv9Q1AZ;B|#}UylW%GJG z0kh)_){#O86>0$iLO8P`W)tqsa#U|JPU;m&%>I_3BGj&2w0BAJcp!o9v6j%K^1cjA z$5-3!JX_B2@o2ZZlI4q#Pv)gOqv%n8Q11+sH|wMvY(itJQ|@q}CH%?g**LJvI%&}) zZL?PcM0%ZiTU4E_i$XH}GT{)BN<|Ij9W)}?AOdwfrQA*vxvEh15>8VxPL#=P0&2&a zgL(s69Y<4OK8Qhw$v^;ti8=_Rk>xlDQP%81TR94yZeMq`)2ObSR;D0Gw^^}*7|k81x7+jeg>wNNP%Ip8)K}0I*D0uR^{Ka_8t2x> zyEA8d-HTZf(=#m^QdzU-qoHg6uPqoY888Xy!c1!*7Xok2+BiTF+uS~l=0SG_2GXP?cS=05! z<}|v}WQJ9inh(=!{XuhCh8Pq_LT!JmztZhVhiZ>udDdE}_Xp11LT;Lr_d2VKctH-Y9NBV! zPv^seE=SW1K35tgc&0NFb^pZ zgIYAn8sy!YJP+&)Nu0Ovrf*>2>0+DL=26pM1m1bBvUhtMYxq_IyVb3Ux?6*kW?rt%%oe8(90|BChT?2 zuS_axs;n=QS4H*)K;0i`UBCy|u)M-7LYF?_K�Lq&#&7b2875fq>6H_x9WCnn0+ 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 Cbs+Zu diff --git a/cb-tools/SuperWebSocket/SuperSocket.Common.dll b/cb-tools/SuperWebSocket/SuperSocket.Common.dll deleted file mode 100644 index cb07bd2690a91d73aacd993d1fed128c9c009085..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29696 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 diff --git a/cb-tools/SuperWebSocket/SuperSocket.Common.pdb b/cb-tools/SuperWebSocket/SuperSocket.Common.pdb deleted file mode 100644 index 33b1455a58d509cecc6f786207c63260ff5f76a8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 89600 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 - - - SuperSocket.Common - - - -

- Gets the array. - - - - - Gets the count. - - - - - Gets the offset. - - - - - ArraySegmentList - - - - - - Initializes a new instance of the class. - - - - - Determines the index of a specific item in the . - - The object to locate in the . - - The index of if found in the list; otherwise, -1. - - - - - NotSupported - - - - - NotSupported - - - - - NotSupported - - - - - NotSupported - - - - - NotSupported - - - - - Copies to. - - The array. - Index of the array. - - - - NotSupported - - - - - NotSupported - - - - - NotSupported - - - - - Removes the segment at. - - The index. - - - - Adds the segment to the list. - - The array. - The offset. - The length. - - - - Adds the segment to the list. - - The array. - The offset. - The length. - if set to true [to be copied]. - - - - Clears all the segements. - - - - - Read all data in this list to the array data. - - - - - - Read the data in specific range to the array data. - - The start index. - The length. - - - - - Trims the end. - - Size of the trim. - - - - Searches the last segment. - - The state. - - - - - Copies to. - - To. - - - - - Copies to. - - To. - Index of the SRC. - To index. - The length. - - - - - Gets or sets the element at the specified index. - - - The element at the specified index. - - - is not a valid index in the . - - - - The property is set and the is read-only. - - - - - Gets the number of elements contained in the . - - - The number of elements contained in the . - - - - - Gets a value indicating whether the is read-only. - - true if the is read-only; otherwise, false. - - - - - Gets the segment count. - - - - - ArraySegmentList - - - - - Decodes bytes to string by the specified encoding. - - The encoding. - - - - - Decodes bytes to string by the specified encoding. - - The encoding. - The offset. - The length. - - - - - Decodes data by the mask. - - The mask. - The offset. - The length. - - - - Assembly Util Class - - - - - Creates the instance from type name. - - - The type. - - - - - Creates the instance from type name and parameters. - - - The type. - The parameters. - - - - - Gets the implement types from assembly. - - The type of the base type. - The assembly. - - - - - Gets the implemented objects by interface. - - The type of the base interface. - The assembly. - - - - - Gets the implemented objects by interface. - - The type of the base interface. - The assembly. - Type of the target. - - - - - Clone object in binary format. - - - The target. - - - - - Copies the properties of one object to another object. - - The source. - The target. - - - - Gets the assemblies from string. - - The assembly def. - - - - - Gets the assemblies from strings. - - The assemblies. - - - - - Binary util class - - - - - Search target from source. - - - The source. - The target. - The pos. - The length. - - - - - Searches the mark from source. - - - The source. - The mark. - - - - - Searches the mark from source. - - - The source. - The offset. - The length. - The mark. - - - - - Searches the mark from source. - - - The source. - The offset. - The length. - The mark. - The matched. - - - - - Searches the mark from source. - - - The source. - The offset. - The length. - State of the search. - - - - - Startses the with. - - - The source. - The mark. - - - - - Startses the with. - - - The source. - The offset. - The length. - The mark. - - - - - Endses the with. - - - The source. - The mark. - - - - - Endses the with. - - - The source. - The offset. - The length. - The mark. - - - - - Clones the elements in the specific range. - - - The source. - The offset. - The length. - - - - - This class creates a single large buffer which can be divided up and assigned to SocketAsyncEventArgs objects for use - with each socket I/O operation. This enables bufffers to be easily reused and gaurds against fragmenting heap memory. - - The operations exposed on the BufferManager class are not thread safe. - - - - - Initializes a new instance of the class. - - The total bytes. - Size of the buffer. - - - - Allocates buffer space used by the buffer pool - - - - - Assigns a buffer from the buffer pool to the specified SocketAsyncEventArgs object - - true if the buffer was successfully set, else false - - - - Removes the buffer from a SocketAsyncEventArg object. This frees the buffer back to the - buffer pool - - - - - ConfigurationElementBase - - - - - Initializes a new instance of the class. - - - - - Initializes a new instance of the class. - - if set to true [name required]. - - - - Reads XML from the configuration file. - - The that reads from the configuration file. - true to serialize only the collection key properties; otherwise, false. - The element to read is locked.- or -An attribute of the current node is not recognized.- or -The lock status of the current node cannot be determined. - - - - Gets a value indicating whether an unknown attribute is encountered during deserialization. - - The name of the unrecognized attribute. - The value of the unrecognized attribute. - - true when an unknown attribute is encountered while deserializing; otherwise, false. - - - - - Gets a value indicating whether an unknown element is encountered during deserialization. - - The name of the unknown subelement. - The being used for deserialization. - - true when an unknown element is encountered while deserializing; otherwise, false. - - The element identified by is locked.- or -One or more of the element's attributes is locked.- or - is unrecognized, or the element has an unrecognized attribute.- or -The element has a Boolean attribute with an invalid value.- or -An attempt was made to deserialize a property more than once.- or -An attempt was made to deserialize a property that is not a valid member of the element.- or -The element cannot contain a CDATA or text element. - - - - Gets the name. - - - - - Gets the options. - - - - - Gets the option elements. - - - - - Configuration extension class - - - - - Gets the value from namevalue collection by key. - - The collection. - The key. - - - - - Gets the value from namevalue collection by key. - - The collection. - The key. - The default value. - - - - - Deserializes the specified configuration section. - - The type of the element. - The section. - The reader. - - - - Gets the child config. - - The type of the config. - The child elements. - Name of the child config. - - - - - Gets the config source path. - - The config. - - - - - Extension class for IDictionary - - - - - Gets the value by key. - - - The dictionary. - The key. - - - - - Gets the value by key and default value. - - - The dictionary. - The key. - The default value. - - - - - EventArgs for error and exception - - - - - Initializes a new instance of the class. - - The message. - - - - Initializes a new instance of the class. - - The exception. - - - - Gets the exception. - - - - - GenericConfigurationElementCollectionBase - - The type of the config element. - The type of the config interface. - - - - When overridden in a derived class, creates a new . - - - A new . - - - - - Gets the element key for a specified configuration element when overridden in a derived class. - - The to return the key for. - - An that acts as the key for the specified . - - - - - Returns an enumerator that iterates through the collection. - - - A that can be used to iterate through the collection. - - - - - Gets or sets a property, attribute, or child element of this configuration element. - - The specified property, attribute, or child element - - - - GenericConfigurationElementCollection - - The type of the config element. - The type of the config interface. - - - - Gets the element key. - - The element. - - - - - This class is designed for detect platform attribute in runtime - - - - - Gets a value indicating whether [support socket IO control by code enum]. - - - true if [support socket IO control by code enum]; otherwise, false. - - - - - Gets a value indicating whether this instance is mono. - - - true if this instance is mono; otherwise, false. - - - - - SearchMarkState - - - - - - Initializes a new instance of the class. - - The mark. - - - - Gets the mark. - - - - - Gets or sets whether matched already. - - - The matched. - - - - - SendingQueue - - - - - Initializes a new instance of the class. - - The global queue. - The offset. - The capacity. - - - - Enqueues the specified item. - - The item. - The track ID. - - - - - Enqueues the specified items. - - The items. - The track ID. - - - - - Stops the enqueue, and then wait all current excueting enqueu threads exit. - - - - - Starts to allow enqueue. - - - - - Determines the index of a specific item in the . - - The object to locate in the . - - The index of if found in the list; otherwise, -1. - - - - - - Inserts an item to the at the specified index. - - The zero-based index at which should be inserted. - The object to insert into the . - - - - - Removes the item at the specified index. - - The zero-based index of the item to remove. - - - - - Adds an item to the . - - The object to add to the . - - - - - Removes all items from the . - - - - - - Determines whether the contains a specific value. - - The object to locate in the . - - true if is found in the ; otherwise, false. - - - - - - Copies to. - - The array. - Index of the array. - - - - Removes the first occurrence of a specific object from the . - - The object to remove from the . - - true if was successfully removed from the ; otherwise, false. This method also returns false if is not found in the original . - - - - - - Returns an enumerator that iterates through the collection. - - - A that can be used to iterate through the collection. - - - - - - Returns an enumerator that iterates through a collection. - - - An object that can be used to iterate through the collection. - - - - - - Gets the track ID. - - - The track ID. - - - - - Gets the global queue. - - - The global queue. - - - - - Gets the offset. - - - The offset. - - - - - Gets the capacity. - - - The capacity. - - - - - Gets the number of elements contained in the . - - The number of elements contained in the . - - - - Gets or sets the position. - - - The position. - - - - - Gets or sets the element at the specified index. - - The index. - - - - - - Gets a value indicating whether the is read-only. - - true if the is read-only; otherwise, false. - - - - SendingQueueSourceCreator - - - - - ISmartPoolSourceCreator - - - - - - Creates the specified size. - - The size. - The pool items. - - - - - Initializes a new instance of the class. - - Size of the sending queue. - - - - Creates the specified size. - - The size. - The pool items. - - - - - The pool information class - - - - - Gets the min size of the pool. - - - The min size of the pool. - - - - - Gets the max size of the pool. - - - The max size of the pool. - - - - - Gets the avialable items count. - - - The avialable items count. - - - - - Gets the total items count, include items in the pool and outside the pool. - - - The total items count. - - - - - The basic interface of smart pool - - - - - - Initializes the specified min pool size. - - The min size of the pool. - The max size of the pool. - The source creator. - - - - - Pushes the specified item into the pool. - - The item. - - - - Tries to get one item from the pool. - - The item. - - - - - ISmartPoolSource - - - - - Gets the count. - - - The count. - - - - - SmartPoolSource - - - - - Initializes a new instance of the class. - - The source. - The items count. - - - - Gets the source. - - - The source. - - - - - Gets the count. - - - The count. - - - - - The smart pool - - - - - - Initializes the specified min and max pool size. - - The min size of the pool. - The max size of the pool. - The source creator. - - - - Pushes the specified item into the pool. - - The item. - - - - Tries to get one item from the pool. - - The item. - - - - - - Gets the size of the min pool. - - - The size of the min pool. - - - - - Gets the size of the max pool. - - - The size of the max pool. - - - - - Gets the avialable items count. - - - The avialable items count. - - - - - Gets the total items count, include items in the pool and outside the pool. - - - The total items count. - - - - - Socket extension class - - - - - Close the socket safely. - - The socket. - - - - Sends the data. - - The client. - The data. - - - - Sends the data. - - The client. - The data. - The offset. - The length. - - - - String extension class - - - String extension - - - - - Convert string to int32. - - The source. - - - - - Convert string to int32. - - The source. - The default value. - - - - - Convert string to long. - - The source. - - - - - Convert string to long. - - The source. - The default value. - - - - - Convert string to short. - - The source. - - - - - Convert string to short. - - The source. - The default value. - - - - - Convert string to decimal. - - The source. - - - - - Convert string to decimal. - - The source. - The default value. - - - - - Convert string to date time. - - The source. - - - - - Convert string to date time. - - The source. - The default value. - - - - - Convert string to boolean. - - The source. - - - - - Convert string tp boolean. - - The source. - if set to true [default value]. - - - - - Tries parse string to enum. - - the enum type - The value. - if set to true [ignore case]. - The enum value. - - - - - Thread pool extension class - - - - - Resets the thread pool. - - The max working threads. - The max completion port threads. - The min working threads. - The min completion port threads. - - - - diff --git a/cb-tools/SuperWebSocket/SuperSocket.Facility.XML b/cb-tools/SuperWebSocket/SuperSocket.Facility.XML deleted file mode 100644 index 77c6fc99..00000000 --- a/cb-tools/SuperWebSocket/SuperSocket.Facility.XML +++ /dev/null @@ -1,467 +0,0 @@ - - - - SuperSocket.Facility - - - - - Flash policy AppServer - - - - - PolicyServer base class - - - - - Initializes a new instance of the class. - - - - - Setups the specified root config. - - The root config. - The config. - - - - - Setups the policy response. - - The policy file data. - - - - - Gets the policy file response. - - The client end point. - - - - - Processes the request. - - The session. - The data. - - - - Gets the policy response. - - - - - Initializes a new instance of the class. - - - - - Setups the policy response. - - The policy file data. - - - - - PolicyReceiveFilter - - - - - FixedSizeReceiveFilter - - The type of the request info. - - - - Null RequestInfo - - - - - Initializes a new instance of the class. - - The size. - - - - Filters the specified session. - - The read buffer. - The offset. - The length. - if set to true [to be copied]. - The rest. - - - - - Filters the buffer after the server receive the enough size of data. - - The buffer. - The offset. - The length. - if set to true [to be copied]. - - - - - Resets this instance. - - - - - Gets the size of the fixed size Receive filter. - - - - - Gets the size of the rest buffer. - - - The size of the rest buffer. - - - - - Gets the next Receive filter. - - - - - Gets the offset delta. - - - - - Gets the filter state. - - - The filter state. - - - - - Initializes a new instance of the class. - - The size. - - - - Filters the buffer after the server receive the enough size of data. - - The buffer. - The offset. - The length. - if set to true [to be copied]. - - - - - Initializes a new instance of the class. - - Size of the fix request. - - - - Creates the filter. - - The app server. - The app session. - The remote end point. - - - - - Gets the size of the fix request. - - - The size of the fix request. - - - - - PolicySession - - - - - Silverlight policy AppServer - - - - - Initializes a new instance of the class. - - - - - Processes the request. - - The session. - The data. - - - - ReceiveFilter for the protocol that each request has bengin and end mark - - The type of the request info. - - - - Null request info - - - - - Initializes a new instance of the class. - - The begin mark. - The end mark. - - - - Filters the specified session. - - The read buffer. - The offset. - The length. - if set to true [to be copied]. - The rest. - - - - - Processes the matched request. - - The read buffer. - The offset. - The length. - - - - - Resets this instance. - - - - - This Receive filter is designed for this kind protocol: - each request has fixed count part which splited by a char(byte) - for instance, request is defined like this "#12122#23343#4545456565#343435446#", - because this request is splited into many parts by 5 '#', we can create a Receive filter by CountSpliterRequestFilter((byte)'#', 5) - - The type of the request info. - - - - Null request info instance - - - - - Initializes a new instance of the class. - - The spliter. - The spliter count. - - - - Filters the specified session. - - The read buffer. - The offset. - The length. - if set to true [to be copied]. - The rest. - - - - - Processes the matched request. - - The read buffer. - The offset. - The length. - - - - - Resets this instance. - - - - - Gets the size of the rest buffer. - - - The size of the rest buffer. - - - - - Gets the next Receive filter. - - - - - Gets the offset delta relative original receiving offset which will be used for next round receiving. - - - - - Gets the filter state. - - - The filter state. - - - - - This Receive filter is designed for this kind protocol: - each request has fixed count part which splited by a char(byte) - for instance, request is defined like this "#12122#23343#4545456565#343435446#", - because this request is splited into many parts by 5 '#', we can create a Receive filter by CountSpliterRequestFilter((byte)'#', 5) - - - - - Initializes a new instance of the class. - - The spliter. - The spliter count. - - - - Initializes a new instance of the class. - - The spliter. - The spliter count. - The encoding. - - - - Initializes a new instance of the class. - - The spliter. - The spliter count. - The encoding. - Index of the key. - - - - Processes the matched request. - - The read buffer. - The offset. - The length. - - - - - ReceiveFilterFactory for CountSpliterReceiveFilter - - The type of the Receive filter. - The type of the request info. - - - - Creates the filter. - - The app server. - The app session. - The remote end point. - - - - - ReceiveFilterFactory for CountSpliterReceiveFilter - - The type of the Receive filter. - - - - receiveFilterFactory for CountSpliterRequestFilter - - - - - Initializes a new instance of the class. - - The spliter. - The count. - - - - Creates the filter. - - The app server. - The app session. - The remote end point. - - - - - FixedHeaderReceiveFilter, - it is the Receive filter base for the protocol which define fixed length header and the header contains the request body length, - you can implement your own Receive filter for this kind protocol easily by inheriting this class - - The type of the request info. - - - - Initializes a new instance of the class. - - Size of the header. - - - - Filters the specified session. - - The read buffer. - The offset. - The length. - if set to true [to be copied]. - The rest. - - - - - Processes the fix size request. - - The buffer. - The offset. - The length. - if set to true [to be copied]. - - - - - Gets the body length from header. - - The header. - The offset. - The length. - - - - - Resolves the request data. - - The header. - The body buffer. - The offset. - The length. - - - - - Resets this instance. - - - - diff --git a/cb-tools/SuperWebSocket/SuperSocket.Facility.dll b/cb-tools/SuperWebSocket/SuperSocket.Facility.dll deleted file mode 100644 index 159a9d98bce46b10ae85dc37587dec48fe8f7d6b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15872 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 diff --git a/cb-tools/SuperWebSocket/SuperSocket.Facility.pdb b/cb-tools/SuperWebSocket/SuperSocket.Facility.pdb deleted file mode 100644 index 5193d9703c0adaefe65af1d14a7bdfc528691e89..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 42496 zcmeI53wT{smB-I*!)-$wZ2F=vAT8;OmNZFUDFV%_p@kMoQ=mYAhUBJ=O_Pv>(h9=G z5iJT@MnK`CBT5~M!tfCJ90e_khyyALA~5O)@)%Sc3gS121?KnPkDGIEZ_=AI3ZdEU z%DQLowO?zmz4qGc>~qeF#=5qa_Lhc@yoxy$vu5S3sIJOeSWsA)bMS=9>VPoF{15c| zo`@oX;1r7n{(b;BuAYRjeq{Y_F1+@ zvTKS{qY`NYKl%>`(#?OZhGG4$YxO7<_n%%4w6rS@42*wQoH;P!-#q`+#Saa6Ja=1l zuGp~H*(y70@=&Cj8cOx@5lpgV=qwczK)Z+&R_xgV?`)d3z{=qwi zw_ki?%ek*Vf9>zCzw5{SpECzW5Bh5SzcOptmmWR%`6X`r57r*h&q$4WFl>YhN{?C~MqX&I8{)=CE@{mWqeEFPp*K9c@`1NQ1c3^O? z|M#-5#=mjUukVg6tFBpJHTj1#cTRk;|8wTR>49$KaLgwd49M?)5D*2107HRn;9y`F zFdWDM4go#{$p4oMj0AMUXf!Ye7z>O8#sd?8iNGY_FyL_D2p|ub3`_xz1oDBYz%*bw zFawwg;Cc*Z0keUlfdXI-Py`eMbAfrld|&~v5YRpCMZhtDoRP-@#{tI!i-A($P@oVP z2tjay2ASZoy&CxYLFd3bufFrD=Koqv`1)gNPKvd)ZK*kI++AZPhJx z8)6+bn~DnN*HqQkH8wSNY^hn<*3!{Z*V0r|)wnrUzcf}`A8T6`tBW;mid8i>b;Q~V z>e@|-8%&88`4ZbzVtc5>247;UFR?)-t~VvN*%BKPCHAU`6)hJwcT~61?vystn~s*Y zEov$S_q?TbD!APioYYi06)L#rOzpD8ecx19V$a)H9$VMgyrj8)MQz)Ly+*!Q>^xs3 z#+vIlQeqN2d)&sBrpCH0)v>lsv9_A(#wJixzIz!y6Nz>Z>;%J&?_L!Sqff$uk-T2X15;J?;JYSsAN9`1s z#Ka!AF3i2ErrP%PmW5VId)&D25I3ka%Rnoi`_;BSYgTHDWY2}e4ldqy5!YgPJ}=`l zCA@)AT8u#&2P#A9Z?R}Cm(dyu|Hie}vTC4;eA|K7i94Ohok}3AvdH}X8D$=-buScT zyB1Y2e;@Ffi3$!ng&98hV7i+g*`LD57*;S3FUbTkbN*C`n zHZi~ljf=n^U_C>3-Fs2IeVOpXF!U4ts z#E|{;QQE=>FT1{L-|qu^_2?tW6jKL68MWHm56)G-n*q`K^n-)^+|#PJw0Fcdvd2&S zE~sc}YKql$G`2Li7n~4ljQv)=w7y@u6>? z`Q)y(LS|DdPrD7IBU>C=u?sGYwRgauebi5RS%7>a;+N`ro!{~;Sl&bopSU!BmVEsn;5)fo z%Mb02XX7==#>=97@>STjM2wGxOXHjIzKX-3A4(i`#pQ>g?eQFH%fY*%GTtCg>Cy=b z)MJA9BOl6gdujfPPk#JIIKC*a4<+>HTn5=BrHuc>5Q)~OhsQrWzJ4snnlbO(Q6d-{ zH?}n6ACfGD;_ZgBytX}tFR0730>1@STYD=fsKUME#Pg%l@o%H!i|WCr6m9VQup3=7 zA4Wqn0(#NU)l$WS(^NNRpp2)l8LwSyObL-oxgrN1s2YhhR$6Y%D zH@8;QcGRtp)%z*dt2&QybB1E$jhZWrkP1>7$p6t6Y6y zTk&}6D-P8c>(IQ)L~l$UEWP3R==Fx3QxP-2)CIBC>ka1p<c!yGfWv{=%)=u0Tj`1~2K4)E@{|M9UA(71I&>|%wZL3p25<&Y z04Tl%u<;u2Ccw^*NMhb-eCdY?_|f9Jz7RYSd=dBvFg!Qo_P?*b6PK_1qg+e3Ujoiy zXa7zM*@c@txc*+VFti>s4u}bU?;E(cmdc0hEcY3R8wh>J z0ga>AYZ?ayKgRlvPKYi<_ze5q`k$Fzv-Z$j@@swYw%21SlrYjF^A~*+UGD3sXp7Z$ zI2SUhnbr055b01~m*iVAYulY(W~Iv{v(jaffAqJJ*~Q>&GfyTXv-t_#nh%X_VoRVtm;r=u?8$y=educ05UQ+iI-tKwX@lJB$1F{jl+VidpyGOh%C9mGC zpuC=bg0m3^hit_9+KyV4-BTM)W2iiu7p(nmywcki%za~9j5qUOd_w-E>uuX2rkz|S z?MbFWuQ@mktT{2=%!fJ5p-Ny`VqVDqp)dk?kUl>J>;^`|r-AU#=YZyav9qJKhf$EN z;RE;lquYr$N$X-A=gF;3qSpbB@>}$tCaeQK2zx;3Z3XSKeL&8mJ3LQ%u_lVAnvczn z?~|PDhu1{br0Ctgj`VxugjOtAmUN$ES#x7YV{Mb}R}^&qrtz7rrC%`n5#L5{8_Dp?rOQt(l}rOHjXjfO*lg!u6eu11(7`G9CZq zIvf1H-qjiIBk~%p@pbM~MlH+B>T6qdI6y41F_p0c~qw*IsdTNY;G7E{$&SbrjSY zM{H(1ORhsUKt5pgb*Eoaa_Vhat^K1onI&4S`&QRQ5;{=gE;~YDDzvhTrh{ci&j4p* ziRW-FFj;3n`X;48t<(x>^RFS$V* z3TiTt4;%#)0uz7*!13mKOmHc9Mq+<$=YM)0Qy)B@nV_|EHXai$Mn_skv|QVUnB>Qa(L{lYo#1P(Tfm9$zxade|&n__Up<1 z@1J%5$FHAXG@Y?$P+gaGzBPKO?`L(aT2;kykE2YO+WuexcN5H<^vdJu!g}Bki_d|$Dc0?+w90h^!uK7DRUHR0=JYyfS( z#JW{ri*9%8myKV&o_kgGjm_%{R@b&~Xiv(MNP{o7oiy1tB#BP^ls`eu>IvFnmrqGbh_Hk36>l%H!$0?Z$W|WTMTTv(r9GWhJM1 z?s?hFL{`4LN$aK`x7Srapb+7o*I63ILKADAQ#AXbBN1aYY#KNl+b!FjTTmH$04v)O zBj?J>zAMkVxXG#b1xVaqg;W@Rg4{8i(svO5Za)ZETCe|*_ln?$y~$l z_!O|t%*+8_3}!ss*_lQ3w~7AC-#3omI!9At>U;wl;!~fwjOkU?=b}@O}zR&xHM7?-~}?G4LjO18vs0dH+vldc^8$#?R<$zsGpk)iKOr zA;v!XwLYw4wrOm#e}_34y}NgH7$3dwhhKO6{pzqjET)cM_&U0|-(N`ko&}WG(@XbB z-jQ9YA3|18QJr6yUr6A}>&?z|&b77FZxN-^)a7tmZChKd6771PiOx(OFX=ngYsWtQ zocvGD3TTgUKXWG=ZRYnE%4f$jqI+c~gy-^)$cMiz_=(BaIhR)xXUC}1XT?-9dvF!< zOY&*%{fd0DRh|UPclB#9{;S|AQ{GX`Rh>5zOi#2)`rq=pQ%{8T{|n@mE`Jd`3;Yr| zhm(mp+?CM$_w!!QrYPhA&ob6;0P+vy0VTk4pdQ!?^oNi|BmLYQ;rgcj`;doBKgZyi zq?G-?=CWvgdf5I?XUlhGi+5+^XS&~)ocCr=FZoK{(|dmC9)rTs4(s=1_@Vm_h99AO z-a0$63p&yI^l8jX<)IEYr-Pt9R@+v$-aR$z?m~-B<#kE7Jl4FfV?C?1)e&L3*0$It zcb3}trz6OmVA=+q1xBS|_a(UDy$wpEh>T)gjRT|v` zO%feX@UEnI(_W@WQd(ZEkd_i(zi6%*&(GuLnS z-hCe9XY<%S&Kfa>FwG+K_ef|KQ7P;&XU8+YXKZz7o~uJTR65Phk52!g__Y*Jz!oz0 ziTs5lxtc>0A!Qe=R|Zr%*t9MA*03QsV80mJsj5<%I?%S z$nKON%I?HZY0h)c2Fvc0&MpP71eb%)09Sy|1y_M%;FG}EU&g*{1)t0{c9fp!i(d*p z4ZID!1}wYvEbzVHv%#zp0cFL12-ZEnAA!&3`p4ipFl&La@zCkU#(N2jZ5RI&7+HzG z3T^@a1x(%XAq>t1;E~`q@Obbhu+~=E6kiBt{fnOrmTgx9z67j$DYP|yG59j@jo{0{ zx3j8d1EBJZvXTW~|Uk!c>{8{ij;Lm{v!}HIBhk&mIPXu2Fo(6^& z@f>Dn4x_7ktGSFoF5Jq+TRetS-Q&4mKAzFine#D7qQB1N?s_g<%w>l#2JR?5SKKG$ zqO~o^zuXGsk*;&-Gsu4?c!i^nkv0oB577DZ)!=-v>|25C{7SCXmKpq38K(kj&*5B) zR&8Ad*!#+oX*)k6iFp&2>7DdfHutSy(jMPDIYpEvCgNibE+bo-q6T-q}44Q zwJ15|ADogNUgxw%YfmG8tM&|=T)VWcYh1$RN+}1m-L5`|Or0~RuQ^;_ug|n4*Xi%s)QIT>?InDeNu8~}&iyzy z=KJSu$z86lLwrSG*7xXTzOEjf8%rKDUq`-#oFLunLn(D!?d#~#xv>wMa(q8z%Pa-# zTu=W@1+;OhGQKnI3y~M|Oa=pgXe-v!Slh&2d%--7(0eERx<1Yu;5$;9BrA;5zVgV3qqk zcpcZTfjJFlul;S$ zqRoI#w8g-Az+u2d@`+0-U+ZmN!Z&B(>~kI zpY&(WWJ3*s<|?q}!qs5;7IUz5yj`m@?7ZmoJ81e80t&LPBnQ`-yxzW2Uz5+DS-J1L z$0=D-UlfqDF3+F&J$?yvGq5#|1y%!1z$bv6z%JkgU=J`Zo3;U!Kn>6V^p~J-`b@~4 z^V+j8M`yN|fFm^i&-@l%;?|lg88lr_=8y1KUHk=(ev7--hE3_?-Ssk8&bNGihcARj zqMPaHzwF`{Ih^D0a}2cTu5slRIeZ-oLGkCf`hUcQu45kaTh|`0aP>@e?V0MzJ04a8 zKjyBladc0+_*-2&Z+F*kyX%7-{W-3@w|#y`_X`*QTi32D9X`(GuXXk0chW9a^H68V_f|&y7Jhg8NS@==*PSKxB7UO|7dru z_qyDoD1-gfO;LqPl>@+#;V`P2|0T@HSSQj^WKviWp(&-~-1 zCdDS0bm~nhU(GgcH}yx@#-ytsnsE8HMCA-h^~bc2%2oeffk>sRe3PE0{JrvY9Z{OD zl0kX44`N}uHux&LO7FV5y;C9ql^IA)Q+#U3OO5FES~imQm-&F!6|Ff-0qt$q0+{9I zndDD^w*fZ+I|2F1fIETvfQJFu=+6SL0B-_%H;H6q5-<-a1@!)*HGupy z^5MwVz828C6CMN}1AYU%3>Y6IGA=*QI6&u}3xQ*SwZLXT=bpC#x-YF~QSJd`Z$1h< z1?&d)0HYbO%9KGH8B8^M7De;s2lHIP*kEQTu5f{FK@`QbPqDPG7}}H@zQp-_Hr?fG z&^^=6I)mrM9|SsItOwlnX2 zCNBnf>PsR*^;#O2_x#R2&4#bT^@0hUguWgd=V;+uxc|e_^naM|{{r31vAkMO-NS<9 zLu0;to94>L$PJgDm8SeGSAG$+o?oSsP53o}db$eUHs~sSAin=|z`W=vp=fh-4PquYFfj)74eqsblh(_k+{u7#r{CnZJI{Naxyqr##FDKYU%5#^x*)*4Dey7fvfn7B?MgQ42 zM~hDLI{hj`>zojw_Skq!KR0E)7?dJ6lCeQfpB0Amzt)^VLHY+bd><^0t)~$E?{%%p zJh%&8YtvlYa4v*(?LleUc97S#$iFXN^XQ;1%C~8bSImQO`H?i`M||HyZ5fRmM{M5{ za%kgRU*~nop==CCyVs#O&nHXc@-o(5hPAFmg7k;md|pfA@-p@XPKIwbdG~!t&6i{8 zT{)~<-u95}M*KSO&bTpriGJI3mk*s4=4W;qer7v<7Kh|c>uV~ljkD#>WnTMoA5BAR z;~XvX*z-^Krm}t8yU@9o#^q&h`g$aXL(;T=h;Kh_^7BkK(vUQI&BoiZi)oiH`%PqG zNXi_xagH`5Yih^PH0>Db+kve4cI+{azY$IFGKad|`bOgxdd zYyE0zTwY|u&rjLdLxc2s&!)LN=o`W~fVp9f_d&^j?vC7n7Q%XC1uHT(akb z^641{UtemP-n;1A$+D9O%BOcL`u?S+Jx7}LtZF~h8I*50jq_(gQqy!lbebbiO_R@D zeiQ}YCjC~K@_+wD7lhO_`DcBc$D&g{`D1CS38`uFzxw{Arpd4B`KdHs>|fCNnw`)q zstf$`5PGG5uxR0e%F3dJ=mQ1&Rq*h4!EL<#J2CODqc{im66v&_wV}(;5zvN;1q&9Imz7s#(uTu=aZU>I z7?VC~L$Ii_qPVQMVu@|RzT|R3Fv-coS~B!e8=PdWEe=YSRF=&xsbHxvp?ldJ8$99Y zGM7y+7hR5tpCyH5#bp%>t61m=-M3?m@gIq9caLSGtM7jx8dVh~6$?w|sRc)Wr$DY^ z6Gl25MoLqB_*oO5t~8xFPf{Q*QE5739@cZ=d^%I^^_`CwR=#8TqxGTg}yDRX*z%8`I4IUAZed7wI%aFW$GOhUDc;|O8Bxo zRx!#~ahe>Ad1y+IT{VMsVmNElY~rtVx{IIKH1?ZL+|AsjYlZG* zYf|F*aMhXFeA{~+vJbWd#Y^TcT2eB%GJ|@@2RErxgf9X;uXnFcGz1G+`YNj`+11$4 zeZR*AyWyY0Z5j95&+YUduwX{bT~JiT+odzD>f>bD{cWTs^62dWULG7ioHDs&8jF-ymjXj;Slc z7}`D6syAKYs@FGN+?L*C-@?o?){l9YLO?pUYAK#&Ee{%iOJ;2X6 z_$u*&)j*HFsT7);`}nd>&%Y?t)l$;q&zblyx9n?;>~X>2-ls8;dt9DxX!;4?+Fm^C^qR6I6W7eE=Re?P4mOirOmAp1OLr5H-(u&E@;BqFbXB)fBV$; zCBC+xD02DC9jB@<+qYNEUtjt3k~_xSGWNQxQKP=kx<7}P^K3Au?dLStZj7}DbK2YL z=Gc$3o!`V~+-yVx54yI`>D5QS8oK$~o2hI+%C5`c`)vQPIwULmTX?r$MC)zgMoZWa zMb&ikA+x?HzSY##7XFG~L+7XQ{y{bL1>=oWrv2tuUo<-H2fO;BV9Reiwqk=!lhJ^t z_q)Tbp)!Tv-VBn3s(bxdKjtWvOgxVMVPM!#zG9)7V+nF6Z9pA}_U{y?l4M@XT>L#C zQ)?vYBfyJ^_vTL7HN~oWAiQ5Wa3l@O1M2V%X`f!lNL!Bqf`e4(Z2TJHGl49IdIq#Q z_i`JO(*eGU{JW7hoxRw~bt$-ma^(;EHZ*Ja{RUE~edbk29nm_Mt8Yb^CtW0fCji>< zEdi>43ScR)3^);>2qWaOwY5Jz6c`7L2PEiTm?e}FSP8ELT|zB^l~5bs19$>35tsyU zMwI(bcK?zGo(xO@#It;0DliS04yfNVfun#~fckSZPyoyUx2 s@$t|c2XL-ACdq4Z-}%1x`v3pCuK#^5a{AoooI16g zs;;hH7&qezVF@8@{QdTu5Rc+WzoAV3m@I)hwe99qu`&8;;iIzr)xxQ#S5_Cyukuc- zI_a!}lTVsA&s$J1drm>sIr9oC=M{{YFs0xu@02-ZDJiiI#`VOJLX=BOEa`IlBtN&0 zg(^&vNkW`1g@|bBH_O2nfWH+-A<~&wy4)mih|uq2=pnzOR-#_BE+SL<*K{pu+jQBn zV?fK@%hv6G`^s_iYBzNJ=Hz!Oo`3e4r(Zl_%ECcQdz?{p>dCbOQ_i}xX3B^?0}qt! z*-|Q&zcqHlYx|aMJY&vtW9Jl(n^kg__1UG@{AIzN5AHeU^nwYSe|_`QD_)qe@0k4O zt*0&eqn?;s;?*^#p5)F+wd*K* zQ_+xDsB3!eA!?ehFp7m;)J|D-%s_yK(*kB7Kn_~K3 zLT)Ox8LUITH8W_q4%EinPlyAW7ZD$5Y&+(Zq&Ui|?g+itiHxW(yTAr11Dm9<<#vX0 z0Xnc%vRXtbBViY%FOE6ko*(%!aHw>U9X?$jWR1izw>=d+C?EVI?{Gr-iecoHfCS}( zHn0iGhoGYJQA(5#jd^hSexHuZcer$Z$e~F~DRb&GW*{i@1Qq7WHU9gl|0P zrLM58?gkRGOVVr?omyNHc5JT)L}~IsHrk+-*Au|)1(Fo@Ey8T!XjrD4`rc5N#%*B?Fh~+dF6uaoRVs`*!6bA$8NQGu*fN}>xfT%gb=n*zLs+jR>J5QI}QZycm zG5L5%=C~E6eoBl${h~qrGDL=ShmvE*Jqk7|?2f}BLuFX*FcL>xoQxb5btllF?I0H? zMMq`3$KXJvOz}&>3oMEf-1C({)kmUTmyn3%Gz%2FNkmRyKSPWkI?~*r{Sd0hRIL4+ z0aQJb{E&#{E;^u?CXr%y7POW(6qL$C$0k37>hpa+X98(IBw~4jpV`n_9>ReA(6Px6 zp?VBGU7oo>+7F3Xp5W&cXf1CzD3ynfO@0W~7x;e80@8j+#PS3`r$TFaXg>Bs$0k37 z>I;28^MJG;60tnN&*{)o>}Es9Ba9gca3a4< z7a)REokK=BoH(b*_tKHAa;9@yokZ1=yB?2bQ%h1Jlz%e3EaawT0p=_;B<3vkhKgl& zQ@r#=1H`kd>&!`*3P_1uv=7gG?ffQW2JYMY0%rWF4vG%Gf*A;KF4V2h3Vqk9pWT;;e+b4Hm5{dbrcw$tJM{~uXIk^d ztLwRXRvj5SY)+GAtR#seqIw$mh041a4zR3ROpA?UA#yQG78fJPLh2HJ%Orw!4xWD*}zeNv#l;1H=(ZFhEH(5wM)dh{D0v)iHTPbUAp z`g10St-sM8rv6%c*!uhGrux?>)?cep{U&|0>f_AjS0C|r==zh*f3NZ)9zgK_G|u$W7PXEcXcTE&V6`^F&;OQdEP zOPv@N16(#-GpE`f7A*;x;Y^?CeSUL@l|rJnieN%!)xX0F2sg{@9Fhbzt0B}>vvZrn z^P0o+2_|HQwRAIFOSoBPIE5x;R!68Qv-;+6adWtYU_xdMzrz<0ZkE}FBnfJU({^HJ z7d3~=n#1J;6EefexfxzTxLIZ^Ns_N>`%|RR#TqjZ4Dbca;GCR^Yla6T_9to0#;XX+ zy#%RCB3NUzq|1`}Re)`eE6cUqOTkqk0x+;I1M6N6k}lmVaEN6mId5P&s@&B;v_lf3 zRY)v3QWB2bL5ies=~OoLu7cchuLezty4T=<%%VE8CW9*U*L*fQjHzF~rX<=h(~jHP zYgB>bIyBk1*CIdfIwIGDWQO7)t-JxK3N4AcQMEYdL%E8FexFUcpAD8{t*FJ~p{BzB zKazo8^&YAUT{7QoOS$TGYz6u>jr!7ZTSKbcJkZh%<>um8eIwOuEl8)L>YH%DrWz}} zn*ki}7SN<{)xGfP-3lI^7YRqabznP%-Su=R>S4LJfvNsh&vdjOphJuqgxDZozzhUO z_yT4i80-s}fnbO)UYIJ6bubh?XQ}NR7kAAs6~$7SokQ4$4BqbORm7nxa zCJU6oyC+cM1VTpMeWdcip9sL?pXo>gpBc;zWUA-$`xEW|LSUs)z?50YTKav#DGt+Nq_I&=gX*>$Ysc5%e>3XOt)siEwts+LT=O(0Yr4P9u9 zlfv19ke5)kk3y+x9|J8LXxW;pAchnb8i%I4o5`#r+l8jrkvT3N=}<>@#V7qv$0}WF z??m*Zw*x)dqm*`rr<*+V0q69`$yv7cna%F%pMa3*i#E<3^8eF_dz`kjDD`ru^V)2ti@ zrIS7Q@7#RU1lfZzF3^<3J6*eLcaF)^dj?W<*qruK%KxI+#&gAgS!~2zLb3g1rr0Zk zoSZ){Q}M}%DG*mMyL+Zq`>pD2`J)OJXE(K@E|4yca-T)A)X><{DEgjbD8uh?NGo)w zM~lwzBZK=p+k1{ObGL$G_F*3;IYvL9Uo&ycK>Q=pylpVUu4ZT^TMldt} z{1Z$vbyG+mK=*i>>1L=}5|_v#1k7wmwAF*Eb~40S?#svkcZ8IC9CSJED}Z&R(R%k) zD%1tWN3uTN(Hpa3k@lu}l;8ubZ9nNA_U@@5H0Pi&v)tyTzklA!UGiD&D@CD5Hx$AT857f`--)d~3;67E= z7j*vgz&me@p`o-Ip1rdPyXSzi8BVjm`DWoa(8LL5VfS3$EICruSsHJVH_+J$W)b&% z-z+6sb(C)weWP_Vv#5)kLpoDh&s3phbm`*u8||7|Vzq?ZN5&KjqpB5wY3PkmbJLK! z7^X#e^)skp^3@VP=GD(MkdJxw#~H}Sy!zt}`8e2gY}-@Z%P1%G z!7S2Kj4%ViNxpy?2xj{NW*|7(7cc|CDZYRi2KQS}#_Kq)L z#gYkH{kAK4v=O zY`>Y9!FdjM9-PZ6v%5z%n|(tO&r3y}G`4OiI#s>V8$+MRLtPcpV@9jSAqVyKy3u+P zTI^M>!r3f~=F6-})gL2QEC$Go?`C;BsTjK5h;=^!DappEt+|PZ^_ib;G@b4Lj^b6_ zfq-^*L4*5Ym}p|9DXMB4$uJO{Y|NhQgzbNVeaYhAOWX{sR7y_&y|{h>%Ez}-o<_3j zPvLqAiCFGtB8`g*6}!8kNRGHI$Vu3Vc%K39$U`BXQeu9z`g1_OZ<(DL{%|>F6^Bhr zlN=XwN{s7Ny$4*T?Ra0(F;YAtBjSEVY$$HWv7VxKb+eIy_YI^wI%YbmD$5e{;~@`A z_89M##+_za?Pv=h|Aeyip&h9ikptOBpdY+(5Nx;N_N1Jmc(_QqD2}*6jQ?EZrSTSj zSD8Ihb|hYj4sy~tGL=>+7HP5C^4YLxcNj#VwhsO*@OA>1EO1ubr&ym6=*t2|8V&JwcTfhTI5 zu)7y#+u5!phYz%AxQShb_XYI{}!uS0E#9 z16vfp4s&GiY)RciuvR&jPSJPf?DbCFMoORAEhLVDEvC@8rE1B_`w^vz@rgc(X|j{u zy@i$Sct1grWZ?-f?_c1Oiuap)6z0hFMDQN-hLRU z+GO0M^j?5ja5Izo?5`+8OOzoi)2?2EJCwA?rs~Kx9@0s0PIvSPeo`|L26(I_Em0IJ zZr!vwn5f_lz?kEU8sV|}XC%k&p3dWWNX#;##HqRm4Vx6M{x>X=BiY_BI7Yl*LEU^z z&T+dGxgKyD=zi*Nkn+xATbj1f_{Av^50AFRqCo>7{-2`;p%oZ25N!1Y%s{Zs7cc`s zqc30vg6Dk!GZ4Js3z&i6MPI-S1TXmlW+1>xQkR1n2ww38%s_x^JZ-^@--l4(IX%6I zWCj8}*TyU4kgs3{3f!01mds!^h=&B&5;x(sf*DvweE~BN;5h+p!3+erv8@HnK!Ctt zUn%C8>KiZvgEU{j%y_jZ9RhtVZUXX*gJn1|$0;3Rk)9vO!ZC%$P>z=Yam;ZGsdqyl z82`4wvRUMjzBUtTFAF3y)NdK286md?r%(r+Y%tY1WY7v@+kphzwz2A#BtgVOLi@Qf z<+X#~(qcvX%`yGNCAN$RRBc2cIv)0ZW8U3Jk)3IOI2gG&8IqpYbPk;;Vjq+U?JL6l zKSi~|v?ML=|30x^8#vB%`eE#gxZ~vmC0h}1J;s997NDeCVwqZ*GR=j1ZR8eE!8VwJ z-2pF=bCR&yOo^739xCPghfcWz&y#W0X(lddZL;YX!Xlc>64ea=|BfkL)GI{JDDqg0 zz5&C2`|Y_6kory_yi~+xf2`UC zSwbR~*LU%iMWieyC5c$>mVl#&M2c}M#`em9ysjXbc0UB9h}#}^s4+M7#~>6DuRDO> z^1TyUo_pN1r*3?*qMxmju$gJ?lFqi<5%FEp7B`64rK-lpcdU+p7TMl# z;zRC{ICRQEW;8dCLJQy$iSCJwfV@=UO5buv`mAnJcNB2lXb>Jr)nmZXn(rvzOa}z{ zq_K;2r@)y#u8qf%vE?0&gI5lcT--L|jRTY9ggM~Ds+L?=k0*->AQ29}()V`6A|;4p zGMxN|n3vtz$veWzJqD&lks>O$5VJ+j@N-(?hi=8s-mL)L$P~S>7pdjK#zNKA z&`t_XTreHkCpq2%O79dGV_|{Kew=Wd%9E06MltLl8}IxYOGsz$HI>M}v}vb6dsYC7Blh*>3BmfHiKN^sML3@i_w4o|rFCs!6< zo}!(Iz2f=K_vbNJ})gnwiOW++rmdy;N z{!gh$4G<=UQK7JV0qMIa_d*;?3KPBh)8C)rRZo8@J38-ZpPXpp&s@ndS`^TRL3YXx zl~WX@@u%ml$zsGcdfuYFv?|EyXO(s~dA3RP=jS;Q{y5L9BFo$6w?auTi?3ewB>RUB zu1j#>UWvJa+cqR{Vh|Y6aopOb6Cw_D8KdWMI;j`eEhSt_H0yxOJ|L5J)cJ~oZ^|fG<==}XRYLOMtYGw6Gu-+O7!jw6&A>O+L zCdEBWQVywRA>=oFB}%4img#(W8x~r6%e6li7>4D&*#1}@qX?KHic1dZXI-KnOqC|F zw+h9`4EK8r-4G9ZmjYo#qvqzsiFo2b#7z%6rW6m<+8n&7!f}7dYqa0TJ&OK0GKiMo z8t5{(X+B!~s?JMJsOqDz*9}aQm5O)>0Nun94}In|HF4aIc<3?R#&J8!?Te)@H883) z7Nd1Qp}rmpV+|bEYZ2X6rR@$C?7t-xWJeFIkuHa;I*QAjOlL=7W+*XHiUzp-uz^M! z3~=FC6kJQuRpoB{J%c9q*VTah`X+8Hh%o*Nfhk6DsZL8_OzRx({NC0x&>?ADuP0jO z@_HiXG306uC#J=p?LJ`y+XeT%MVcm zObT?)rF{&r=9<*X5EL%466F+S;Yq2EwHVTPb`LXBY(W9?s3V-9K+zKw55Y{R# z?J9B`u8zZ1rj1j0@o|xO=!Q%u?qEQn>F9t+bxH>3n7G#sKS)QP_EPl$nqOZQ;LML% zBF4KF?n&U>mFVB%lz1p-I_+vkF01TuQ?#^;Ul!aKEN&5rhi}M?#3RV71!akg*-VUE zvfULeoqOnN-He);v&K%e3O$h23L;&+e$9$EKTXVU)-wA23NBe{nzT(r-Gbz4c=!_s zBe@l9zkASHhb{uOtnMbX>^guQnVHcv8IPLe>q&H&v{d|qGfS+Hzlp(P&HYa+q!uN^ zvx^f49&Oa;{|z#2lgy;~2G0`eAoXsCo*v6dgqVefl9*!L=KX!FIT42>qzK0w&_gwH zygMMn8>L<~dbc!jkEA3=m$YgX8r=$eRWR^2qgS(w_~mHrz`GOadpxMGXKk?wO^lT& z)_(d%$Og(0>$G$nIZliCF+*G(@$C}FyXzo>3_O%&n*DAlt&ZMOnph4v_W)Jj3!=w3 zVzQNY5vlk>(%Vb1(>^hF{JC@=Dv*uyqmUlXO^&4>e)JYnTF1K&9<_vC8KQ~gPn50B zq>#os;RFSb^(FmQ97>pR!yZ#*Ax7aZh6b#`5c;CIJ9CJBHMJ!LntLK>b#9O za=SbX#xz(vYB0hH3U06_1bows?lDXfJ}Qm0=84rn0}Oe25pD+ zzu$J`nA+|!82m?VN6)HmhOv%E^A4Ul(4Nd-lnqlK_AwrZmB0CO99jnpMt%8y4Vni0 z6VQ8iYMwSTybozHb1Mmo%HE(9{6Gn<3h1O9T=dnb+9!ckEiP(6iDv`vX|lp~49$NQ zcfT3Kv`>U3b-el+Xi(caN{P=7up8~&+X9ion{m(LSp6J<1I||BvFMM{OVv&%Qw}{Z zTmqlH;gjdd819=9 zrgwXjrMI4Wy<#~Ry!R6My6gASmq~gLNgeMx9I((BZi>+p%am83hUhY-cd}juta}Ya zWnVgn!t8b8-8aZ;%^%uW?we%vl5+owBkvpNIpcHmS1Bftx7ZVR;U_eWK=d|5IYsZQ zTFMdQ>~A=fVD2;%3}3&P3C89~s+x;YtELdbbV)=rL zkr*zA$#ybqv8fpkUXTAfbO^m;FdR#J>LhfZ1m< zc<+Hwxk5DVghXCuYWN2#T<|%g%YaVIC-TRG4s>%Vb&*Svqs(x(KFN9o1U7Uy#iKXAch5UxK*k+v57K z!0`=~uSxz5$PyB<+(nDf=Nk?<-;(m5ATA11+-XPQA$JJ|Wca%TJH!rruwpMdYkQH9 ztx;-`Brdu_6w-M@fxqh^5hf|_32=l{(9_-thsF2E2q8&B3C+Oqfs!P~2kL^SXMcb} zLX_+UJUB{zBriAU_A6R`}~CagM=iiC&CF)F^X3#&-L0Q;iP8XgGkK~In4rs_B*%09wo*&p@~S< zQte6#^sz+??`J?vs3i6NO$J$Y#Nz=)P_mmxbSlOtg)xgQU^*bSzKIjei4 z6J10D-+tB%b~~|2tOXV^I7`_^P{$1J3F2uQR^TZ!edn5)D!f7u0Ul9o8>^0ht0Sm| z%V!nVG2$FmOGkDq*4}HbF3+Nnx(;orLyO5BtYDfh1j14qgI*-nklxd3Z zk{Jk6eE~BNr1=77AV~KG%=ivzkQ5=fX#O+T*KrtP>z?Ko_f`-^&z!~FOiIsVhVH>3 zI^))YY+8o zgG14G%4-QRs_Es?kR4`b^Uk{M;$o)~^|Jt2Jp?m&9n=faci zoTkR_@p_a_pPjGpTRI0XA8|af+-fR|;%_hwq;hM$ghy86g#g-TxD-RF7%x1z9g$s= z%2m%lFusSi^QqiR-;&VD(Vfj??%?D;3QHEs+c zAo(S+X1&98_}{fh@*K;(MyIifl?f zZQ^0y7Q2~dg!5SoXJV(vM6AA`(T|mM*FaUx!@2PHrhMBu(sc8g7iNBmLZ(C^QzEYf zPD^Mbn+6{vZXJ3)E-a2j8y!Jy!*%BG|CDm^xX5@lO}AOZ!y~G(9kCP@`k{oT3n?b=?V`psKc`R+fjyTrmJK z8oZt)r+xq7C=dNqEt%piu_ER50_XK6f~A|2gYW1RnO*Y~tG*A(@fa_z#5G`tMHVu} zXzd4u%g%oX2m4yv?GGD_Xk%?~8NX<_guG*7atD~>Kyw^K$Kg}P4wtl=z>oG&YkHM+ zE9=>8o3FLC;%-dujDT?85Q7Y2c?UsH&WIS~XcL`UglRWXDVqi+NSrX#-1B>VO)t_x=ioYJ!$@#oz2xvAp#mRn<~qBL)7 z%n>i8lFjgZq7y(f#HzMuwRObSVv;14d|2#=#U+%N+m-CU=t{YK*Oe^0c598f;8q+zT_lgKEVG(u?(8h~a1MWM|Q>yYkj zu>{n?v%{pj1tbE53zIvgpRT6!|UECF6>je6|Oo zC+T`I>T1wnMk5RwA!I?in9S)?@N^HM3^bm&Re z-6g3;9YihH{V|{r?!PP-5);u@phBRFIrle!=-Ni34-8tvmS3`EnYfZI_p`3ML6*Ye z9ksiW^Xm*my93X$We?W%6JN1zFzfn>uNh4==v!{xnT-02Tof!_oB^~5^Jl)Kx-U{x z>VcwAQVU+Ju$_c{>&ED*08zSkfI`r9XD>ejT_Hw_-i%sUq#G&vNkGvPs1RrXdl>>m zpYYOXyg>t5ccMW&?g@7(*bObCz;uNu(Eyvna>eIw^ zjE*(vdM?YUKq2UEV6*_J2;Vtf$Ck_NCCH&ttY>t&LAP;@ZU72FcRQEtE}%l7yV%PU ztUJq~HyC;1PweGm&V4?3OG?CFQ z22EphpFz_ZJ!Mb@qqhy3!Dy#J$1(cap!tmcZP3|_QXE~D1&mr5biP7$MK_>Alyf2L z2C?pbAexts1`3J$5#LmYnGW^sgW@tp?KvMP1a!Gt0?Xyj4NeGmNv>p{o7v|kaTV;- zMI(E85{RPb?~I-kSF`1(Kpn+v;yy+{8T1!M;V_l#HL+1qlxF~i#G3}S1Db(Rn{829 z%7A8zA4L~N1AtBiDz+%y2u}B-C}lJW=mZ4&>1=rd&>2F>vsqUeUV<1y4>14kneI}?E zUkG|}{6f$>n7+VtH`CON3$3^iNA*jOi=Iav4SFim>zTg7^fyo`YBR{^YNnc(nIvh= zRM(|f<_FSomSu+3v?Z5iZm^~;xsvIPOz#10(jE)#xU6HXNlP}jkf5(HeVgfarn{Ja z2`a_+Oe5KE*-}i-Cds^Ps_(@)-$JrJ=X=nna;P;vW*W^UUbh&vRiSt!H!V~sp3Th! zeLXiPL?xLYnzrO0xz(Vdr0NiSwtNtDSIga?;k;^C4$0dKeq7!wvX?kEk9y~{Jo5Y* zsPEHjl>?jWTlIDt#GS4BJ5p?KMJ4RndJ%ZtR%2Q>1pMlZ-cDQ$ZLjfcVs%0 z>2y#j&UVRuXd9xpF-@$?YL_BY$LBpR^`kD&R`)}c+ zOdnzSPNt!vPmor(PrD*YHKK^@=Q7{GWq6V4)hyY_^zTf6VwzR_89eB*(-qItNinII zJS=3ouK1s5nH3YKw}^`eC!X2DZ|8Hm&WTQ2Qpa=^(;JxH#q=?z+nBz}^aIdIOWyA$ zt?uG;(6~6z?Q2L1yGyH3lrZhneN4EI7~35WAe+%NqkB$l+LB79HB1|ru4KA~=?pxA zPc}qrm^Lt7$#e~<-_Cn_42^+BqcIruW9M+Sxy3J4SNuy~+Pa0WS#baS}KWje# z^~+P=>qA7>1)v?ovR)KLdW7EEi~M}ks{yH!d#?m-+xr1X`t-gfhS=^+d2R0fFyycG zrd&SlP5$rc`(qMjl730ah>Ct4Krioq6zI48j|07Ez}cW;;BwGV(JIi}+N=i69Y~Si zci;`+rwsf$D#e)tZw4RjNws@6eSI?Z*ZN%Quh(;>xNOkV&^|QiMNsPqqIpNW51Z0s zUTPB;FEDkc5kFws@_Z@oIwCtoioYB|W!`f{NAPWj(4&L{hNNMh7{d|<@{2`R% z6-@Pbxp&AiSpI9s2Qn@)hDys3J%*O2I%53LiJ(oq6gQ2{jALvbjkXo5j&2>tl?Lz4n>Pw@vKQBg&muV} z`TGGX!2gBtX@H6)-YqEfEeFV`cql+e%M|fcfTrX8`BH#RlUZVVJ3rk*nJaE$v@DVn zy%#U`U05?v4Uw9!q7-YwgT=Z$W5^nRc>jP9K1CRrff zHE2n6Gtf?raxaNKEepi~qw5meD?13MJ(X-(q-Sg&kp4A>j)?s#>4|juHHIbvbrNZ8 zxfEV3g=e$)*BDwHOHDv4W4Q_s320r)-LVc>@%rdtAX=5tuQ6qJY>+C^0O+4Ur9NVx zxF5;C#t`}JCUSM|A}Ogtbr*ve?T%6oJ;VZoD2JZnZe!UZsYdk@j|Qk#^%mQWCFR~n zWO0ReM=AHd;wVPCe*MHGM#~~?l2)jGqJmLFq)XCeK(mdmchXg=zc|C_h9%tqv`FjF zO6$}BQO#(z_<3|~+92U@lP-%~p0q_BAyOGNMAjy~4wR#HNcTF{>89=}uO#hPLqsRm z?T%_xX1siY`VA3%gLIY}8l;OR@0CrcZ8Oxn8JgS-dC8j+(p}k1w<&plLJr%KZL28< zCneg1LM>BLn^4LZDQ&G`LCJnh>5_m#sfnmnGgQ(Hjc$e>O5LxTd_KWwNYD}^k;8ET zvea;KAEVt-YM0?+qt-=UPu&Ofq|s3;9VxaM-KWqUDPA)=YO@jI9i#g4QT7#+3l7?Edmi_(q; zYHxJZ4@cqlD|uNKS(#P?)XV6oAI6G7Mt6PMB%qOu8YT70(PE0$@hC1Aw+HA%t6ZFp z{d_7J_1HK(0-(-H)saX zF`~|(OM#||l?FWnbga16pdWyyi`Ptk6j>GGV}mHND#Z6XcW$!^@iU{XqAcSQt3ud3 z-E9>^GOhsH&Svm*YeagUHKqVxU1AGWM(E#gPU*4Bhdf+@R;6J3&k}=r!n05XT!t zXM_{Q9E0eLaH5!J(0du1NGSC6N+D!MZHOEf}-EQmD1oV}4T7Z7a{Kcva&@Y*m3Q&s7 z+Mj?1W~JFpI%>}Vg^tZ?*`y2ol(|XH^?jxspVi3@P>6B}P|9gpdlM|r%PL8*T$}#gdA>7K;`zj08K*ne)_RAG0Q0|GS5?kUFxsM78uX9Q@z z-Cxc%h~n%BxiCQG_F%cfAd0i0^11+3*u&+90L`*T$VUQHX^)oA2WY-MR=#Ht#o2iI zS%Avz3Gyd{D9$FyP!C z$=)Q-maiDyknGJs?`j>-ZdJ{qwOU@?i)yRmbAh})Ks9QCjN)-;%$R~A`5c+eXj$a0 z><{f4nQzd;**k$c8}w}UZu?x>)u1=C_W<=X=;Q3Y@;o`zpdYjM0Uc#fEN8DgUrsWp zQ_eo183ql9ZlRoQ&>ZL%$}?V1iK~%Dv>$%P$NpcUT$a9D5(zXycdzVcbZ_QK=U!9Wt)VybVt5N0*AqO#9CSGqf6zF)3BEPj71vJN?aO-lQ zc?Qu+{7G435M9|kDHj`*)_RKblw595Ve1N@D>UMGd0JiI>TA);oIKP)kUAijP45PUY4ascNcUo%ic!!40Nx^K}PpC=w6W{jBZo%I_Fh+ zw9&0h+W<7h==LDpYjTFsS#34|%{IE$ZPq!j%SxjwgYI>XC5z|Q$DB6|YLo->pLXzG z1%8%jl*i{k@4OwL%KW#ScNx)jTy_3_h4&En*O;;}f3Ivp7dAtev*p>GyPnJ6lMRe^ zN41v~S{HdZzXm?n7#;b1U*2GJPvq|dy3Ob)hY#evMz;;R59CIpqx`nZCyee*_}nhH z88oo%d(MaQRfEQ~-2wEDLC3V+D|g5p2A$M)AJAt8U7o+o`AB|i(3|;R0PWL=qu}G9 zC4R#=YC<2jT|tPq^Yp&r$1=i*T0(|DmbqGo`AmjCkuIa%QS!1&c4O2K3Ae5R>Th)9 z^HVv@=)P=w8PHfp8SR!- zvTx-B8gW^^laDddqwzbx4k---DY(Apo^;eG~$-PdjtGKsoU*W38-}<+S3ftJkexH zGeZ-)s9n@*LbU(cgjToPlwe8Yp-FdhyOY9AXhXY1pN}*{uQx-VHbeWGi z)TMo54x^f(P!^iOno4$7Gs_E_`Mjx_?wMw7*1Aoim&ck}zT6D$Y=(YFM7)#V zR2I9#Sz)~9Vfv5up_@=fhrO~1b?&e!0Ug;49pB+xczGj;iHCsh8ct62`5{@Q97$+* zlp-WqwP3U?GQU#|P`T7gXn;M{@Mn|!frp6c@opaLE zB%`CqN>>#|N9UY$HQVSY&f@9}qr0%vBp~|lOsSj{bs79WywDs%yY3lksnJmkW~x;t z-Q}G&16^%&6p>l#CZnU3PL{gU=qNs0s6QLs+D@B+9x*zK)@=2((cRH$AJ7X%N3olu z-Y~j{q03S4868D(uG(pIuR@or_81+-bxZZV(ftFumg?U|M^T=qtPxb(Ws#pyhdhSJ`BJMRM;Vstb*^3`ag8v$Lu znrL)1R@$oRMmMwb7NC=ijz&&_sx&$}_ZO%tqoZ+Ds1_RCnebVtE;KqCRqfOzMz;jz zY^Sa=IvQi`)mo#w5$W2iJB*G-TnF_hqx&0Tu!GuUbTs}ts;7*u3^CYIJ#Tb08at`i zjqVZn?4;f`IvSgu)yGEnB6OYA7e+@T)m7ga-AB;5>Sv>)aoa_yk-Cq*gRYB8Vx;f< z6seYsc1LLx7pp>JsoWQUij0oNbcyO=baZd2L=7}L8sVktNTW-4_W_j~9gX)gHPz@k zx|@NHH#!>qUDX_;8wOohHP7g1F6gFejBcj;7SKkX8R+iLgcjY^7K1t;bxI4oLczbr zlvj)Qh4H3?L3W1&320C2K~9tInaoh62_4@)DFL-A+0|qjDlLgO=|Y1`@*;Q_L+22h zSW+0lOBp7=P02+OyoF)VvXV&&=msFXh@o|%EhY0Jc;&*NS4-+6c)3EOl@ex*eFGYs>C}nNwTan=bq7_6F+Fbgc z(}cE{ew1Ljr}WbV^h@cN35eRWDV z(E#z?q$V`G>(m6@;@H*l7(X5ENm*)IfXd7M9cewv zQ{5e)e?(7IZyPkJ#Xq96RcsW+{52vDG)E0GDAN7A=;`WFM*3R&3_piZdiVX&Gt^er z=@rD8>fHd%44+jnJL13JiK3y7N^R zjd-oUko(BrXYdj2GhC(?86E92%#7hZP5fxrf%X}eG)vbI=xBf9!T{0!#6OB(Z z8k3G@qRZ9IMz^TfZMQJ!*8+)2r1pjOdJhAiP?=U=W=f zSF1Nnx|a}d@F70Ef zJJgwhZgA2aYH5H@NV-?;3(%6J2h}N)$;(c8d!Lr}V`@I0L8m&r+~=00C)9R>e(G~y z(o>2)qeHsXzAq&`s~#|@ec#>IR`r%aJ^Ox;v`s~hA zysWNb)F|mJ^Rk*XgDkhofAzK0%WAekvLB&S7&S_IO5tU-i&29}>i2Wf%gUZf>2^kY z_nQ=XMYS<#G0>~3AEOJ!EB#s~zot%N)EK3e$6wV#gJ|XPmb!}3R^G*VTkSobd|oJG z1znThQPv5ZgZ#E%*W`Cq2BT$>P-?&A_f=~~4WeiN;nDZi#m2I{e?0Pm8haw8+bS3L zAD+BjjbpSsO6#Q^>QsYhz4Vb+KHca+vyyVbP@(K_oh^%q84|x<*aKEGY6E|->7RCZH;;Z);Qm& z^^A5$>DiHQ)W?i=%Bu(T0O~cH^4lt39#E6~t$Nv@j|S8w|5LrnXgBuau`8zf@z`7~ zo|?2e_j@(cpnpu-l>37!;SqrS`N==z?Nt|ZB(E07PWhtMPwEX*vb?FaYMv{gDyaW{R#zDvwb?J~8Ka}t{Z-|kLS7mp z^xVvEs)P|n;~-1@rbf&mU4!_#!)W_A<(|qY^4*}S^SqQP6l$);i8_wPi7i))hk-2XTZ8@yWLq6L-D*)b z?Jq4tR^jR7MYn`wl?CX#WXI|oAWMaIc$w#)EM1f`fGB;n#c%yoJT}cB31>X zozZiT_$U&!PGYn>N~_Eu#w~PaLrs=vJenb#jt* zmqD~nPO=^_h}Ox;)?)_IIyu>TmeH~ZtpHQ3*A1c-V2bsjv81(Ps<3j&?lL zt-eM_S1567h|#@!M1D%#8e?>H?$5A}VWj&g!&<_qQPO%o!}^lZPWdyUC(HVQ(N~b!YT1M%yqK{5z6m?KisNgKJd_EA0%b(N;Nma9K*W6=!szI1MPr$}?ywEOV{) zj2b1a(py^H17xW@Ymh;rTd$Nn>vV%|99*MXS(h4gD^P3en!vJuN*n9Wz;akhfwd{H z9G24F+QO(o{Auu|(N5M!LApz$ovpn__vGNCQe3O=nN){Hc_UC4Yp6lD0u@NS^g zUn*W2JUgYAHIz|<_-OE1DZQ=Z&LZ7T`Ssv%bfEPFBi)-rtZf0>EQeTcGukR9556{K zsP&abvh~oLQ-)as=8>01LG3x*I?5nw&m*nrfv&F8gRpjkUHIG?3AA#`2`22RUP{ml;v+`{3oKqNegnY2 z?(w6oIegWl%UN!nrI8po>HQYv)=|6)(zv4{9WKOtI7itCDA0U$;Ob@#)kD+Dx$SGw9c;mnr(1gA_f6()S+*gC_EpIPxIL z_=6;5W8xEN5QRYuIKTHfyA~ zH^Js$ZwFf*Z2vKP`1}w3P)U^7!#2(sY9F0fqPKmlr5t_j!R7g5iS`f~_Mdv7t4=BY zn5O;5-gMb=*uFKW5}NPClA%nG33w$muPtXmZi&-adp1+uszjBjW65$*B`!25q)41dZ+v{?k!`gbL2lvZz&V_o@6529RqEjVGE;)>!gFPoouHam*<+Riq zdXHkzwKwhOA>?I= z#2gbPiI!wziOmT$+Q$6B>)TxH`%1jMP3`k?f_-8w4(9dB^KbvL{lOmozm@3n|0iWw z&h7ITrk`+qbt~yU`sYC{u>T;*;c_{+m+=xT`r=^wLycmxjKE$AT`GzkC1~tRk;Z-! zOGt7h+CXjzT6^OwAWU_Ro!Pz{)5P5SL85rPSYilxTj;BvMES>@<9{Mq$7Rs=`kG}V z`yU62x^Q_;U_P-7|7ZN~$G$(}XuRY92ID^3!1VW)%Cx_3J<9#RH%laz|8%aMp3Ul* zzXDX5R!!vfIMqGX#9QK8PDOr{xEB=vR~JjPrS|!Xk))?9*r%sk;&f0O*I()BA*4!A zcT6hZGue=(&6Xjybl5U%EZ^iDUtw>5Y&wYT zUnlB%(GyeBMC^~uQ%QUYUJM7H*uuKk=qzdRS<>e7@1bmH$H0$@gLyrIKH)Occvoh; z{5~)LtdTg@G=K0|PL%7|(=&;tdfw32!YN!Y;+4?6o_{p`8ut|Nsa?L7PvrI3Y{h94 z%dGt;=C1c=G}Za)JpuA2g{E4fBUaPIy8rL;>v-(z_S7w-ORHO6Q!UZjkGXde>1^(W z&Y*ftb*Qz*X-r*?au0#iJ&qTf(OAa+B>op$wMhri!4Ylxi*6LcH7Wl{4 zp@Lc;D&PA?QCdx>9ZkIMqo$~6-ggJaIQZY6V)zzfGX6)URQ#ohZul#~cguR=Yl;s3 z+K3GBt;Ffz=8JqB+v3<>T#Qtd@?!iyfEoC^SQO)1j3we${N08B=i>#u;Pf`WCEW>s zZ{wS;yYRKbGW_kr1Nde5+a-pG9{77(425nim1D#yVvIOWj1>iP zEcmhDj~2u6S0IlDJ{ov}=qD$NLHO$@kHOz`F-pu5}J7k7QQ+-OH9LG zFUYz;+75q%@P>Ood8T+)oQW?Ko(cKwf}S6{UC?u5_u;)y<%Yg*QpiiozW`oB(-OBYFdTrRG`w%hfR%I0a~kmVVe<>zOyw#(zo13NP2sq z93JrYzM4E{Otx_!=^9khi zHI})Oz6RHl%QIKfcUI2layF>@$5`nr@Dxo3=zE~6Rm+sZ^aaLGr?F?IFJit>d{VGJ zy^uYuL)sd3H|Vv_1B!O)m&(}DE7Svu-qxBV=+5f{Du$i-E`nODizqGc7=Mg&+`_aA z`?+4NEbkk?Uac-45`U9@t}^*PTwWf3PrXupMtrCGq`WGAzWAnmzdB$140^qaj;lec z6Eho_E@yff(`%XD9N!}7z3?r9-qYP8=>6O+g5JyBBIteGEqJDCv`zF*Skim9L@Sv7 znI)CX&j+RVX^E00y+2EoEPJ*f%bs{oohVt-d$B~xlHP+QN|qzpawJ>Q`_x+mz28cd zEa`n#qGUOZEvK<1y%)Vj(0i&x$&%hnB}$g3vL(GGPyBp)ziK;fNE}bBj(ZgJ$Z?N@ zo(M{9^$cExo;&XO_)c-fxL4zGi=Nh#)}O|$$Cv&d9=BiZM@;@L-qLyn9ty25$9)j* zV*NPolX!P4Z9G}F2JLTAj}Nm>8oxJQ0Ul$-x?p?_=$i4r#w)EqkGC=^t*6JwGRP(~ z<9zF#@ovV8;^XnRWWFf&jK4jz!Fs0EJ(<1uJ9%$%@Atx!W{-zJ@vh?v_IZ_TH{rR= zt9X?4;&16)CCetf2uc457QSFa-_=_y^%wQ147Vd%c4n@X^rgMq<(LV3<@MHy6F!H8 zzOlDf()ae(S$N7OtCyg68rMqt4&Uvv0lBP|^e!RMm~)j}Ibolqyi&5(N_zkBcDV+U zUi|hUl`21L5X$`T(Dmx(3DR06=t;X5#qAS{vmW4*?-bi6^vv3cQ8_S+YC0VJn-lh{ zjkwP$!y6^NxwKKzJ4zcReQ$50r0?xbg-;oNLekgup5RfxRnjwcTP1zdZZ5YHQ5k+k z(z`ly(PG5YxAz|79$CcNx6uxk+9_U1x-)Bwl{fLethZ%5*soUZ#7$Y-c}z8^?i1gJ zoMsQ2b9TdW?8L9Lc5&Lb@tu}mvcBR`+@MaKnATz!TW;YP_=@kw?PWi|Fn!b7Iq~im zZ(5&C{7Z}L)mIar2HiVxE7K#x*Q@xXms;$!=na^iDCf-Z7hKvetXY$Gw)nzYGU@Xc zm1^~*Z(7(G^^ZA~>bgnM>`L`;c|rCU7CjNWk-yotQP4NxHu9I-Hu9I-HVS$YZ6klb zZ6klbZ6klbZ6klbZ6kjfZX**5cO9lUn9(o^6c7s&z4} zEeA>ZHr7p0Hq6!v+FqBqYiP*KrTK}GSLa!&9D98(1aFGtQt zd_5{^s`VVNM-V(-j+fu}`>eJ1?3q1*9?$Qe_k7+$zO(kTp7pHzvz~R?Yw!6D=)7HT zQts7DgC4NcHe1M~;(0EVElyxfDxSldl#_3hawp$%aBf(P+~M?FtGI7M9^;FTOKR_u z)V6?r!fur^iQ1pq^H%WlJi?@+{+W~$Ka+Al-=yLhut}U|%gpA;_+xNhQ~Nr^$P;ao zif82}6;GQ@DxNEwR6HFxsd#E`Qt`AL`CkNur{n0}2ntULPReOE#EbJi<46x{mM7XK z{%1OE1 zFlz1D))3K=gGpTsO{|sz^FVtApeckEs zq~Zz4NySr!|fqA^q`knZwF4wX{s=!ZCKnC;GL080p1fi32Bs% zF%&h+lVME(p4Vy$@O)P|@Zp}j>%#$-|G45=pK#z4J$Hx00hU-(fTzB?N?1OJk&^e- zHwAbPDJc7~A`M(3uj; zx^?fLA%`c(S^_+K&?@DRRN*GerT|ZYbqb{=z_SS3#eH4LgL~T>!hz%41{xNXJiK?b zp((&~W=#R!h}jh2EtpLKo*8Qj@Gi`z08fk^w)S6rcf(;hjn@?5?U+pgo*nBEnY5zc z({P=NU;RMC6H-23m9iqfFlStD%^X)eB{nX1XO7Fwn%5&N#%sA>b6jrI9GBZP#}!XQ zjmw>y<8r6wxZ<6fp|e}tiJ`m+a~AxY;sQ6>ssXKMSd{$$8RFz&&ZFfuFX= zW(Cz__CEoivi}0gb0Pt!ToLMwNcf3J_=QM#UL?FEJiim3KMIcuFqV=4W2p!*mev4M z(iS)w2&sE3KMuT4@Pyz4f)5HlBKR4>#|2LbJ}LOmg5MPUw&3>!PXiSn^j8Ox2bP~@@DmB;CodN^pC3ukfE9}mtP9{A=6}-)6s!oXe zl;CMWD?n+Dg6)D)!K~m3!Bc{#1+5a{7i<@d3T6dQ2%Zw07CbGeN+r#L5y3XWcxi|I zk*bWi?}YnvRTJW#5{#5FRGVO9Io<7oQNgU>3Bgl>rvA=5zGo6uVQLXi2Ib_ zbQRmcX>p$sU#pt(8wJ}1qk>t%6N0A%PYYTh;TLQdj0$E2PYB*0V!WrseOl0}VW^TC z(i_FyE*KTe3Z4)=C3srUs+G6|+XbV7S-~f8wcyD>(|qEY1;kEVFL*K#UC2;b!4ra~ z1WyZEi$tbi(<1V>i#sZq6+ABV6XNcyBhUSU?f4J^kE*KTe3O?AdrexECr-8i-ehR#4fwhF3vzDwW`M`okaW8}WKNhr$ zy9@5-bE4vo!+rRithkRaVT^Y&)Hx@F@*v#bKj)OVPYa$Ap8FfgS#mB>oyWK$f^CAm zn$kpiQxkdG#N8{H5j-yR32{#eP7A7~B2BPOuvai6I3YMC*!e0-zF)Ba0=lOzcunca zz!TzD%S4Xggy59m@e9d$Mlf>`c_sui%f&5t|8kKg?rA}_g1#jyNRNnnN^n|GHA`HA z8Nms`DZyz$wNiKl+XQQ-afik=5jH6YLet2u=u22~G>DH6mHCO|VxmBRC;A zrQH{kb4qYpP_>Yv)`|qdHo;!OX+d=feJ2E`1l2m>7o67aOG#-H>=n!iM%D{muvai6 zI3YMCI4!6y6PZ^CzhJLmMsPxKN^n|GwF$pqn_#bCMsPxKN^n|GwF|#sn_#bCMsPxK zN^n|GZ4!RLHo;!OjNpXel;E_W>JWaxHo;!OjNpXel;E_W>J)y#Ho;!OjNpXel;E_W z+AREnZGydm8Nms`DZyz$wMF;^+XQ=jg3aioX{wh8tMW&|hpFw~Ubw4mx1If8A1y@DCR3Bf7BX+hN^{DN(Qy@DCR3Bf7B zX+gDD_yyYpGgp%`Avi6lt`Q!=Ho=VGgy59mw4l0HI0f4Tdj+Qirv=q@!Xv2mi(9Zw zuvai6IMGkvDZyz$6(eOra7s`e5D9`!2dH`a1t)|uB{(gp4vI9vX+bq0VFlX+dj&Is z6N2{-h$a-AczsRvcN)~8nreH=1aR|`w!xa}u_YPcBTFWR=sOL3WJx6PX3V_W1T%sY zg0}@f9{k7POTlIHcFfy1?^p8{SCv;^RNY^FQ}s`(!=XoN7Sz6~_R89(`47y0Wd3L8 zKRbWZ6-)lGWLAXV*ek>CbQezgy&A47RRwk=XDNlR zDBS=oo&AOa_ruF?1?7?zcK~0#;!VIiSKJM>T1nULoNoZ~OCY-(vN6AecQUVUWB+7ySvw`W_-hHrP_zR;{DjSeLZG~!;p|vYt<-reUCuWQGDTh40FaC z@b%9dvDwq_mgwU|IFO^0j0i!x51S9o8TJY z{$ML`STG~_7Qy=kpA0h8mj!2vMa$Q5Y56*?zb+PUjM}}ohH2O*s7vEO4fAzQtyO|j zs;vYrt0m8t+FH1EsC#QKhx-GyESZOD+u=SupP@2>ZxwvM;6sA{9pAebF!p;FQ2zRb zzd>A^7XBW%V}qXqs+VBb{elk(WhVMp zm;4qIzPW__4>kM|?t@`U_}3+DpI*1F1&!%QW4Y6YHRY}d`8Ad|ZijEE@hYH}FwjVu z4+{Rh;G=?1zAQb}#85w}Coc5m;?^d%dEF-8)Wn+7DS1az2JybTNlHNQ(}G_X{I+oZ zQ1F+6e-`>VOUZxX(mN1Vm)Ipj*}9aHUnlNzNy7@kBSJYY_)fw51s`5|7bNKZ@~BY0 zDiY2J{$c4m;rl-|U$NyYE0lV8>002zhF0LYwQax)mu>+bUe4C_$3-y5$5~7)xyV?n}D@8em-u zwSms#cJVzZs8g$Tl+N$Mh7ZrdJCv8R9CY6ISk!DfosVCB624ldmZnQjmyb@*mo8^1 z4F?|qr`CI0Td4=M_S2NtZ1^#JFDrlZTxj(xScbY}bQ$Wh@|Kb=0bLrpK^yX;EPb^_eg;i>JRn;(Ek%?iv@zp2rLA}0@|=9SO%9M-!?3Ydf=t7NNiXZjlj!r2F-?r zu@u;fl;RClq|1h#aS^Z$X|Z8ztOVlQx4;fqDK@N*wZP4gXT#!HkCb!)ZCGQM!+oXT z4%jTV+Ag>g@+{aRSAen`^6*VP!EVU2VV@9tAP;9!fwsCDwv45&5xfqvZCFfQpu7&U zZS`8geXw(IK2>l(WZST7h<%W4;Zz#&fa(S%2DH=wWZUYXU;?sjoWHvcctpN@d{l4@ zvTb!t@J3i>mbwXu_aq_Pg5^cbAO;&2SRdTCLbi?D9Aa=Ehin_Sgb@FiiUaS&dcLjR zriOrT#}}1t+&Oa?_)hE>*y`P|@@#c4Y&;vg1!KVX!N#-IeTdCg??((az6_lOJ^`t= zdQ#mE{35=vY^yIpwv8Q~Hv^x?*W+#Vf_f|P*NDxAmG=(dOX?orLhD^f+akd_>t0Y6 z11)u~^iE$_l~D ztOwz4wLT8qV0{93h4pv9cI#o_R_sThomh{+y$y)dnbxP^-XXZl`V8FHSdRj)w;qS@ zYk@YtYWz94V}b{9+RIi4fR>8miyyWc5FECi1SKJO*!mLaBZ6t`Y0yW3sAcOba32Mt zeyy*;Jtlag^>t8g09xv1>!0DiN$?HUH$b@sXybdy-vr)neG7Pp^=;rAt?$D3P1g6| zzEkkc)(=3rOYm;%H0W;u;%k7`kKleQ&{A)=ehl~91m9u(8z|#ITis**6z+Ed(JHK; z1Mjtd3A_*A+qTsE1t+W*K>2{+2dx+3e!%(-@T1mm!SkTt$F1Lk@-e}`v;GtGlRz6^ zPW}_{5$i9Yd=hA@Ph0q>J_E4TqZT%C)G4bJl+O!3VU>gO1;Hn+S#W>RnhpGtH5d4l zH4pf-RSo_x3x3tA0p%-#U$f?e{!c(#ecj@h2cH4j__p#Q;5YF73|l>G)dRn2Edl-? zs}cAu>pbAUSWAK5w$2BB$65x&x59zXSu23wvsMDXZ>7@Q+p>DE|q>iD4@S_n!p+VjToU z*+kopgKhya$FvW@9S|(Fhd?O<+N#`6z+E9&X&(k<}G3+lpsWI7T*J9wjBA1|_Ip9OSnv}2KG4?zF@D(}fO|a<@+OotA!{|)YYfEM=Ve+u`zfR=iX{d2hQ6?`vlFt@O0Pkg`qE70!~ z{DA!eC=)=u-DJNA_Xh>D)O9}xVA{d-V8D)=$`KS6&`a1!^4Tj~*@ ztv+f01^6l3vMucWTeht}W8=(<`YaHd2e*A&>Tw&Fh2u;>IozMaz1p_=N1&xn;T~pN zO#vspBH=rcN=3gw5vh?l3fG$7lD>~+MW;hQ-c3yp9A+#a1tE-)UE?o1nPm6 zfhEA%fkt3Ba2{|;U@5{j2hN9kh2Y8nZk$!C0vCb48fdFEffaDK1XjX*vEbUkYEUi_ zyfknz=<9&^CTw6W-0KA|53GZ`HLxDIA#gcxV_*Y#t^nFNsc;3{?SdVFc2G6}@t$g+ z1MW`2ErHE&j|H{@Zwhqb%c38&Gr+&a$-Z7yS@zD7UiHb+3#?xCOzGdl{ak4P_R#ln zj-yxoth5~XeCasoM@sJkE-Y)os?Y1nK3&qU-d^^7;6If;S8_=Gy6j!BQ=Tuo2l!Ii zy}JW%t4ATnzj0BG`PF!$#W$BtG^RD85zs-izQ8hmH)$HDZxH_dx!-rvo8blxB5^;W&TYEE@$ z^PsQGTqw%Xp>f!dMU8*1;X{e103a6zUbc)Pq-FxdkRQK_^72(eC_V5ki zFNFUPesz7a{>J*->wi#R+R)H&S;Kh4V-4SE2t^i0N-})2qZD%j{wnpcxfcO3@p1p* z`@`jY@X{*PlR|Ss(*I)n3CzUu`OyCiA7>f*-9y4x{!H_#46BVc4zw7gQ%DO;zeM^1ISJUv7T4sMlcX zP=$IPs9<`w%d>)gPP#(v>)&e)-+pye`1Tw6pt?u+hRtu<_@@p35%Ua-Tlj8}bX39W zy#TX91zTIoyUF;!L48oH(d~>G?tFt=X_l1r(^bCF4(5H>OADZVM%Cmyj)ActNV0~7p(*c%Wl{!?S%kjk$ zUA~_wWjrP7QSgHXguAbMY?wfa5JvT3f-&@q&s@v6tRkx_URekDj zs_s;`Ri)HjaPNity>RcXdRXnN-eH}rN#ORyKJ|F*o$AZADfLWkpPH`yuGKLA7Bz~$ z$7^pv_*>MC^ZW38r@9M&@1OrC>ksoS`w#O2`1_M}-@InEa$%opnEx94=%PN=y!eFu zp2bhwR^8KfdEM9TX1EvC{j+^u-NR~s-52c(t1NqeT_4;p!2Nan^{Mmf{$iJfe_gU5 z+^05%ExV@SPPL-pM}dqbFmKaEN0}bRuz7$@mmdA2<}=u&ByZsxa;s6#$Ubsos0GTXEB?8Rz>g}0e>Uh z=i=`?glPh8DgMsKy!d?lUI6+sxR=3wA>0>1n_dL>a?qCJcLiwN6KIBerTh(J=KpH^ zwOXs;cQM=-!`%Wmz6+1PR*P?iT?+T5aIc4(Z-%uZ?hW|ch-dt3-I3@Y9gJ;M-J>J1 zWOt(PP%Pc7e>O!^vF854LDilb8}6HdL3JdChN8p$n~%l%M$_@(gB^+CbnIB#p>K%~ zren$WbUGQ|KbnrIF2lb)5$%s9XAs(Km>eORndxyTUBd?wy{nz4y(9h6bZqmH*l@Z% zc`yYJCz|$DDmJu#a15zB5I;C`bmD7bEeO-KCy_{-@DNMR(PT6oPYib_Mw5NWQqR~( zYOR5`%-WK8jWOWJy|QJT)>H9m|#D=40vDa0=Wh)t!ze(|cm6 z(ZRIp+JwZW(#hzE>e`+-crcc96V{bV3`(+5<4E*1iR7WKbZkiYW-1tpK%JtThY>o4 zM`7O*?Mo+;V;-_Y0Y}TuSelIZGgPU#oR7o16Y1z4MeBj2u0657So}y#Hy4LvhBk>T zr4xOL!Q!D%`#rJbP<%LwG!_->G{N|Atf+ry>_8NSS4&wMiR;ND;Jk;nod(N{Om)@p0}5<(#P~1=o@nrx|I`hoG$jd~x|tP7~`GOr+3hqA92;H+C%@ z9USV$)k}FrzdM>tp+%wYqp5hGr!=*P@)Y2X=x`KrX3|LA7-o|3^qA6_*4@zKrlv|e zo<_Nt^2n820bz~OP4)-I=1g+)IY9$h-Rt$7>uYA9~DKMzd} zrTP-d!T5gFJ%)}qq;~CpJ!(kh)P9fQ=7P90+!*v>e@U!IuuJr_YYE2`5CMbCQ?j}lbD`?Bv??O%{|f7p_C#jqbk~I!R#5u z&R>Fr&omMR{qDL+_hyTJ-e>xEj3$#PYp0^fQa}=} z=x|?bP=t4AXEVoC)oP;Mcxd0gP0_wX$j_E|Y!L6!d*bnX=EqRPU*ho5ojiJEt9(() zq>gFcm85~g3~AS{VUtPM_)`U>7}71gzL>Ry+=!@pl4CUUjKq{~-nlLt?eE{GHGRKI z#)c9{V(z2YN?>rPrp?J@BAM$T3@juksD5zd>8D-8&hxHeO_6%i=B=(M<0f6J%#_d_ zcdJRfFSD(N!{=cF7@Hy`?anO$X*oK|lyYRJW|E-;JGLuqc5V1d0^NsJ$K{j4Jne`c zqg99|r~8r?xg$PYj2btRbt&Eu|92h^{buHq1e^Y!BLFej+PSvS{oQenNnM0 z>5hT;AljqY-_X1A>n|dI@qnjz?!j8zf z1DfhejvKZ-HLi^U#|v5GL{QJwFe?!Q$obU~p%C*dj#O@vKq)f_FA&U}uo9)#C|9af zCQZ`V{Z+({T1fehK>gS>{eV&AD}3<+H!N=G7A0ED1BT zTP-24Q($!Gw7{>#jfX_-OD1%GLBH{{xTSzZ`F)G0(9f6O)qMV9ImLFyjym=vw4`xz z{^;0%C*rXG zw~P+=(UU7iz{H5ilSj^)ImE$KiRC6YDKllzLGpN*5|k?EYUT&&>P!qp@s(wtUhS zm==TiB(4i4;fo`XrE5r&^}6mx1?oAcf+lEk!}#{ zb;D>QL##@v6;CVB3e0U3y4<@5qRE(J7LlVXl}DnoGR?@>F!v0#-j;domdv>y91d(hHYPkvt<>)3o|7AZt3=AmJO32Iy{)-G)va@|PU99KGFiiq1?;Rlm2IG(Nt8v{J~BMfvn zOwv%CJ%ZM*5noBPGKimQ!uvg(SbaXshih0fTK9oIlt+`wNgLh9CU+z@w~wX=Ff?!{ z35#f|J2mL2AguQ}(tPo@8qq!vCj>TGblj2-uA$39x}=0pt$g%{mJNKYib)+~EZ4ge zs=dD-;~f`Vm0ll}O43fVx=zV?gjVm9FD9?22s2Cj=7-XZvsM54Brs#ppqM{mK~T@Z zJ&#*s=`HbODlH_5K=!#?b1XiAIWHx=Lr^MG4o3aDGcMCYWt8jcl-f089-f@F`>sx3 z1UV+AopY?+(e!{w+Z|1%jwX_N5`#HeQjZ4Y>lqjw+CPF#BFx;JATE23)x*ZZb6NS| zvhyJqgioGeT%eW|`4Wy&)5-JuxYGPyInh2p<}0U-WZ)x7uheLZ$gB?O5|K?g9cZ|} zCq5LzWLnBimRTLOwG#z}i75Gu>N2*AW5jjGhWiUi*b^dILZN>;O`k%rx73PMrne%I zqWnsnp#lpk)D`bmlT@7h}hLv}+ZceFb`$?*Y^D>-!c6N6P9vQx1;$l`%^dcSo zIor`T-}XcwcS_7g5&gE~OqN)Ax^;=B&nHO=IhaUule2-6u3TA@qXRnz2&@ND<0gG~ zGIqqYZt?Rzn`JyxetP%Gl(1V2Iu|+z`Kd2RxwJo>L~SylUY$gFBSqs$95rc|d~~I7 zW^9-X&Rx3`i9y*$kvw}wNP1wm!eR6(2=UULCa)g%4j&p$9OW(==ym z+!)#&W7M9`6Y6o$tP|+7NygUZIVZ)eaaQE49%v3xj#w8pPgH7HvGuMS;}NH>m||}D z4Z?+v&Xer=baieE(Yz1<+g%!vu4759I19qDp%jqxLIHLjr9J4LCH8@KlA2Q_!n7+H z$H`ndGpH9(P4S>y@)9YHu3!1tvE;0`eZ|Ohm!?^Vo*gr1YZzLaPHwvLd~5C`Gggj= zW8EWBEWJVoDuL&`WjR}~J{VF8TZVFI++B*@nZVAobKbijt7XQ`x`m(7iMnNz<~AVg z%8rmAMWxRu8GSA#mPg?_JNK(llTzyJiyr1&v!pQDp2-=_dfmiFbUF3eJ7)>q!(HgX z5T?k)3yH^pEA%lYsi+4-v>(CdHq-9yF}gD|+{<{oDJ&jRa^&~IzNDP@(#7K&M7aav zl%3zxEl9stVFAv-QBY`pu3`n{=bE`#{Jg~r!pE0aE_x5o+ek!8LFzHg=iU@>t9$0O zX{r7?>FFC$I9;#Uj6MBdw{tmfhd##_Ejswh$_(2!xdZdEQ)83{7PQb)oi%=={M=BI8DFQ8QN31`Nli(t_z5fU|u%N1)Q^P{mo?Gxr$e@lfv;6MLO!qX-o2) zmwmHgZR%3|Wt8IT7~=6***dr0#skX&E9^Oj;T&gjsE0SEQMlL=z4fX0Z_Klf@MxGIqZN|3kcKD(v#4Egdsu&|@$m9o^S znLL;h=X*AIt!NmgJKv4rezS2FjKHQjlIkp9=+TcBG3Plnz6+PfG+i{Bco`v@jH3r~ z+<$j8&Q}_B3zVs&d41RI6V4us6O9p*a_D)pP=~0Qd!mP&og&8rs@@w*iZ-qL;@`_w zxC9fOoeO{yATm>TM}1>n8ymnpNf9tbdN$xy04TP+HZ)_!=X!Coh%?!McI6a?=?#sQ~qc@@StNbzZp5!nl_iDduf)9!nw%II>?TO*MZJ&OjG9|+c z(v3j`MVJ_bqGb^|#$uoNr~^@+ed6xtu;-)-WcN9V17^VtnLjUY#5zMdy@x-a;V?Y)HQ!uVT)TN>8=OB1jVJq*1eFt`c%s zQVq)SJ(W&uf;BV}ht`4NnpPuN$craNQ>NGGGFJQL6s79N32d;*e9;JwJBQiQ&e69+ zI3LCbmC!G|;$X)>qF-S}QlBJ3G`!tlKaZ~r=odjpa_2wd!~L;iPWzHm8659SDWtr; z222=tXqWc^@j5Fr$#rSw3#jxX&n7=yzYyX@;J3jwkRKY!P^oh(aeX#je5>Iqm^=cjWd}Y;c2S)2&zM0 zMUhf*XVD2Fb&932BU#Q1?2ZiG3B0xv#prcF$(D?vXuiI)u)1mJbF^r2#g$4jdeqS$ z1-vVj796iL7clVZnT!7Y%efzi-&TCFO z2BWFeYK6B|v3&|j8~5+qw@TqX_=5+N@``xQ+@L*|dop?TTAlh8dS+@6%U03;UBkQ$ z#(DJ<3#XLOg|3CG3(V`^dP79NE5H}VogPR|$1USDhEM%QImWH{@Tio5bCdzijI`J_ zah&t=Wv(SZa~E?xMG9Enx5tW?x7|6H=g8X+xe{|-J<@Slt>y)K2;0vQT!zL$>JnM7 zK}U`ulLOIVjEJrBzPT>h1FECDsdeM}efv71JT~1voERP(f=1sVdf1WcdG9~z?k>Vq zI4m#x>&pRD`|udg4DOLgw#L#j8|adgMzQ|9O9X_r32*o(WT1lumz0-u^V_Aqev$a+tZma_{I^#oaac0q6(1sWD7WO82xCvUO{Db3x~hNo}m zdFOhX+gepCYAW|;;KqLTZO^NNY6r$dn)2v1YxeDPri?|pwz-?4*%P>R1~XVEsyXkf zrj0Vk>M&DMc5cUABrZ=J%`o(^ooSdnH&HaZs|p)e@7t$s-&YXjjkR@S%f5Z-fjAET z_VFdTjcfPqgA$DM*L|TcSD^0Q9DjZO&oaLb8`eZ(s5>)!#FE?M{r$1wT*Y!*YjlKW z2zt7sOr^RxY{Zg!T~VtX_wExYr8q6H&4)*$gDLE!ax-98a`Vs#thDyzLEJhroF*+N zQBL>nN}3%vwntu0rLLh#Co`AL!$;!D#4tmOdNn18Zl)MFHE#{y+VA6uyp!931WEM6v&)|vEnkw2DOQUG6yr=Bw@&5Pa-~q875``yof{IUQ)UfAmv8$;{9M}kn|b| ziIIKMdz{m_&X9Q(dK50IiuFr%I!7|4J>|08T5$1-?w(NvppB3Z9 z7(_7lLDWbSCO_yJcn_M>#9g@ce{_FJk7PRSGFx-ZHhJ*dYikORN3BtGB2jbdQ6mZO{G?0vr&7>6!sd3t4U*qp5Ua$gM8!%gN?simy~* z_4?&D>2$Krf@!sq$}LEjneypE5<@w?*9x1+a$Y3!33TGx!2AW|HkwIXVK_o0AxpvRpUZfYU(Uhi_ z8xzh0@;Hp7fc#<5!IO(#k`w&(W*(t6kQ~!bu4&cp6WQZCla`NP?o;1M7tStGleOR zv8ZHaFC|%7e|B1NHH?2WOwSof3BCU;L-(OgV6RxhU_N;^3VSsSD?Q!3CK_kNQZ{>g zwp^;Zama~8aqQBsw)UrFw#<8JXAR?C>LIM#s#$4#|73)&MK|KM+ne##nH$w={9cB8 zb#GK_;Id|S;>$3b@b#9hO3mJkJE`~JYcG4?-lE!ZRbIQ=4mn-;?%fv9)vRkk*@Gul zK8Sm;4}z~5-&+~RcUg`pH5cDshx-7Y;1g(8s$O%ofEvRpL^DPq563#~dk}s}d?%(2 zTsq|&aIf!fp&kG~Hm*P)M%WnWuT@s*T--0L=64In5y2iOr{{MgL?7<#W_(_bEs)U% z8s&{C)u_|TbdegDbZx=!AfA{WHGey32a)f*aLe{ch!{8-vo}NmXB}f0GHLHeIVK>f zU&~&?a>)6$V?LdN&k(*?ILOpxe%pf&>%>q^-U1s%vZ8KcBjQej7DupANp=JU^zx$5 zhs-!4i=aRg2*amg;HV^^9~UM^&}0S?cT^}5@UX!6n{fH~$+$qRnZ2h?8+1krOq`a6&)N!(z&_9B|m^3d|8=v#@$`bJwq>iUisn-;f%4Wy> z=gVNp96*j)J-J-w%VlZk4%LV*u@k&ZkDBE)S=Gpf$CT)1rMnwD;7Zd0RUiWXJqXD< zP~G|IV~xZ?k0U>Wz#AYfmzMnWM!=D4SWddo_4@Ks(d?%2VpKv??`do45O8oZ|1RtD)YZ8 z8wcNzNRDW)G>))p*k#Cn7&(ZGi`_+CRlHDi_SqSku3ST58g;|g3Y}F>CkXYUh z8pofvId$!AX~kM=FM9t;Y!}UyTj(V{a|y6&eshq*qAk3m2(%Zwuvbq9Mj%B-VU zN{g2nn<92)3(^4@rq{DBom8>zSdYa*Xr1r$W8EvIv1UGZ9fGj|q@ZBv(nf=KNS|3K zqa-IA1qX`=+{`eIkfc!4cq%l7bXDU#211dS3YYdD4HQ1JKkP>}GaS`l&N%Y-HfvrN zB1TEW%Vm&L)Ox9_T`5H;9MD_*dxUj5QPF|%M!G|U} zUiJZV`jKVE@Hsn&8A+qTu~x^#4ULnzou!xS$j*?it0O-e&84e}ZI|VtIkcwN1Ln+4 zz?&_tUYW?F80A#AuEST(s*WRg!%jJuZ{n|$xNWYID^$HJAoJgba3Z{7xOtKxD(K^%) zx9qfx2VjEk`x#V=19pW5y>Z33|E!CfX)>+i#C|A;Kj^`s5YQ0WizU@F&e@xQJkr`uV=AL51 z8Y6SeW-Yo{*4RHbfzz7|-5aza&T+xpZFPSu%$}}Nws`tefihX2o2fL8f{m?A3ubN1 zT&hfZ92voZE|gPRRHW0U3zOmP(oQIC(XeeYtAoH{qp2*OUy3YE;Fr$c7XpFB(nHvp0eFBK1yQ0WZH997Sp8FIr%x{aGr*63Z>P0Qn zcUGLdV&5&*->kf>Y<_5w6>6!NAIjWig|a2(aNLj2tn&9R^NVN2=ZEl1#*<{6H9wSX zvul)Q{TIXXoK;^6pUh9ZJ|gTn2&2y*#Rt)5p0k3Gn!P|v&0g~x)Q-Q11rZg48C7=V-FH7a%g{rc zXM4O+kL^}y^E~lfgg}{>7;28tjR?jbl-M_v8~&0AzWYcLQPqd4m{t7Dj@TecN~!{7 zHL9cx#F7ihd;!_!M#NL1sZlzqCe|9oNZp6I7kD|5>mafmw9A5sy1Ew->MC1hSq&-( z@gShQx@KwVq3ps6<7%0u#gJrNEs~Sv#OhvgcLmC-djn;mE_qs22_MNpcA+ja>0gpI zh+zqkz!X#?KsZz_k?jDj9PO|_bZrpwdP=Z*tx7C{P2eXaomBNR@N&ds&ktQ?2Sa=3 zit{=f^6@{U+M$to5gXnPEi}S>_c)p8XMUGc%m7^jXjyjXT7<0+WgbI~RA&d+1hXR* zXaw1jQ06$wpF}WcAUb_wHW!XcIx6k5 zxprA4>U}Q$Dyt&_v>WO&w0L%-y$Bl%ltE#kPPF=Arh=hZkjW3l9BRmfg!*uTN|dp= zV9&9+5unI)1a?_zNm+H~eU`GU*^$y3RbN_BVOLd1LzASitTXSER;Hg(%y_0;k%1(} zSxs@>sze3jxxUmqGdmS1Wf9CygwPepjtFDb#>?d0uMZd0lkRvz2F_XEuT| zve6N&r_QJs4MwLGM3KM&g#^G8GL?-;KL8JU$xSSiY?Y%;GEX`Ewu$25jpV@F=o5Gw zS5pZ-u&GM=+1*hPss#TbsT>^^O8T-l>8C1J#blmxRZJ6FTJ|QUrHTyzIwtd!Q8rCX zRwi3UO`CZ}QU@i0a=l%;cWWry1HtGL!3t@3ttc{hk4R}`%cyxX&!7v1CFKi=ncG>Y zP%_kYnLC}PJ;(b1Po{(V{5p)dwkd`hrIVjc!wZ@VOxa`bug>nKIY27MYeSl&V|W4# z+J4Y3lTaBxLfLJSux*kv)C7vRMEVfdDXbNf$768Uoi_Hyb zK-CA!(O_6O_?4knw664G(@&jFog0>|xskFOVa9-8CBuXG`--G_V+&h`Pz?EUi6-q5})|l;TN8N{*G%)UijYlA0`()ZhzuL zS55R+$#;I^_rE#znHA^GK2~?`J3iIC;hW*IgEzf&@`v9!^3fMoOmtoO{Dq4?b?T;* z;hTTn_~&_dzIoZ}*8Kui{p`CZP*c~lH=D}h+o5IN%Yur zaUvvfR6YQR(}A39vf*R)1O^|S?yS&)p<;B<(B@hlDzsToiZmj3VPw2iSrs7}nPHMwRUnbnX;qa~nAho!TvCZ= zX;?C7V!*>h3rz;|S6E^QAOROv(X7v|N6**6NJj>&(n`ujo~(MuYfd?}9HW84L^@aj zv+#*)onywgoC5@#XIXg(jKYcv@eOqa;n{!>SfdWY=&S78W+o9X1FKLa|a!ImxU*GO2^Z5+aN#x|UJ~I)!0I6g&rYJB)+StlUamnuGJB zIE~zIk)Fo6=0f^POI##NWCYF@SK=A}IqrTPwFbjVgup!vH8#&G{ZbTESB z=Q#0RAfOZicHZm+=2Z7WOW970Avxz4(T6u9a;nc^r8ZpcVO`{YAtb`f9q{&<36^p~ zaQ3|36QVH}f=5mD_Xi`p@ywL0U_!5;3o`2_9?pdW>=)`h_mntzBYT|GU70?u9f@$WR zmWFzI79-A{#{cXtS|x>=bnj~lQrk5Jk*DBn9HOAzlRb?o8nw`ADt=hP(o0SoshKkz zkgBskMye|07mPu4Vcmi8fT<=zXU<$$&Qwc6VE>8W&<0J(oFU%^SnZGlC;Lw3Dg1Br zqqx}-n?g-SKf)zqpOU0iX$2{KRD{~0^B-!W3Isn!Nc3i?fGQiE-h$$Q$;CS1+_M5b z942H1jH!A|OsliMEG-XIh}nSBdO;74zn2pH(`<|dCVN;fK!RUDVNgH6U@vfUt{F&g zP&PxjI<34ch#Ajs!0vhIhA8!pT62sjZSxwF&&ZQm?QdjcJ{q(qGX;|Qz^zOy7LwS+^Fjm{ z`$(>AYL;6|msJHyL~MiAVAP22Ml^ZECIj`>S+IZ>Rl=I|#GIQ~V!gz(XwE``L|H+n z-V(>ka;Wya5_Y{n*Ar)Nz!F#h8=yEUpjbIKuc*TcvUd3~TwWSF>?doA=!{OIK(n2C zYJ&`6W=GREFHOjjcfKsv|E*a2u}}|^L!D4ONfz}Jj9+LSF~H|bQ!88$xQGU|j>l7c zh1u4^Drmo8LE0`?0T{Fu+JXy1Q9|Q&V$KY`YD<>3(@q=VQq?CAgkJV6iC(6h@T;B8 ziM+I4&Uj%0W*(z0{%I_p)>t(d1fh|GISK|@);I+*0!-hLmds;r2-@4^e$0f=xf^7t zd;m%p0~`C-WB6syV0`XmA2X#IX{HyHJ8bY}C@@lA8_Ce%CN!;l6fL}EmO=v@q<|f? zQQF9U_05&#CDk``onQmaR!nx0WjHaYKgjtLcGta;-AfZC?_KZovQzoHLEh%>y zx&D=TiejJPC@C3v3Y`TjM;LSUdQv5tj&UMe$W>L1HIL27Q^9j0B{e0wsA~d2X>0CE zg^i>ttFo%{Rpl5=J%QX-fw_(yn#0K!R*5#idV;yr$>MaD)-aaBG@)2o!a!vkS7^f= zp_)~Upm>-E%wthPhxO`3<~VmJr=(71OAzgbRb72+BePp>1L0=b$chC6aDNhOV939$ zS)g2nfTG6=22HxqtxLmt1;+4lW6oipqmXRpVV+ifMiLf8u%X87)vQ@4MGIvDGFePV zkPPSpn+tOw%tarBt}`L+O0<0AMjy_3U^}hSwz`wqk5Is zM2=!~R%V!A^cM6+j3w@>47Vh(Z3;^UKG1G(S2~k_EZD;6sSmA~R~~Q!;8AAG4`O*% z*Gfi8E|mEwl^hmR&$raMdsDH<`5gK#&@bji%(Xqs&X4e}7`zoG)01^MzWsQW^EI!H z^y78d<={z2^fe^ft*@!^29~m1d$FaOb0WMqA-7WA#)!Gy#T)W+OI?x+(?ye@)gRIg zn(~J2wA2;3u=!bA9vR^UHmQid@dkO3XZ_BdS4glNi&3G>L{<0L|u~RnFKTA9pqN?&aSh))Ce);L-+lIeuCoJ^@*9y$PCjw}B z>T1=k_5ry;-Hl^eyYSA}KKz>Zz`*f%$*=y*wMdLicnY|`R~R{*P3Wd(2>9v5p9QHr zVzfmLMdAHd9OU7_GX85)4Alc)9$w{<>D=p1!k|u;G$E}P(xgxO@`zCpNy4)V`PTon z;OHF60Qo%!VLK2Tj|S=kNGZ_I=Jft1q=m!Ec_K--%y&>uyGQC~Jw| zNpGhddFn%M<2VGx16(`^%zyvY^sM5trCSbHLoSb^=j5%%LB+NByBL2ghig?G_*wEi zubIRzkKE;Q@L!3)8AlU&xQ+jg;{_)RZ@S^mIFZPb9zoeKhX-*G46iy#spZom>OnN_ z6b_H^q$g9JdxKBEG00QC`lYmE$N^8^@#U;F$jfT{tuQXAH~j0ByzMe^@_Zii=9D>~ zbS?e0dDL~UkMj2;%{*3@E4i8K4(eL!C_h|bO%>MDr4ptcCBl~Hyd4-p56Z9M!f;X$ b{}29Wmp}%~Qpn-+>;7kF^Z(_pcnSP}JXIYh diff --git a/cb-tools/SuperWebSocket/SuperSocket.SocketBase.pdb b/cb-tools/SuperWebSocket/SuperSocket.SocketBase.pdb deleted file mode 100644 index f877f7096dba2fafe166f963fdf7a565e6a29349..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 196096 zcmeFa31C#!{r)`zID!%cSp-E55H`UOc2r~yo3aV0s3?SFfJjJU5)c%1RKyJ}wN$av ziWZgDrKo7FqT-H9TdLG*%db^SE4FIUT5DUX@8>zoojY-mNl4#+DI9q6%)R$}&iCxw zJ?GqU#f24Rm1VQ4vWNE=-mP2qxXB~4`{(B6WgT*4{^SUzp7|fSEE=>|t$>bJD}-J^fFIP2iC^FP|;1@)|Ymv87ddsV;X z#Sid46a(qXzuLKQ{jX~UsXFgN!93vA-Ne9w^xsXy>?gNBpLyEg7Y}~D+49MejT63K z?~dn3oO~44B=UK~F;C98yL8CUi>?^^=+0qVFR!@g zR@Y^W{J!s~pFVNKkZ3`-cYg7w+uyrwKZ(A5KA3ft{#QnyX!Gfv*FW^!xGv}SZV~-- zpO2XRBmi}l{@<>6@40nLn+-Yp)KmXHzGPMV{UrMK`C!&n`VYIK|MW8kE?6V7@4Z=9>7Ud0wtpToXYe;#Z`!c+(UY&}xZg+YJ`w?SmHu6q zJ^$;&#@sZn-F^Lkd2s%m@9rbP-|xLySLwguhVi%D+TxaX-~at@SN)<%qX+lpv$)CPB=;I&nBf;PAy;)c3|JmkYIXyq#`P%x!wyr4XnxDPj zN9;Zl0d?v)piKwf|L$k$|3KBp!-~3n>wzQc-G9j~ zWmmnjj|6|e_hwy{|9VR|-t$?HQ47y+xqbEDPw8~bejl;>NCebX`d@uY&qF?G_H@5n zetG`ymb~(M)jksZ{ob2(mHzEdeQ)QqvAH4I3==jqbZ#l63b3aS}CSN>w@!iYD_c>}&>8bUnlrGvwg1_H;v#!ej z(_K4lyLs-bBMb6xe(t@>)_3ps5xb8>KwYJOo4?;P>TgHC_1u}Qmwq_1@jG4jk>Kz5 z-mI(iuYc$hzsSAoh~i)WWm~s{^0&Od-$(2|5&?CU{x2WY_t}#3j&1a_;nS{s;gBvH z_mSZ5_uj0l^lw_V?d6B+9a((r9q*i9I{3_-{XSy%kqD@(^xr)DuwTqAU-!ySdX0Mc zyx~0#)c^Zg`u}ip!L46zdE@POpSyVJ!N1>iVE$)6OaFU+H~yh#j+@YC{@bVg^yRnK zzqOA9f4}!;UDf|HUwg5AgJHK^a_!&|iwdR`U%TH&>^>3!b(Q`<9=`DMhuXjKS(oX* zJ^TA(M-JXcg1_H;v#!$r4NiH|r|>PrQBV_Kh9C|8eIl?mlwd*>_&I-$(2|5&?CU{>P55 zdapy9!pd!er z;}d{7BPW4@;AAieXn-;V3i4}pildhiH%6#N)G27Uq_2TyEo|_yT+hba-zR z)C2WF18@+CfrCLq&^1XW-@SO6{r z3&A3A5x5vE2A2SRqeI{9NPx@172ry+1Y8A{f~&zb;977UxE?G6H-H<#H^6f6O>h(V z7FYqk4Q>XvfR*4@@Evd)SOvZdz6WjxtHB-MPVjxO2HXYi2KRuq;0NG`;9hVaxF0+K zegxKm2f;(&VXz)N0v-iF29JTCfXBfTU;}s(JOws_XTY=IIq*Dq0lWxa0-M0g;1%#H z*bIIOeg=LHwt!!N*TCywEBGaN1N;iS3El#~2EPH@!2f~Yg5QC+!8_nx@O!Ww`~mzC zya#rG_ragQpTP&1^HkE7zsv!(O?WX1)K`Tf^lFxm;fe%)4(J!8B77EgEPRHU@ABZoDI$a z)4;jlJTM*100m$sC^1XW-@SO6{r3&A3A z5x5vE2A6$f6JOmyF z>%k-7QSf8%82AZz96SLwfG5FI;AyZCJOiEu&w=N`3*bfY64(S@2Cslu!DjGN@H6mp zum$`AyarweTfr~E8{k*qP4E`@HTVtK2L2EH7W@vp4c-Cog5QJf;1A%B;61Pdybt~a z{tP|Vf*80XPW6 zz`>v)Xao)chl0i+3p4?TfmYxM&>FM>M}nh3TW~Zu1{@2rK|9bMbO1S^D>x2x1Kq*# zAQ$uid7vlg1$u)%pfBhL`hx-B1aKla2@C`$gF#?07y^cZVPH7O2P42pFba$YW56lk zR4^8d1LMI2FcF*vCV|Od3OF5{0nP+d!CByJa1NLT&IRXz>0kyZ05d@$C<1XX3(N*{ zKruKU%mpQ29w-H6-~vzqDnS*P4;Fw6L3fg^7@QC0f)X$fl!ApkUj!}!7lXy%5^yQF z3{0hte;N!U?T3RKz)c{*v&%tK&uP1 z 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
{Yt*?|gd;(z`z^4g++2s6L6zYi|rh`Ot^PHyU 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 Q{auo#cY|I2UrEFN50a)k=>Px# diff --git a/cb-tools/SuperWebSocket/SuperSocket.SocketBase.xml b/cb-tools/SuperWebSocket/SuperSocket.SocketBase.xml deleted file mode 100644 index 61d2d077..00000000 --- a/cb-tools/SuperWebSocket/SuperSocket.SocketBase.xml +++ /dev/null @@ -1,4974 +0,0 @@ - - - - SuperSocket.SocketBase - - - - - Async extension class - - - - - Runs the specified task. - - The log provider. - The task. - - - - - Runs the specified task. - - The log provider. - The task. - The task option. - - - - - Runs the specified task. - - The log provider. - The task. - The exception handler. - - - - - Runs the specified task. - - The log provider. - The task. - The task option. - The exception handler. - - - - - Runs the specified task. - - The log provider. - The task. - The state. - - - - - Runs the specified task. - - The log provider. - The task. - The state. - The task option. - - - - - Runs the specified task. - - The log provider. - The task. - The state. - The exception handler. - - - - - Runs the specified task. - - The log provider. - The task. - The state. - The task option. - The exception handler. - - - - - Command Executing Context - - - - - Initializes a new instance of the class. - - The session. - The request info. - The command. - - - - Gets the session. - - - - - Gets the request info. - - - - - Gets the current command. - - - - - Gets or sets a value indicating whether this command executing is cancelled. - - - true if cancel; otherwise, false. - - - - - Command filter attribute - - - - - Called when [command executing]. - - The command context. - - - - Called when [command executed]. - - The command context. - - - - Gets or sets the execution order. - - - The order. - - - - - CommandLoader base class - - - - - Command loader's interface - - - - - Initializes the command loader - - The type of the command. - The root config. - The app server. - - - - - Tries to load commands. - - The commands. - - - - - Occurs when [updated]. - - - - - Occurs when [error]. - - - - - Initializes the command loader - - The type of the command. - The root config. - The app server. - - - - - Tries to load commands. - - The commands. - - - - - Called when [updated]. - - The commands. - - - - Called when [error]. - - The message. - - - - Called when [error]. - - The e. - - - - Occurs when [updated]. - - - - - Occurs when [error]. - - - - - CommandUpdateEventArgs - - - - - - Initializes a new instance of the class. - - The commands. - - - - Gets the commands updated. - - - - - Command assembly config - - - - - The basic interface for command assembly config - - - - - Gets the assembly name. - - - The assembly. - - - - - Gets or sets the assembly name. - - - The assembly. - - - - - Configuration source interface - - - - - The root configuration interface - - - - - Gets the child config. - - The type of the config. - Name of the child config. - - - - - Gets the max working threads. - - - - - Gets the min working threads. - - - - - Gets the max completion port threads. - - - - - Gets the min completion port threads. - - - - - Gets a value indicating whether [disable performance data collector]. - - - true if [disable performance data collector]; otherwise, false. - - - - - Gets the performance data collect interval, in seconds. - - - - - Gets the log factory name. - - - The log factory. - - - - - Gets the isolation mode. - - - - - Gets the option elements. - - - - - Gets the servers definitions. - - - - - Gets the appServer types definition. - - - - - Gets the connection filters definition. - - - - - Gets the log factories definition. - - - - - Gets the Receive filter factories definition. - - - - - Gets the command loaders definition. - - - - - TypeProvider's interface - - - - - Gets the name. - - - - - Gets the type. - - - - - Poco configuration source - - - - - Root configuration model - - - - - Initializes a new instance of the class. - - The root config. - - - - Initializes a new instance of the class. - - - - - Gets the child config. - - The type of the config. - Name of the child config. - - - - - Gets/Sets the max working threads. - - - - - Gets/sets the min working threads. - - - - - Gets/sets the max completion port threads. - - - - - Gets/sets the min completion port threads. - - - - - Gets/sets the performance data collect interval, in seconds. - - - - - Gets/sets a value indicating whether [disable performance data collector]. - - - true if [disable performance data collector]; otherwise, false. - - - - - Gets/sets the isolation mode. - - - - - Gets/sets the log factory name. - - - The log factory. - - - - - Gets/sets the option elements. - - - - - Initializes a new instance of the class. - - - - - Initializes a new instance of the class. - - The source. - - - - Gets the servers definitions. - - - - - Gets/sets the server types definition. - - - - - Gets/sets the connection filters definition. - - - - - Gets/sets the log factories definition. - - - - - Gets/sets the Receive filter factories definition. - - - - - Gets/sets the command loaders definition. - - - - - Type provider configuration - - - - - Gets the name. - - - - - Gets the type. - - - - - Type provider colletion configuration - - - - - When overridden in a derived class, creates a new . - - - A new . - - - - - Gets the element key for a specified configuration element when overridden in a derived class. - - The to return the key for. - - An that acts as the key for the specified . - - - - - Returns an enumerator that iterates through the collection. - - - A that can be used to iterate through the collection. - - - - - TypeProviderConfig - - - - - Gets the name. - - - - - Gets the type. - - - - - Display attribute - - - - - Initializes a new instance of the class. - - - - - Initializes a new instance of the class. - - The name. - - - - Gets the name. - - - - - Gets or sets the short name. - - - The short name. - - - - - Gets or sets the format. - - - The format. - - - - - Gets or sets the order. - - - The order. - - - - - Gets or sets a value indicating whether [output in perf log]. - - - true if [output in perf log]; otherwise, false. - - - - - Extensions class for SocketBase project - - - - - Gets the app server instance in the bootstrap by name, ignore case - - The bootstrap. - The name of the appserver instance. - - - - - - The bootstrap start result - - - - - No appserver has been set in the bootstrap, so nothing was started - - - - - All appserver instances were started successfully - - - - - Some appserver instances were started successfully, but some of them failed - - - - - All appserver instances failed to start - - - - - SuperSocket bootstrap - - - - - Initializes the bootstrap with the configuration - - - - - - Initializes the bootstrap with a listen endpoint replacement dictionary - - The listen end point replacement. - - - - - Initializes the bootstrap with the configuration - - The server config resolver. - - - - - Initializes the bootstrap with the configuration - - The log factory. - - - - - Initializes the bootstrap with the configuration - - The server config resolver. - The log factory. - - - - - Starts this bootstrap. - - - - - - Stops this bootstrap. - - - - - Gets all the app servers running in this bootstrap - - - - - Gets the config. - - - - - Gets the startup config file. - - - - - The interface for who provides logger - - - - - Gets the logger assosiated with this object. - - - - - AppServer instance running isolation mode - - - - - No isolation - - - - - Isolation by AppDomain - - - - - An item can be started and stopped - - - - - Setups with the specified root config. - - The bootstrap. - The socket server instance config. - The factories. - - - - - Starts this server instance. - - return true if start successfull, else false - - - - Stops this server instance. - - - - - Collects the server summary. - - The node summary. - - - - - Gets the name. - - - - - Gets the current state of the work item. - - - The state. - - - - - Gets the total session count. - - - - - Gets the state of the server. - - - The state of the server. - - - - - Console Log - - - - - Log interface - - - - - Logs the debug message. - - The message. - - - - Logs the debug message. - - The message. - The exception. - - - - Logs the debug message. - - The format. - The arg0. - - - - Logs the debug message. - - The format. - The args. - - - - Logs the debug message. - - The provider. - The format. - The args. - - - - Logs the debug message. - - The format. - The arg0. - The arg1. - - - - Logs the debug message. - - The format. - The arg0. - The arg1. - The arg2. - - - - Logs the error message. - - The message. - - - - Logs the error message. - - The message. - The exception. - - - - Logs the error message. - - The format. - The arg0. - - - - Logs the error message. - - The format. - The args. - - - - Logs the error message. - - The provider. - The format. - The args. - - - - Logs the error message. - - The format. - The arg0. - The arg1. - - - - Logs the error message. - - The format. - The arg0. - The arg1. - The arg2. - - - - Logs the fatal error message. - - The message. - - - - Logs the fatal error message. - - The message. - The exception. - - - - Logs the fatal error message. - - The format. - The arg0. - - - - Logs the fatal error message. - - The format. - The args. - - - - Logs the fatal error message. - - The provider. - The format. - The args. - - - - Logs the fatal error message. - - The format. - The arg0. - The arg1. - - - - Logs the fatal error message. - - The format. - The arg0. - The arg1. - The arg2. - - - - Logs the info message. - - The message. - - - - Logs the info message. - - The message. - The exception. - - - - Logs the info message. - - The format. - The arg0. - - - - Logs the info message. - - The format. - The args. - - - - Logs the info message. - - The provider. - The format. - The args. - - - - Logs the info message. - - The format. - The arg0. - The arg1. - - - - Logs the info message. - - The format. - The arg0. - The arg1. - The arg2. - - - - Logs the warning message. - - The message. - - - - Logs the warning message. - - The message. - The exception. - - - - Logs the warning message. - - The format. - The arg0. - - - - Logs the warning message. - - The format. - The args. - - - - Logs the warning message. - - The provider. - The format. - The args. - - - - Logs the warning message. - - The format. - The arg0. - The arg1. - - - - Logs the warning message. - - The format. - The arg0. - The arg1. - The arg2. - - - - Gets a value indicating whether this instance is debug enabled. - - - true if this instance is debug enabled; otherwise, false. - - - - - Gets a value indicating whether this instance is error enabled. - - - true if this instance is error enabled; otherwise, false. - - - - - Gets a value indicating whether this instance is fatal enabled. - - - true if this instance is fatal enabled; otherwise, false. - - - - - Gets a value indicating whether this instance is info enabled. - - - true if this instance is info enabled; otherwise, false. - - - - - Gets a value indicating whether this instance is warn enabled. - - - true if this instance is warn enabled; otherwise, false. - - - - - Initializes a new instance of the class. - - The name. - - - - Logs the debug message. - - The message. - - - - Logs the debug message. - - The message. - The exception. - - - - Logs the debug message. - - The format. - The arg0. - - - - Logs the debug message. - - The format. - The args. - - - - Logs the debug message. - - The provider. - The format. - The args. - - - - Logs the debug message. - - The format. - The arg0. - The arg1. - - - - Logs the debug message. - - The format. - The arg0. - The arg1. - The arg2. - - - - Logs the error message. - - The message. - - - - Logs the error message. - - The message. - The exception. - - - - Logs the error message. - - The format. - The arg0. - - - - Logs the error message. - - The format. - The args. - - - - Logs the error message. - - The provider. - The format. - The args. - - - - Logs the error message. - - The format. - The arg0. - The arg1. - - - - Logs the error message. - - The format. - The arg0. - The arg1. - The arg2. - - - - Logs the fatal error message. - - The message. - - - - Logs the fatal error message. - - The message. - The exception. - - - - Logs the fatal error message. - - The format. - The arg0. - - - - Logs the fatal error message. - - The format. - The args. - - - - Logs the fatal error message. - - The provider. - The format. - The args. - - - - Logs the fatal error message. - - The format. - The arg0. - The arg1. - - - - Logs the fatal error message. - - The format. - The arg0. - The arg1. - The arg2. - - - - Logs the info message. - - The message. - - - - Logs the info message. - - The message. - The exception. - - - - Logs the info message. - - The format. - The arg0. - - - - Logs the info message. - - The format. - The args. - - - - Logs the info message. - - The provider. - The format. - The args. - - - - Logs the info message. - - The format. - The arg0. - The arg1. - - - - Logs the info message. - - The format. - The arg0. - The arg1. - The arg2. - - - - Logs the warning message. - - The message. - - - - Logs the warning message. - - The message. - The exception. - - - - Logs the warning message. - - The format. - The arg0. - - - - Logs the warning message. - - The format. - The args. - - - - Logs the warning message. - - The provider. - The format. - The args. - - - - Logs the warning message. - - The format. - The arg0. - The arg1. - - - - Logs the warning message. - - The format. - The arg0. - The arg1. - The arg2. - - - - Gets a value indicating whether this instance is debug enabled. - - - true if this instance is debug enabled; otherwise, false. - - - - - Gets a value indicating whether this instance is error enabled. - - - true if this instance is error enabled; otherwise, false. - - - - - Gets a value indicating whether this instance is fatal enabled. - - - true if this instance is fatal enabled; otherwise, false. - - - - - Gets a value indicating whether this instance is info enabled. - - - true if this instance is info enabled; otherwise, false. - - - - - Gets a value indicating whether this instance is warn enabled. - - - true if this instance is warn enabled; otherwise, false. - - - - - Console log factory - - - - - LogFactory Interface - - - - - Gets the log by name. - - The name. - - - - - Gets the log by name. - - The name. - - - - - Log4NetLog - - - - - Initializes a new instance of the class. - - The log. - - - - Logs the debug message. - - The message. - - - - Logs the debug message. - - The message. - The exception. - - - - Logs the debug message. - - The format. - The arg0. - - - - Logs the debug message. - - The format. - The args. - - - - Logs the debug message. - - The provider. - The format. - The args. - - - - Logs the debug message. - - The format. - The arg0. - The arg1. - - - - Logs the debug message. - - The format. - The arg0. - The arg1. - The arg2. - - - - Logs the error message. - - The message. - - - - Logs the error message. - - The message. - The exception. - - - - Logs the error message. - - The format. - The arg0. - - - - Logs the error message. - - The format. - The args. - - - - Logs the error message. - - The provider. - The format. - The args. - - - - Logs the error message. - - The format. - The arg0. - The arg1. - - - - Logs the error message. - - The format. - The arg0. - The arg1. - The arg2. - - - - Logs the fatal error message. - - The message. - - - - Logs the fatal error message. - - The message. - The exception. - - - - Logs the fatal error message. - - The format. - The arg0. - - - - Logs the fatal error message. - - The format. - The args. - - - - Logs the fatal error message. - - The provider. - The format. - The args. - - - - Logs the fatal error message. - - The format. - The arg0. - The arg1. - - - - Logs the fatal error message. - - The format. - The arg0. - The arg1. - The arg2. - - - - Logs the info message. - - The message. - - - - Logs the info message. - - The message. - The exception. - - - - Logs the info message. - - The format. - The arg0. - - - - Logs the info message. - - The format. - The args. - - - - Logs the info message. - - The provider. - The format. - The args. - - - - Logs the info message. - - The format. - The arg0. - The arg1. - - - - Logs the info message. - - The format. - The arg0. - The arg1. - The arg2. - - - - Logs the warning message. - - The message. - - - - Logs the warning message. - - The message. - The exception. - - - - Logs the warning message. - - The format. - The arg0. - - - - Logs the warning message. - - The format. - The args. - - - - Logs the warning message. - - The provider. - The format. - The args. - - - - Logs the warning message. - - The format. - The arg0. - The arg1. - - - - Logs the warning message. - - The format. - The arg0. - The arg1. - The arg2. - - - - Gets a value indicating whether this instance is debug enabled. - - - true if this instance is debug enabled; otherwise, false. - - - - - Gets a value indicating whether this instance is error enabled. - - - true if this instance is error enabled; otherwise, false. - - - - - Gets a value indicating whether this instance is fatal enabled. - - - true if this instance is fatal enabled; otherwise, false. - - - - - Gets a value indicating whether this instance is info enabled. - - - true if this instance is info enabled; otherwise, false. - - - - - Gets a value indicating whether this instance is warn enabled. - - - true if this instance is warn enabled; otherwise, false. - - - - - Log4NetLogFactory - - - - - LogFactory Base class - - - - - Initializes a new instance of the class. - - The config file. - - - - Gets the log by name. - - The name. - - - - - Gets the config file file path. - - - - Gets a value indicating whether the server instance is running in isolation mode and the multiple server instances share the same logging configuration. - - - - Initializes a new instance of the class. - - - - - Initializes a new instance of the class. - - The log4net config. - - - - Gets the log by name. - - The name. - - - - - GlobalPerformanceData class - - - - - Gets or sets the cpu usage. - - - The cpu usage. - - - - - Gets or sets the working set. - - - The working set. - - - - - Gets or sets the total thread count. - - - The total thread count. - - - - - Gets or sets the available working threads. - - - The available working threads. - - - - - Gets or sets the available completion port threads. - - - The available completion port threads. - - - - - Gets or sets the max working threads. - - - The max working threads. - - - - - Gets or sets the max completion port threads. - - - The max completion port threads. - - - - - CommandLine RequestFilter Factory - - - - - Terminator ReceiveFilter Factory - - - - - Receive filter factory interface - - The type of the request info. - - - - Receive filter factory interface - - - - - Creates the Receive filter. - - The app server. - The app session. - The remote end point. - - the new created request filer assosiated with this socketSession - - - - - Initializes a new instance of the class. - - The terminator. - - - - Initializes a new instance of the class. - - The terminator. - The encoding. - - - - Initializes a new instance of the class. - - The terminator. - The encoding. - The line parser. - - - - Creates the Receive filter. - - The app server. - The app session. - The remote end point. - - the new created request filer assosiated with this socketSession - - - - - Initializes a new instance of the class. - - - - - Initializes a new instance of the class. - - The encoding. - - - - Initializes a new instance of the class. - - The encoding. - The request info parser. - - - - DefaultreceiveFilterFactory - - The type of the Receive filter. - The type of the request info. - - - - Creates the Receive filter. - - The app server. - The app session. - The remote end point. - - the new created request filer assosiated with this socketSession - - - - - Filter state enum - - - - - Normal state - - - - - Error state - - - - - The interface for a Receive filter to adapt receiving buffer offset - - - - - Gets the offset delta. - - - - - Receive filter interface - - The type of the request info. - - - - Filters received data of the specific session into request info. - - The read buffer. - The offset of the current received data in this read buffer. - The length of the current received data. - if set to true [to be copied]. - The rest, the length of the data which hasn't been parsed. - - - - - Resets this instance to initial state. - - - - - Gets the size of the rest buffer. - - - The size of the rest buffer. - - - - - Gets the next Receive filter. - - - - - Gets the filter state. - - - The filter state. - - - - - Provide the initializing interface for ReceiveFilter - - - - - Initializes the ReceiveFilter with the specified appServer and appSession - - The app server. - The session. - - - - Receive filter base class - - The type of the request info. - - - - Initializes a new instance of the class. - - - - - Initializes a new instance of the class. - - The previous Receive filter. - - - - Initializes the specified previous Receive filter. - - The previous Receive filter. - - - - Filters received data of the specific session into request info. - - The read buffer. - The offset of the current received data in this read buffer. - The length of the current received data. - if set to true [to be copied]. - The rest, the length of the data which hasn't been parsed. - - - - - Gets the rest buffer. - - - - - - Adds the array segment. - - The buffer. - The offset. - The length. - if set to true [to be copied]. - - - - Clears the buffer segments. - - - - - Resets this instance to initial state. - - - - - Gets the buffer segments which can help you parse your request info conviniently. - - - - - Gets the size of the rest buffer. - - - The size of the rest buffer. - - - - - Gets or sets the next Receive filter. - - - The next Receive filter. - - - - - Gets the filter state. - - - The state. - - - - - Terminator Receive filter - - The type of the request info. - - - - Null RequestInfo - - - - - Initializes a new instance of the class. - - The terminator. - - - - Filters received data of the specific session into request info. - - The read buffer. - The offset of the current received data in this read buffer. - The length of the current received data. - if set to true [to be copied]. - The rest, the length of the data which hasn't been parsed. - return the parsed TRequestInfo - - - - Resets this instance. - - - - - Resolves the specified data to TRequestInfo. - - The data. - The offset. - The length. - - - - - Gets the session assosiated with the Receive filter. - - - - - TerminatorRequestFilter - - - - - Initializes a new instance of the class. - - The terminator. - The encoding. - - - - Initializes a new instance of the class. - - The terminator. - The encoding. - The request parser. - - - - Resolves the specified data to StringRequestInfo. - - The data. - The offset. - The length. - - - - - Export Factory - - - - - Initializes a new instance of the class. - - - - - Initializes a new instance of the class. - - The instance. - - - - Initializes a new instance of the class. - - Name of the type. - - - - Ensures the instance's existance. - - - - - Creates the export type instance. - - - - - - - Gets or sets the type. - - - The type. - - - - - Provider factory infomation - - - - - Initializes a new instance of the class. - - - - - Initializes a new instance of the class. - - The key. - The name. - The instance. - - - - Initializes a new instance of the class. - - The key. - The name. - Name of the type. - - - - Initializes a new instance of the class. - - The key. - The name. - The type. - - - - Gets the key. - - - - - Gets or sets the name. - - - The name. - - - - - Gets or sets the export factory. - - - The export factory. - - - - - ProviderKey - - - - - Gets or sets the name. - - - The name. - - - - - Gets or sets the type. - - - The type. - - - - - Gets the service. - - - - - Gets the socket server factory. - - - - - Gets the connection filter. - - - - - Gets the log factory. - - - - - Gets the Receive filter factory. - - - - - Gets the command loader. - - - - - Request handler - - The type of the app session. - The type of the request info. - The session. - The request info. - - - - The listener configuration interface - - - - - Gets the ip of listener - - - - - Gets the port of listener - - - - - Gets the backlog. - - - - - Gets the security option, None/Default/Tls/Ssl/... - - - - - Listener configuration model - - - - - Initializes a new instance of the class. - - - - - Gets the ip of listener - - - - - Gets the port of listener - - - - - Gets the backlog. - - - - - Gets/sets the security option, None/Default/Tls/Ssl/... - - - - - Listener inforamtion - - - - - Gets or sets the listen endpoint. - - - The end point. - - - - - Gets or sets the listen backlog. - - - The back log. - - - - - Gets or sets the security protocol. - - - The security. - - - - - Binary type request information - - - - - RequestInfo basic class - - The type of the request body. - - - - Request information interface - - The type of the request body. - - - - Request information interface - - - - - Gets the key of this request. - - - - - Gets the body of this request. - - - - - Initializes a new instance of the class. - - - - - Initializes a new instance of the class. - - The key. - The body. - - - - Initializes the specified key. - - The key. - The body. - - - - Gets the key of this request. - - - - - Gets the body. - - - - - Initializes a new instance of the class. - - The key. - The body. - - - - Command base class - - The type of the app session. - The type of the request info. - - - - Command basic interface - - The type of the app session. - The type of the request info. - - - - Command basic interface - - - - - Gets the name. - - - - - Executes the command. - - The session. - The request info. - - - - Executes the command. - - The session. - The request info. - - - - Returns a that represents this instance. - - - A that represents this instance. - - - - - Gets the name. - - - - - Command update action enum - - - - - Add command - - - - - Remove command - - - - - Update command - - - - - Command update information - - - - - - Gets or sets the update action. - - - The update action. - - - - - Gets or sets the target command. - - - The command. - - - - - Mockup command - - The type of the app session. - The type of the request info. - - - - Initializes a new instance of the class. - - The name. - - - - Executes the command. - - The session. - The request info. - - - - Gets the name. - - - - - Request information interface - - The type of the request header. - The type of the request body. - - - - Gets the header of the request. - - - - - RequestInfo with header - - The type of the request header. - The type of the request body. - - - - Initializes a new instance of the class. - - - - - Initializes a new instance of the class. - - The key. - The header. - The body. - - - - Initializes the specified key. - - The key. - The header. - The body. - - - - Gets the header. - - - - - String type request information - - - - - Initializes a new instance of the class. - - The key. - The body. - The parameters. - - - - Gets the first param. - - - - - - Gets the parameters. - - - - - Gets the at the specified index. - - - - - A command loader which loads commands from assembly by reflection - - - - - Initializes the command loader - - The type of the command. - The root config. - The app server. - - - - - Tries to load commands. - - The commands. - - - - - UdpRequestInfo, it is designed for passing in business session ID to udp request info - - - - - Initializes a new instance of the class. - - The key. - The session ID. - - - - Gets the key of this request. - - - - - Gets the session ID. - - - - - Certificate config model class - - - - - Certificate configuration interface - - - - - Gets the file path. - - - - - Gets the password. - - - - - Gets the the store where certificate locates. - - - The name of the store. - - - - - Gets the thumbprint. - - - - - Gets/sets the file path. - - - - - Gets/sets the password. - - - - - Gets/sets the the store where certificate locates. - - - The name of the store. - - - - - Gets/sets the thumbprint. - - - - - Server instance configuation interface - - - - - Gets the child config. - - The type of the config. - Name of the child config. - - - - - Gets the name of the server type this appServer want to use. - - - The name of the server type. - - - - - Gets the type definition of the appserver. - - - The type of the server. - - - - - Gets the Receive filter factory. - - - - - Gets the ip. - - - - - Gets the port. - - - - - Gets the options. - - - - - Gets the option elements. - - - - - Gets a value indicating whether this is disabled. - - - true if disabled; otherwise, false. - - - - - Gets the name. - - - - - Gets the mode. - - - - - Gets the send time out. - - - - - Gets the max connection number. - - - - - Gets the size of the receive buffer. - - - The size of the receive buffer. - - - - - Gets the size of the send buffer. - - - The size of the send buffer. - - - - - Gets a value indicating whether sending is in synchronous mode. - - - true if [sync send]; otherwise, false. - - - - - Gets a value indicating whether log command in log file. - - true if log command; otherwise, false. - - - - Gets a value indicating whether clear idle session. - - true if clear idle session; otherwise, false. - - - - Gets the clear idle session interval, in seconds. - - The clear idle session interval. - - - - Gets the idle session timeout time length, in seconds. - - The idle session time out. - - - - Gets X509Certificate configuration. - - X509Certificate configuration. - - - - Gets the security protocol, X509 certificate. - - - - - Gets the length of the max request. - - - The length of the max request. - - - - - Gets a value indicating whether [disable session snapshot]. - - - true if [disable session snapshot]; otherwise, false. - - - - - Gets the interval to taking snapshot for all live sessions. - - - - - Gets the connection filters used by this server instance. - - - The connection filter's name list, seperated by comma - - - - - Gets the command loader, multiple values should be separated by comma. - - - - - Gets the start keep alive time, in seconds - - - - - Gets the keep alive interval, in seconds. - - - - - Gets the backlog size of socket listening. - - - - - Gets the startup order of the server instance. - - - - - Gets the listeners' configuration. - - - - - Gets the log factory name. - - - - - Gets the size of the sending queue. - - - The size of the sending queue. - - - - - Gets a value indicating whether [log basic session activity like connected and disconnected]. - - - true if [log basic session activity]; otherwise, false. - - - - - Gets a value indicating whether [log all socket exception]. - - - true if [log all socket exception]; otherwise, false. - - - - - Gets the command assemblies configuration. - - - The command assemblies. - - - - - Server configruation model - - - - - Default ReceiveBufferSize - - - - - Default MaxConnectionNumber - - - - - Default sending queue size - - - - - Default MaxRequestLength - - - - - Initializes a new instance of the class. - - The server config. - - - - Initializes a new instance of the class. - - - - - Gets the child config. - - The type of the config. - Name of the child config. - - - - - Gets/sets the name of the server type of this appServer want to use. - - - The name of the server type. - - - - - Gets/sets the type definition of the appserver. - - - The type of the server. - - - - - Gets/sets the Receive filter factory. - - - - - Gets/sets the ip. - - - - - Gets/sets the port. - - - - - Gets/sets the options. - - - - - Gets the option elements. - - - - - Gets/sets a value indicating whether this is disabled. - - - true if disabled; otherwise, false. - - - - - Gets the name. - - - - - Gets/sets the mode. - - - - - Gets/sets the send time out. - - - - - Gets the max connection number. - - - - - Gets the size of the receive buffer. - - - The size of the receive buffer. - - - - - Gets the size of the send buffer. - - - The size of the send buffer. - - - - - Gets a value indicating whether sending is in synchronous mode. - - - true if [sync send]; otherwise, false. - - - - - Gets/sets a value indicating whether log command in log file. - - - true if log command; otherwise, false. - - - - - Gets/sets a value indicating whether clear idle session. - - - true if clear idle session; otherwise, false. - - - - - Gets/sets the clear idle session interval, in seconds. - - - The clear idle session interval. - - - - - Gets/sets the idle session timeout time length, in seconds. - - - The idle session time out. - - - - - Gets/sets X509Certificate configuration. - - - X509Certificate configuration. - - - - - Gets/sets the security protocol, X509 certificate. - - - - - Gets/sets the length of the max request. - - - The length of the max request. - - - - - Gets/sets a value indicating whether [disable session snapshot]. - - - true if [disable session snapshot]; otherwise, false. - - - - - Gets/sets the interval to taking snapshot for all live sessions. - - - - - Gets/sets the connection filters used by this server instance. - - - The connection filter's name list, seperated by comma - - - - - Gets the command loader, multiple values should be separated by comma. - - - - - Gets/sets the start keep alive time, in seconds - - - - - Gets/sets the keep alive interval, in seconds. - - - - - Gets the backlog size of socket listening. - - - - - Gets/sets the startup order of the server instance. - - - - - Gets and sets the listeners' configuration. - - - - - Gets/sets the log factory name. - - - - - Gets/sets the size of the sending queue. - - - The size of the sending queue. - - - - - Gets a value indicating whether [log basic session activity like connected and disconnected]. - - - true if [log basic session activity]; otherwise, false. - - - - - Gets/sets a value indicating whether [log all socket exception]. - - - true if [log all socket exception]; otherwise, false. - - - - - Gets the command assemblies configuration. - - - The command assemblies. - - - - - The interface for AppServer - - - - - Creates the app session. - - The socket session. - - - - - Gets the app session by ID. - - The session ID. - - - - - Resets the session's security protocol. - - The session. - The security protocol. - - - - Gets the started time. - - - The started time. - - - - - Gets or sets the listeners. - - - The listeners. - - - - - Gets the Receive filter factory. - - - - - Gets the server's config. - - - - - Gets the certificate of current server. - - - - - Gets the transfer layer security protocol. - - - - - Gets the log factory. - - - - - The raw data processor - - The type of the app session. - - - - Gets or sets the raw binary data received event handler. - TAppSession: session - byte[]: receive buffer - int: receive buffer offset - int: receive lenght - bool: whether process the received data further - - - - - The interface for AppServer - - The type of the app session. - - - - Gets the matched sessions from sessions snapshot. - - The prediction critera. - - - - - Gets all sessions in sessions snapshot. - - - - - - Gets/sets the new session connected event handler. - - - - - Gets/sets the session closed event handler. - - - - - The interface for AppServer - - The type of the app session. - The type of the request info. - - - - Occurs when [request comming]. - - - - - The interface for handler of session request - - The type of the request info. - - - - Executes the command. - - The session. - The request info. - - - - SocketServer Accessor interface - - - - - Gets the socket server. - - - The socket server. - - - - - The basic interface for appSession - - - - - The basic session interface - - - - - Gets the session ID. - - - - - Gets the remote endpoint. - - - - - Closes this session. - - - - - Closes the session by the specified reason. - - The close reason. - - - - Processes the request. - - The read buffer. - The offset. - The length. - if set to true [to be copied]. - return offset delta of next receiving buffer - - - - Starts the session. - - - - - Gets the app server. - - - - - Gets the socket session of the AppSession. - - - - - Gets the items. - - - - - Gets the config of the server. - - - - - Gets the local listening endpoint. - - - - - Gets or sets the last active time of the session. - - - The last active time. - - - - - Gets the start time of the session. - - - - - Gets a value indicating whether this is connected. - - - true if connected; otherwise, false. - - - - - Gets or sets the charset which is used for transfering text message. - - The charset. - - - - Gets or sets the previous command. - - - The prev command. - - - - - Gets or sets the current executing command. - - - The current command. - - - - - Gets the logger assosiated with this session. - - - - - The interface for appSession - - The type of the app session. - The type of the request info. - - - - Initializes the specified session. - - The server. - The socket session. - - - - The basic interface of connection filter - - - - - Initializes the connection filter - - The name. - The app server. - - - - - Whether allows the connect according the remote endpoint - - The remote address. - - - - - Gets the name of the filter. - - - - - It is the basic interface of SocketServer, - SocketServer is the abstract server who really listen the comming sockets directly. - - - - - Starts this instance. - - - - - - Resets the session's security protocol. - - The session. - The security protocol. - - - - Stops this instance. - - - - - Gets a value indicating whether this instance is running. - - - true if this instance is running; otherwise, false. - - - - - Gets the information of the sending queue pool. - - - The sending queue pool. - - - - - The interface for socket server factory - - - - - Creates the socket server instance. - - The type of the request info. - The app server. - The listeners. - The config. - - - - - CloseReason enum - - - - - The socket is closed for unknown reason - - - - - Close for server shutdown - - - - - The client close the socket - - - - - The server side close the socket - - - - - Application error - - - - - The socket is closed for a socket error - - - - - The socket is closed by server for timeout - - - - - Protocol error - - - - - SuperSocket internal error - - - - - The interface for socket session - - - - - Initializes the specified app session. - - The app session. - - - - Starts this instance. - - - - - Closes the socket session for the specified reason. - - The reason. - - - - Tries to send array segment. - - The segments. - - - - Tries to send array segment. - - The segment. - - - - Applies the secure protocol. - - - - - Gets the client socket. - - - - - Gets the local listening endpoint. - - - - - Gets or sets the secure protocol. - - - The secure protocol. - - - - - Occurs when [closed]. - - - - - Gets the app session assosiated with this socket session. - - - - - Gets the original receive buffer offset. - - - The original receive buffer offset. - - - - - Logger extension class - - - - - Logs the error - - The logger. - The session. - The title. - The e. - - - - Logs the error - - The logger. - The session. - The message. - - - - Logs the information - - The logger. - The session. - The message. - - - - Logs the debug message - - The logger. - The session. - The message. - - - - Logs the performance message - - The app server. - The message. - - - - Basic request info parser, which parse request info by separating - - - - - The interface for request info parser - - - - - Parses the request info from the source string. - - The source. - - - - - The default singlegton instance - - - - - Initializes a new instance of the class. - - - - - Initializes a new instance of the class. - - The spliter between command name and command parameters. - The parameter spliter. - - - - Parses the request info. - - The source. - - - - - Server's state enum class - - - - - Not initialized - - - - - In initializing - - - - - Has been initialized, but not started - - - - - In starting - - - - - In running - - - - - In stopping - - - - - Server State - - - - - Gets or sets the name. - - - The name. - - - - - Gets or sets the collected time. - - - The collected time. - - - - - Gets or sets the started time. - - - The started time. - - - - - Gets or sets a value indicating whether this instance is running. - - - true if this instance is running; otherwise, false. - - - - - Gets or sets the total count of the connections. - - - The total count of the connections. - - - - - Gets or sets the maximum allowed connection number. - - - The max connection number. - - - - - Gets or sets the total handled requests count. - - - The total handled requests count. - - - - - Gets or sets the request handling speed, per second. - - - The request handling speed. - - - - - Gets or sets the listeners. - - - The listeners. - - - - - Gets or sets the avialable sending queue items. - - - The avialable sending queue items. - - - - - Gets or sets the total sending queue items. - - - The total sending queue items. - - - - - Used for session level event handler - - the type of the target session - the target session - - - - Used for session level event handler - - the type of the target session - the target session - the target session - the event parameter - - - - Socket server running mode - - - - - Tcp mode - - - - - Udp mode - - - - - AppServer class - - - - - AppServer class - - The type of the app session. - - - - AppServer basic class - - The type of the app session. - The type of the request info. - - - - AppServer base class - - The type of the app session. - The type of the request info. - - - - Null appSession instance - - - - - the current state's code - - - - - Initializes a new instance of the class. - - - - - Initializes a new instance of the class. - - The Receive filter factory. - - - - Gets the filter attributes. - - The type. - - - - - Setups the command into command dictionary - - The discovered commands. - - - - - Setups the specified root config. - - The root config. - The config. - - - - - Setups with the specified port. - - The port. - return setup result - - - - Setups with the specified config. - - The server config. - The socket server factory. - The receive filter factory. - The log factory. - The connection filters. - The command loaders. - - - - - Setups the specified root config, this method used for programming setup - - The root config. - The server config. - The socket server factory. - The Receive filter factory. - The log factory. - The connection filters. - The command loaders. - - - - - Setups with the specified ip and port. - - The ip. - The port. - The socket server factory. - The Receive filter factory. - The log factory. - The connection filters. - The command loaders. - return setup result - - - - Setups the specified root config. - - The bootstrap. - The socket server instance config. - The factories. - - - - - Creates the logger for the AppServer. - - Name of the logger. - - - - - Setups the security option of socket communications. - - The config of the server instance. - - - - - Gets the certificate from server configuguration. - - The certificate config. - - - - - Setups the socket server.instance - - - - - - Setups the listeners base on server configuration - - The config. - - - - - Starts this server instance. - - - return true if start successfull, else false - - - - - Called when [startup]. - - - - - Called when [stopped]. - - - - - Stops this server instance. - - - - - Gets command by command name. - - Name of the command. - - - - - Called when [raw data received]. - - The session. - The buffer. - The offset. - The length. - - - - Executes the command. - - The session. - The request info. - - - - Executes the command for the session. - - The session. - The request info. - - - - Executes the command. - - The session. - The request info. - - - - Executes the connection filters. - - The remote address. - - - - - Creates the app session. - - The socket session. - - - - - Registers the session into session container. - - The session ID. - The app session. - - - - - Called when [new session connected]. - - The session. - - - - Resets the session's security protocol. - - The session. - The security protocol. - - - - Called when [socket session closed]. - - The socket session. - The reason. - - - - Called when [session closed]. - - The appSession. - The reason. - - - - Gets the app session by ID. - - The session ID. - - - - - Gets the app session by ID. - - - - - - - Gets the matched sessions from sessions snapshot. - - The prediction critera. - - - - Gets all sessions in sessions snapshot. - - - - - Updates the summary of the server. - - The server summary. - - - - Called when [summary data collected], you can override this method to get collected performance data - - The node summary. - The server summary. - - - - Releases unmanaged and - optionally - managed resources - - - - - Gets the server's config. - - - - - Gets the current state of the work item. - - - The state. - - - - - Gets the certificate of current server. - - - - - Gets or sets the receive filter factory. - - - The receive filter factory. - - - - - Gets the Receive filter factory. - - - - - Gets the basic transfer layer security protocol. - - - - - Gets the root config. - - - - - Gets the logger assosiated with this object. - - - - - Gets the bootstrap of this appServer instance. - - - - - Gets the total handled requests number. - - - - - Gets or sets the listeners inforamtion. - - - The listeners. - - - - - Gets the started time of this server instance. - - - The started time. - - - - - Gets or sets the log factory. - - - The log factory. - - - - - Gets the name of the server instance. - - - - - Gets the socket server. - - - The socket server. - - - - - Gets or sets the raw binary data received event handler. - TAppSession: session - byte[]: receive buffer - int: receive buffer offset - int: receive lenght - bool: whether process the received data further - - - - - Occurs when a full request item received. - - - - - Gets or sets the server's connection filter - - - The server's connection filters - - - - - The action which will be executed after a new session connect - - - - - Gets/sets the session closed event handler. - - - - - Gets the total session count. - - - - - Gets the type of the server state. The type must inherit from ServerState - - - The type of the server state. - - - - - Gets the state of the server. - - - The state data of the server. - - - - - Initializes a new instance of the class. - - - - - Initializes a new instance of the class. - - The protocol. - - - - Starts this AppServer instance. - - - - - - Registers the session into the session container. - - The session ID. - The app session. - - - - - Gets the app session by ID. - - The session ID. - - - - - Called when [socket session closed]. - - The session. - The reason. - - - - Clears the idle session. - - The state. - - - - Gets the matched sessions from sessions snapshot. - - The prediction critera. - - - - - Gets all sessions in sessions snapshot. - - - - - - Stops this instance. - - - - - Gets the total session count. - - - - - Initializes a new instance of the class. - - - - - Initializes a new instance of the class. - - The Receive filter factory. - - - - Initializes a new instance of the class. - - - - - Initializes a new instance of the class. - - The Receive filter factory. - - - - AppSession base class - - The type of the app session. - The type of the request info. - - - - Initializes a new instance of the class. - - - - - Initializes the specified app session by AppServer and SocketSession. - - The app server. - The socket session. - - - - Starts the session. - - - - - Called when [init]. - - - - - Called when [session started]. - - - - - Called when [session closed]. - - The reason. - - - - Handles the exceptional error, it only handles application error. - - The exception. - - - - Handles the unknown request. - - The request info. - - - - Closes the session by the specified reason. - - The close reason. - - - - Closes this session. - - - - - Try to send the message to client. - - The message which will be sent. - Indicate whether the message was pushed into the sending queue - - - - Sends the message to client. - - The message which will be sent. - - - - Sends the response. - - The message which will be sent. - Indicate whether the message was pushed into the sending queue - - - - Try to send the data to client. - - The data which will be sent. - The offset. - The length. - Indicate whether the message was pushed into the sending queue - - - - Sends the data to client. - - The data which will be sent. - The offset. - The length. - - - - Sends the response. - - The data which will be sent. - The offset. - The length. - - - - Try to send the data segment to client. - - The segment which will be sent. - Indicate whether the message was pushed into the sending queue - - - - Sends the data segment to client. - - The segment which will be sent. - - - - Sends the response. - - The segment which will be sent. - Indicate whether the message was pushed into the sending queue; if it returns false, the sending queue may be full or the socket is not connected - - - - Try to send the data segments to clinet. - - The segments. - Indicate whether the message was pushed into the sending queue; if it returns false, the sending queue may be full or the socket is not connected - - - - Sends the data segments to clinet. - - The segments. - - - - Sends the response. - - The segments. - Indicate whether the message was pushed into the sending queue - - - - Sends the response. - - The message which will be sent. - The parameter values. - - - - Sends the response. - - The message which will be sent. - The parameter values. - Indicate whether the message was pushed into the sending queue - - - - Sets the next Receive filter which will be used when next data block received - - The next receive filter. - - - - Filters the request. - - The read buffer. - The offset. - The length. - if set to true [to be copied]. - The rest, the size of the data which has not been processed - return offset delta of next receiving buffer. - - - - - Processes the request data. - - The read buffer. - The offset. - The length. - if set to true [to be copied]. - - return offset delta of next receiving buffer - - - - - Gets the app server instance assosiated with the session. - - - - - Gets the app server instance assosiated with the session. - - - - - Gets or sets the charset which is used for transfering text message. - - - The charset. - - - - - Gets the items dictionary, only support 10 items maximum - - - - - Gets a value indicating whether this is connected. - - - true if connected; otherwise, false. - - - - - Gets or sets the previous command. - - - The prev command. - - - - - Gets or sets the current executing command. - - - The current command. - - - - - Gets or sets the secure protocol of transportation layer. - - - The secure protocol. - - - - - Gets the local listening endpoint. - - - - - Gets the remote endpoint of client. - - - - - Gets the logger. - - - - - Gets or sets the last active time of the session. - - - The last active time. - - - - - Gets the start time of the session. - - - - - Gets the session ID. - - - - - Gets the socket session of the AppSession. - - - - - Gets the config of the server. - - - - - AppServer basic class for whose request infoe type is StringRequestInfo - - The type of the app session. - - - - Initializes a new instance of the class. - - - - - Initializes a new instance of the class. - - if set to true [append new line for response]. - - - - Handles the unknown request. - - The request info. - - - - Processes the sending message. - - The raw message. - - - - - Sends the specified message. - - The message. - - - - - Sends the response. - - The message. - Indicate whether the message was pushed into the sending queue - - - - Sends the response. - - The message. - The param values. - Indicate whether the message was pushed into the sending queue - - - - Sends the response. - - The message. - The param values. - Indicate whether the message was pushed into the sending queue - - - - AppServer basic class for whose request infoe type is StringRequestInfo - - - - - A command type for whose request info type is StringRequestInfo - - The type of the app session. - - - - A command type for whose request info type is StringRequestInfo - - - - diff --git a/cb-tools/SuperWebSocket/SuperSocket.SocketEngine.XML b/cb-tools/SuperWebSocket/SuperSocket.SocketEngine.XML deleted file mode 100644 index 95df68de..00000000 --- a/cb-tools/SuperWebSocket/SuperSocket.SocketEngine.XML +++ /dev/null @@ -1,1045 +0,0 @@ - - - - SuperSocket.SocketEngine - - - - - AppDomainAppServer - - - - - Initializes a new instance of the class. - - Name of the service type. - - - - Initializes a new instance of the class. - - Type of the service. - - - - Setups the specified root config. - - The bootstrap. - The socket server instance config. - The factories. - - - - - Starts this server instance. - - - return true if start successfull, else false - - - - - Stops this server instance. - - - - - Gets the name of the server instance. - - - - - Gets the current state of the work item. - - - The state. - - - - - Gets the total session count. - - - - - Gets the state data of the server. - - - The state of the server. - - - - - AppDomainBootstrap - - - - - Initializes a new instance of the class. - - - - - Initializes the bootstrap with the configuration - - - - - - Initializes the bootstrap with the configuration and config resolver. - - The server config resolver. - - - - - Initializes the bootstrap with the configuration and config resolver. - - The log factory. - - - - - Initializes the bootstrap with a listen endpoint replacement dictionary - - The listen end point replacement. - - - - - Initializes the bootstrap with the configuration - - The server config resolver. - The log factory. - - - - - Starts this bootstrap. - - - - - - Stops this bootstrap. - - - - - Gets all the app servers running in this bootstrap - - - - - Gets the config. - - - - - Gets the startup config file. - - - - - Validates the type of the provider, needn't validate in default mode, because it will be validate later when initializing. - - Name of the type. - - - - - Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - - - - - SuperSocket default bootstrap - - - - - Indicates whether the bootstrap is initialized - - - - - Global configuration - - - - - Global log - - - - - Initializes a new instance of the class. - - The app servers. - - - - Initializes a new instance of the class. - - The root config. - The app servers. - - - - Initializes a new instance of the class. - - The root config. - The app servers. - The log factory. - - - - Initializes a new instance of the class. - - The config. - - - - Initializes a new instance of the class. - - The config. - The startup config file. - - - - Creates the work item instance. - - Name of the service type. - - - - - Gets the work item factory info loader. - - The config. - The log factory. - - - - - Initializes the bootstrap with a listen endpoint replacement dictionary - - The listen end point replacement. - - - - - Initializes the bootstrap with the configuration, config resolver and log factory. - - The server config resolver. - The log factory. - - - - - Initializes the bootstrap with the configuration and config resolver. - - The server config resolver. - - - - - Initializes the bootstrap with the configuration - - The log factory. - - - - - Initializes the bootstrap with the configuration - - - - - - Starts this bootstrap. - - - - - - Stops this bootstrap. - - - - - Gets the log factory. - - - - - Gets all the app servers running in this bootstrap - - - - - Gets the config. - - - - - Gets the startup config file. - - - - - AssemblyImport, used for importing assembly to the current AppDomain - - - - - Initializes a new instance of the class. - - - - - Bootstrap Factory - - - - - Creates the bootstrap. - - The config. - - - - - Creates the bootstrap from app configuration's socketServer section. - - - - - - Creates the bootstrap. - - Name of the config section. - - - - - Creates the bootstrap from configuration file. - - The configuration file. - - - - - Command assembly configuration element - - - - - Gets the assembly name. - - - The assembly. - - - - - Command assembly configuation collection - - - - - Socket Session, all application session should base on this class - - - - - Logs the error, skip the ignored exception - - The exception. - The caller. - The caller file path. - The caller line number. - - - - Logs the error, skip the ignored exception - - The message. - The exception. - The caller. - The caller file path. - The caller line number. - - - - Logs the socket error, skip the ignored error - - The socket error code. - The caller. - The caller file path. - The caller line number. - - - - Starts this session. - - - - - Says the welcome information when a client connectted. - - - - - Called when [close]. - - - - - Tries to send array segment. - - The segments. - - - - - Tries to send array segment. - - The segment. - - - - - Sends in async mode. - - The queue. - - - - Sends in sync mode. - - The queue. - - - - Gets or sets the session ID. - - The session ID. - - - - Gets or sets the config. - - - The config. - - - - - Occurs when [closed]. - - - - - Gets or sets the client. - - The client. - - - - Gets the local end point. - - The local end point. - - - - Gets the remote end point. - - The remote end point. - - - - Gets or sets the secure protocol. - - The secure protocol. - - - - Initializes a new instance of the class. - - Name of the service type. - - - - Setups the specified root config. - - The bootstrap. - The socket server instance config. - The providers. - - - - - Starts this server instance. - - - return true if start successfull, else false - - - - - Stops this server instance. - - - - - Gets the name of the server instance. - - - - - Gets the current state of the work item. - - - The state. - - - - - Gets the total session count. - - - - - The interface for socket listener - - - - - Starts to listen - - The server config. - - - - - Stops listening - - - - - Gets the info of listener - - - - - Gets the end point the listener is working on - - - - - Occurs when new client accepted. - - - - - Occurs when error got. - - - - - Occurs when [stopped]. - - - - - Starts to listen - - The server config. - - - - - Occurs when [stopped]. - - - - - Tcp socket listener in async mode - - - - - Starts to listen - - The server config. - - - - - Gets the sending queue manager. - - - The sending queue manager. - - - - - Starts this session communication. - - - - - Certificate configuration - - - - - Gets the certificate file path. - - - - - Gets the password. - - - - - Gets the the store where certificate locates. - - - The name of the store. - - - - - Gets the thumbprint. - - - - - Listener configuration - - - - - Gets the ip of listener - - - - - Gets the port of listener - - - - - Gets the backlog. - - - - - Gets the security option, None/Default/Tls/Ssl/... - - - - - Listener configuration collection - - - - - Server configuration - - - - - Gets the child config. - - The type of the config. - Name of the child config. - - - - - Gets a value indicating whether an unknown attribute is encountered during deserialization. - To keep compatible with old configuration - - The name of the unrecognized attribute. - The value of the unrecognized attribute. - - true when an unknown attribute is encountered while deserializing; otherwise, false. - - - - - Gets the name of the server type this appServer want to use. - - - The name of the server type. - - - - - Gets the type definition of the appserver. - - - The type of the server. - - - - - Gets the Receive filter factory. - - - - - Gets the ip. - - - - - Gets the port. - - - - - Gets the mode. - - - - - Gets a value indicating whether this is disabled. - - - true if disabled; otherwise, false. - - - - - Gets the send time out. - - - - - Gets the max connection number. - - - - - Gets the size of the receive buffer. - - - The size of the receive buffer. - - - - - Gets the size of the send buffer. - - - The size of the send buffer. - - - - - Gets a value indicating whether sending is in synchronous mode. - - - true if [sync send]; otherwise, false. - - - - - Gets a value indicating whether log command in log file. - - true if log command; otherwise, false. - - - - Gets a value indicating whether [log basic session activity like connected and disconnected]. - - - true if [log basic session activity]; otherwise, false. - - - - - Gets a value indicating whether [log all socket exception]. - - - true if [log all socket exception]; otherwise, false. - - - - - Gets a value indicating whether clear idle session. - - true if clear idle session; otherwise, false. - - - - Gets the clear idle session interval, in seconds. - - The clear idle session interval. - - - - Gets the idle session timeout time length, in seconds. - - The idle session time out. - - - - Gets the certificate config. - - The certificate config. - - - - Gets X509Certificate configuration. - - - X509Certificate configuration. - - - - - Gets the security protocol, X509 certificate. - - - - - Gets the max allowed length of request. - - - The max allowed length of request. - - - - - Gets a value indicating whether [disable session snapshot] - - - - - Gets the interval to taking snapshot for all live sessions. - - - - - Gets the connection filters used by this server instance. - - - The connection filters's name list, seperated by comma - - - - - Gets the command loader, multiple values should be separated by comma. - - - - - Gets the start keep alive time, in seconds - - - - - Gets the keep alive interval, in seconds. - - - - - Gets the backlog size of socket listening. - - - - - Gets the startup order of the server instance. - - - - - Gets/sets the size of the sending queue. - - - The size of the sending queue. - - - - - Gets the logfactory name of the server instance. - - - - - Gets the listeners' configuration. - - - - - Gets the listeners' configuration. - - - - - Gets the command assemblies configuration. - - - The command assemblies. - - - - - Server configuration collection - - - - - SuperSocket's root configuration node - - - - - Gets a value indicating whether an unknown element is encountered during deserialization. - To keep compatible with old configuration - - The name of the unknown subelement. - The being used for deserialization. - - true when an unknown element is encountered while deserializing; otherwise, false. - - The element identified by is locked.- or -One or more of the element's attributes is locked.- or - is unrecognized, or the element has an unrecognized attribute.- or -The element has a Boolean attribute with an invalid value.- or -An attempt was made to deserialize a property more than once.- or -An attempt was made to deserialize a property that is not a valid member of the element.- or -The element cannot contain a CDATA or text element. - - - - Gets the child config. - - The type of the config. - Name of the child config. - - - - - Gets all the server configurations - - - - - Gets the service configurations - - - - - Gets all the connection filter configurations. - - - - - Gets the defined log factory types. - - - - - Gets the logfactory name of the bootstrap. - - - - - Gets the command loaders definition. - - - - - Gets the max working threads. - - - - - Gets the min working threads. - - - - - Gets the max completion port threads. - - - - - Gets the min completion port threads. - - - - - Gets the performance data collect interval, in seconds. - - - - - Gets a value indicating whether [disable performance data collector]. - - - true if [disable performance data collector]; otherwise, false. - - - - - Gets the isolation mode. - - - - - Gets the logfactory name of the bootstrap. - - - - - Gets the option elements. - - - - - Default socket server factory - - - - - Creates the socket server. - - The type of the request info. - The app server. - The listeners. - The config. - - - - - Starts to listen - - The server config. - - - - - Initializes a new instance of the class. - - The app server. - The listeners. - - - - Called when [new client accepted]. - - The listener. - The client. - The state. - - - - Updates the remote end point of the client. - - The remote end point. - - - diff --git a/cb-tools/SuperWebSocket/SuperSocket.SocketEngine.dll b/cb-tools/SuperWebSocket/SuperSocket.SocketEngine.dll deleted file mode 100644 index ded732da201bcbbdffe8ca6870143a58bf92ff81..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 71680 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

XkP^}0-6RA}yGuuReh(yL$5LV9Xt@(kR*c;d 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 diff --git a/cb-tools/SuperWebSocket/SuperSocket.SocketService.exe b/cb-tools/SuperWebSocket/SuperSocket.SocketService.exe deleted file mode 100644 index b3a7c7d767580ddaa3786a61a5c874e6eb80bac8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11264 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 diff --git a/cb-tools/SuperWebSocket/SuperSocket.SocketService.exe.config b/cb-tools/SuperWebSocket/SuperSocket.SocketService.exe.config deleted file mode 100644 index 4ce9e122..00000000 --- a/cb-tools/SuperWebSocket/SuperSocket.SocketService.exe.config +++ /dev/null @@ -1,24 +0,0 @@ - - - -

- - - - - - - - - - - - - - - - - - - - diff --git a/cb-tools/SuperWebSocket/SuperSocket.SocketService.pdb b/cb-tools/SuperWebSocket/SuperSocket.SocketService.pdb deleted file mode 100644 index 30066049a99675f1971a7cb3bcfdbb2dd7ac9a19..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24064 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 diff --git a/cb-tools/SuperWebSocket/SuperWebSocket.XML b/cb-tools/SuperWebSocket/SuperWebSocket.XML deleted file mode 100644 index cdd8817c..00000000 --- a/cb-tools/SuperWebSocket/SuperWebSocket.XML +++ /dev/null @@ -1,1762 +0,0 @@ - - - - SuperWebSocket - - - - - The command handling binary data - - The type of the web socket session. - - - - FragmentCommand - - The type of the web socket session. - - - - Initializes a new instance of the class. - - - - - Checks the frame. - - The frame. - - - - - Checks the control frame. - - The frame. - - - - - Gets data from websocket frames. - - The frames. - - - - - Gets text string from websocket frames. - - The frames. - - - - - Gets data from a websocket frame. - - The frame. - - - - - Gets text string from a websocket frame. - - The frame. - - - - - Gets the UTF8 encoding which has been set ExceptionFallback. - - - - - Executes the command. - - The session. - The request info. - - - - Gets the name. - - - - - The command handling close fragment - - The type of the web socket session. - - - - Executes the command. - - The session. - The request info. - - - - Gets the name. - - - - - The command handling continuation fragment - - The type of the web socket session. - - - - Executes the command. - - The session. - The request info. - - - - Gets the name. - - - - - The command handle handshake request - - The type of the web socket session. - - - - Executes the command. - - The session. - The request info. - - - - Gets the name. - - - - - The command handling Ping - - The type of the web socket session. - - - - Executes the command. - - The session. - The request info. - - - - Gets the name. - - - - - The command to handling text message in plain text of hybi00 - - The type of the web socket session. - - - - Executes the command. - - The session. - The request info. - - - - Gets the name. - - - - - The command handling Pong - - The type of the web socket session. - - - - Executes the command. - - The session. - The request info. - - - - Gets the name. - - - - - The command handling Text fragment - - The type of the web socket session. - - - - Executes the command. - - The session. - The request info. - - - - Gets the name. - - - - - Command configuration - - - - - Gets a value indicating whether an unknown attribute is encountered during deserialization. - - The name of the unrecognized attribute. - The value of the unrecognized attribute. - - true when an unknown attribute is encountered while deserializing; otherwise, false. - - - - - Gets the options. - - - - - Command configuration collection - - - - - When overridden in a derived class, creates a new . - - - A new . - - - - - Gets the element key for a specified configuration element when overridden in a derived class. - - The to return the key for. - - An that acts as the key for the specified . - - - - - Gets the enumerator. - - - - - - Gets or sets a property, attribute, or child element of this configuration element. - - The specified property, attribute, or child element - - - - SubProtocol configuration - - - - - Initializes a new instance of the class. - - - - - Gets the type. - - - - - Gets the commands. - - - - - SubProtocol configuation collection - - - - - When overridden in a derived class, creates a new . - - - A new . - - - - - Gets the element key for a specified configuration element when overridden in a derived class. - - The to return the key for. - - An that acts as the key for the specified . - - - - - Gets the enumerator. - - - - - - Gets the type of the . - - The of this collection. - - - - Gets the name used to identify this collection of elements in the configuration file when overridden in a derived class. - - The name of the collection; otherwise, an empty string. The default is an empty string. - - - - Extension class - - - - - Appends in the format with CrCf as suffix. - - The builder. - The format. - The arg. - - - - Appends in the format with CrCf as suffix. - - The builder. - The format. - The args. - - - - Appends with CrCf as suffix. - - The builder. - The content. - - - - Appends with CrCf as suffix. - - The builder. - - - - The converter interface for converting binary data to text message - - - - - Returns a that represents this instance. - - The data. - The offset. - The length. - - A that represents this instance. - - - - - Json websocket session - - - - - Json websocket session - - The type of the web socket session. - - - - WebSocket AppSession class - - The type of the web socket session. - - - - WebSocketSession basic interface - - - - - Sends the raw binary response. - - The data. - The offset. - The length. - - - - Gets the available sub protocol. - - The protocol. - - - - - Gets or sets the method. - - - The method. - - - - - Gets the host. - - - - - Gets or sets the path. - - - The path. - - - - - Gets or sets the HTTP version. - - - The HTTP version. - - - - - Gets the sec web socket version. - - - - - Gets the origin. - - - - - Gets the URI scheme. - - - - - Gets a value indicating whether this is handshaked. - - - true if handshaked; otherwise, false. - - - - - Gets the app server. - - - - - Gets or sets the protocol processor. - - - The protocol processor. - - - - - Called when [init]. - - - - - Sets the cookie. - - - - - Sends the response. - - The message. - - - - Sends the response. - - The data. - The offset. - The length. - - - - Sends the response. - - The segment. - - - - Sends the raw binary data. - - The data. - The offset. - The length. - - - - Sends the response. - - The message. - - - - - Sends the response. - - The data. - The offset. - The length. - - - - - Sends the response. - - The segment. - - - - - Closes the with handshake. - - The reason text. - - - - Closes the with handshake. - - The status code. - The reason text. - - - - Sends the close handshake response. - - The status code. - - - - Closes the specified reason. - - The reason. - - - - Handles the unknown command. - - The request info. - - - - Handles the unknown request. - - The request info. - - - - Gets or sets the method. - - - The method. - - - - - Gets or sets the path. - - - The path. - - - - - Gets or sets the HTTP version. - - - The HTTP version. - - - - - Gets the host. - - - - - Gets the origin. - - - - - Gets the upgrade. - - - - - Gets the connection. - - - - - Gets the sec web socket version. - - - - - Gets the sec web socket protocol. - - - - - Gets the current token. - - - - - Gets the app server. - - - - - Gets the URI scheme, ws or wss - - - - - Gets the sub protocol. - - - - - Gets a value indicating whether this is handshaked. - - - true if handshaked; otherwise, false. - - - - - Gets a value indicating whether the session [in closing]. - - - true if [in closing]; otherwise, false. - - - - - Gets the cookies. - - - - - Gets or sets the protocol processor. - - - The protocol processor. - - - - - Sends the json message. - - The name. - The content. - - - - Close status code for Hybi10 - - - - - Close status code interface - - - - - Gets the code for extension not match. - - - - - Gets the code for going away. - - - - - Gets the code for invalid UT f8. - - - - - Gets the code for normal closure. - - - - - Gets the code for not acceptable data. - - - - - Gets the code for protocol error. - - - - - Gets the code for TLS handshake failure. - - - - - Gets the code for too large frame. - - - - - Gets the code for unexpected condition. - - - - - Gets the code for violate policy. - - - - - Gets the code for no status code. - - - - - Initializes a new instance of the class. - - - - - Gets the code for normal closure. - - - - - Gets the code for going away. - - - - - Gets the code for protocol error. - - - - - Gets the code for not acceptable data. - - - - - Gets the code for too large frame. - - - - - Gets the code for invalid UT f8. - - - - - Gets the code for violate policy. - - - - - Gets the code for extension not match. - - - - - Gets the code for unexpected condition. - - - - - Gets the code for TLS handshake failure. - - - - - Gets the code for no status code. - - - - - Close status code for rfc6455 - - - - - Initializes a new instance of the class. - - - - - Gets the code for normal closure. - - - - - Gets the code for going away. - - - - - Gets the code for protocol error. - - - - - Gets the code for not acceptable data. - - - - - Gets the code for too large frame. - - - - - Gets the code for invalid UT f8. - - - - - Gets the code for violate policy. - - - - - Gets the code for extension not match. - - - - - Gets the code for unexpected condition. - - - - - Gets the code for TLS handshake failure. - - - - - Gets the code for no status code. - - - - - http://tools.ietf.org/html/draft-ietf-hybi-thewebsocketprotocol-00 - - - - - Protocol processor interface - - - - - Handshakes the specified session. - - The session. - The previous filter. - The data frame reader. - - - - - Sends the message. - - The session. - The message. - - - - Sends the data. - - The session. - The data. - The offset. - The length. - - - - Sends the close handshake. - - The session. - The status code. - The close reason. - - - - Sends the pong. - - The session. - The pong. - - - - Sends the ping. - - The session. - The ping. - - - - Determines whether [is valid close code] [the specified code]. - - The code. - - true if [is valid close code] [the specified code]; otherwise, false. - - - - - Gets a value indicating whether this instance can send binary data. - - - true if this instance can send binary data; otherwise, false. - - - - - Gets the close status clode. - - - - - Gets or sets the next processor. - - - The next processor. - - - - - Gets the version of current protocol. - - - - - http://tools.ietf.org/html/draft-ietf-hybi-thewebsocketprotocol-10 - - - - - Handshake request - - - - - WebSocketFragment request info - - - - - Gets the key of this request. - - - - - http://tools.ietf.org/html/rfc6455#section-4.4 - - - - - Plain text fragment - - - - - Initializes a new instance of the class. - - The message. - - - - Gets the message. - - - - - Gets the key of this request. - - - - - http://tools.ietf.org/html/rfc6455 - - - - - WebSocketReceiveFilter basis - - - - - The length of Sec3Key - - - - - Initializes a new instance of the class. - - The session. - - - - Initializes a new instance of the class. - - The previous receive filter. - - - - Handshakes the specified protocol processor. - - The protocol processor. - The session. - - - - - Gets the handshake request info. - - - - - Resets this instance. - - - - - Async json sub command - - The type of the json command info. - - - - Async json sub command - - The type of the web socket session. - The type of the json command info. - - - - Json SubCommand base - - The type of the web socket session. - The type of the json command info. - - - - SubCommand base - - The type of the web socket session. - - - - SubCommand interface - - The type of the web socket session. - - - - Executes the command. - - The session. - The request info. - - - - The basic interface of sub command filter loader - - - - - Loads the sub command filters. - - The global filters. - - - - Executes the command. - - The session. - The request info. - - - - Gets the name. - - - - - Initializes a new instance of the class. - - - - - Executes the command. - - The session. - The request info. - - - - Executes the json command. - - The session. - The command info. - - - - Gets the json message. - - The session. - The name. - The token. - The content. - - - - - Initializes a new instance of the class. - - - - - Executes the json command. - - The session. - The command info. - - - - Executes the async json command. - - The session. - The token. - The command info. - - - - Sends the json message. - - The session. - The token. - The content. - - - - Sends the json message. - - The session. - The name. - The token. - The content. - - - - Basic sub command parser - - - - - Parses the request info. - - The source. - - - - - Default basic sub protocol implementation - - - - - Default basic sub protocol implementation - - - - - SubProtocol basis - - The type of the web socket session. - - - - SubProtocol interface - - The type of the web socket session. - - - - Initializes with the specified config. - - The app server. - The protocol config. - The logger. - - - - - Tries the get command. - - The name. - The command. - - - - - Gets the name. - - - - - Gets the sub request parser. - - - - - Initializes a new instance of the class. - - The name. - - - - Initializes with the specified config. - - The app server. - The protocol config. - The logger. - - - - - Tries the get command. - - The name. - The command. - - - - - Gets the name. - - - - - Gets or sets the sub request parser. - - - The sub request parser. - - - - - Default basic sub protocol name - - - - - Initializes a new instance of the class with the calling aseembly as command assembly - - - - - Initializes a new instance of the class with the calling aseembly as command assembly - - The sub protocol name. - - - - Initializes a new instance of the class with command assemblies - - The command assemblies. - - - - Initializes a new instance of the class with single command assembly. - - The command assembly. - - - - Initializes a new instance of the class with name and single command assembly. - - The sub protocol name. - The command assembly. - - - - Initializes a new instance of the class with name and command assemblies. - - The sub protocol name. - The command assemblies. - - - - Initializes a new instance of the class. - - The name. - The command assemblies. - The request info parser. - - - - Initializes with the specified config. - - The app server. - The protocol config. - The logger. - - - - - Tries get command from the sub protocol's command inventory. - - The name. - The command. - - - - - Initializes a new instance of the class. - - - - - Initializes a new instance of the class. - - The sub protocol name. - - - - Initializes a new instance of the class. - - The command assembly. - - - - Initializes a new instance of the class. - - The command assemblies. - - - - Initializes a new instance of the class. - - The sub protocol name. - The command assembly. - - - - Initializes a new instance of the class. - - The sub protocol name. - The command assemblies. - - - - Initializes a new instance of the class. - - The name. - The command assemblies. - The request info parser. - - - - The basic interface of SubRequestInfo - - - - - Gets the token. - - - The token. - - - - - JsonSubCommand - - The type of the json command info. - - - - JsonSubCommand - - The type of the web socket session. - The type of the json command info. - - - - Gets the json message. - - The session. - The content. - - - - - Gets the json message. - - The session. - The name. - The content. - - - - - Sends the json message. - - The session. - The content. - - - - Sends the json message. - - The session. - The name. - The content. - - - - SubCommand base - - - - - SubCommandFilter Attribute - - - - - Gets or sets the sub protocol. - - - The sub protocol. - - - - - SubProtocol RequestInfo type - - - - - Initializes a new instance of the class. - - The key. - The token. - The data. - - - - Gets the token of this request, used for callback - - - - - Text encoding binary data converter - - - - - Initializes a new instance of the class. - - The encoding. - - - - Returns a that represents this instance. - - The data. - The offset. - The length. - - A that represents this instance. - - - - - Gets the encoding. - - - The encoding. - - - - - WebSocket protocol - - - - - Initializes a new instance of the class. - - - - - Creates the filter. - - The app server. - The app session. - The remote end point. - - - - - Blah - - - - - Blah - - - - - Blah - - - - - Blah - - - - - Blah - - - - - Blah - - - - - Blah - - - - - WebSocket server interface - - - - - Validates the handshake request. - - The session. - The origin. - the validation result - - - - Gets the web socket protocol processor. - - - - - WebSocket AppServer - - - - - WebSocket AppServer - - The type of the web socket session. - - - - Initializes a new instance of the class. - - The sub protocols. - - - - Initializes a new instance of the class. - - The sub protocol. - - - - Initializes a new instance of the class. - - - - - The openning handshake timeout, in seconds - - - - - The closing handshake timeout, in seconds - - - - - The interval of checking handshake pending queue, in seconds - - - - - Gets the sub protocol by sub protocol name. - - The name. - - - - - Validates the handshake request. - - The session. - The origin in the handshake request. - - - - - Setups with the specified root config. - - The root config. - The config. - - - - - Called when [startup]. - - - - - Called when [new session connected]. - - The session. - - - - Blah - - - - - Setups the commands. - - The discovered commands. - - - - - Executes the command. - - The session. - The request info. - - - - Serialize the target object by JSON - - The target. - - - - - Deserialize the JSON string to target type object. - - The json. - The type. - - - - - Gets or sets the binary data converter. - - - The binary data converter. - - - - - Gets the request filter factory. - - - - - Occurs when [new request received]. - - - - - - Occurs when [new message received]. - - - - - Blah - - - - - Occurs when [new data received]. - - - - - Initializes a new instance of the class. - - The sub protocols. - - - - Initializes a new instance of the class. - - The sub protocol. - - - - Initializes a new instance of the class. - - - - - WebSocket AppSession - - - - - Gets the app server. - - - - diff --git a/cb-tools/SuperWebSocket/SuperWebSocket.dll b/cb-tools/SuperWebSocket/SuperWebSocket.dll deleted file mode 100644 index 572359830ad3b470a3b8be80cefa540a65b593f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 62464 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 z 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Ã#p}qjHI9 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 X%V)voz*g{Q_vak=a}NCP$$|d|Y22Kh diff --git a/cb-tools/SuperWebSocket/UninstallService.bat b/cb-tools/SuperWebSocket/UninstallService.bat deleted file mode 100644 index 9948b806..00000000 --- a/cb-tools/SuperWebSocket/UninstallService.bat +++ /dev/null @@ -1,2 +0,0 @@ -SuperSocket.SocketService.exe -u -pause \ No newline at end of file diff --git a/cb-tools/SuperWebSocket/WebSocket4Net.dll b/cb-tools/SuperWebSocket/WebSocket4Net.dll deleted file mode 100644 index 9ca44f139a5ff2379b93b37d64b58bc5b2214648..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 88576 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 ADgXcg diff --git a/cb-tools/SuperWebSocket/log4net.dll b/cb-tools/SuperWebSocket/log4net.dll deleted file mode 100644 index c3ced3548bda4109a7b789f1d9c4837bd45c7137..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 286720 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

A!4yIJjVfq2a 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 2 - n = cellfun(@numel, trialsByX(:)); -end -p = phat/100; -if numel(x) > 0 - h = plt.errorbar(axh, x, phat, pci(:,1), pci(:,2), varargin{:}); -else - h = []; -end - -end - diff --git a/cb-tools/burgbox/+plt/errorbar.m b/cb-tools/burgbox/+plt/errorbar.m deleted file mode 100644 index 85eb9b61..00000000 --- a/cb-tools/burgbox/+plt/errorbar.m +++ /dev/null @@ -1,23 +0,0 @@ -function h = errorbar(axh, x, y, yL, yU, varargin) -%PLT.ERRORBAR Like MATLAB's errorbar but specify actual EB co-ordinates -% H = PLT.ERRORBAR(AXH, X, Y, YL, YU, ...) -% -% Differences to MATLAB's errorbar, this: -% * takes the actual y coordinates of the errorbar top and bottom, rather -% than the offset from y coordinates -% * always takes the axes as the first parameter -% * if there is no data to plot, then it won't throw a wobbly, it just -% wont plot anything -% -% Part of Burgbox - -% 2013-10 CB created - -if numel(x) > 0 - h = errorbar(x, y, y - yL, y - yU, varargin{:}, 'Parent', axh); -else - h = []; -end - -end - diff --git a/cb-tools/burgbox/+plt/hshade.m b/cb-tools/burgbox/+plt/hshade.m deleted file mode 100644 index 216e064a..00000000 --- a/cb-tools/burgbox/+plt/hshade.m +++ /dev/null @@ -1,37 +0,0 @@ -function [fillhandle, msg] = hshade(ax, xpoints, upper, lower, color, edge, alpha) -%USAGE: [fillhandle, msg] = fill(xpoints, upper, lower, color, edge, add, transparency) -%This function will fill a region with a color between the two vectors provided -%using the Matlab fill command. -% -%fillhandle is the returned handle to the filled region in the plot. -%xpoints= The horizontal data points (ie frequencies). Note length(Upper) -% must equal Length(lower)and must equal length(xpoints)! -%upper = the upper curve values (data can be less than lower) -%lower = the lower curve values (data can be more than upper) -%color = the color of the filled area -%edge = the color around the edge of the filled area -%add = a flag to add to the current plot or make a new one. -%transparency is a value ranging from 1 for opaque to 0 for invisible for -%the filled color only. -% -%John A. Bockstege November 2006; -%Example: -% a=rand(1,20);%Vector of random data -% b=a+2*rand(1,20);%2nd vector of data points; -% x=1:20;%horizontal vector -% [ph,msg]=jbfill(x,a,b,rand(1,3),rand(1,3),0,rand(1,1)) -% grid on -% legend('Datr') -if nargin<7;alpha=.5;end %default is to have a transparency of .5 -if nargin<6;edge='k';end %dfault edge color is black -if nargin<5;color='b';end %default color is blue - -if length(upper)==length(lower) && length(lower)==length(xpoints) - msg=''; - filled=[upper,fliplr(lower)]; - xpoints=[xpoints,fliplr(xpoints)]; - fillhandle = fill(xpoints, filled, color, 'Parent', ax);%plot the data - set(fillhandle, 'EdgeColor', edge, 'FaceAlpha', alpha, 'EdgeAlpha', alpha);%set edge color -else - msg='Error: Must use the same number of points in each vector'; -end diff --git a/cb-tools/burgbox/+plt/vshade.m b/cb-tools/burgbox/+plt/vshade.m deleted file mode 100644 index c4abb2b1..00000000 --- a/cb-tools/burgbox/+plt/vshade.m +++ /dev/null @@ -1,37 +0,0 @@ -function [fillhandle, msg] = vshade(ax, ypoints, left, right, color, edge, alpha) -%USAGE: [fillhandle, msg] = fill(xpoints, upper, lower, color, edge, add, transparency) -%This function will fill a region with a color between the two vectors provided -%using the Matlab fill command. -% -%fillhandle is the returned handle to the filled region in the plot. -%xpoints= The horizontal data points (ie frequencies). Note length(Upper) -% must equal Length(lower)and must equal length(xpoints)! -%upper = the upper curve values (data can be less than lower) -%lower = the lower curve values (data can be more than upper) -%color = the color of the filled area -%edge = the color around the edge of the filled area -%add = a flag to add to the current plot or make a new one. -%transparency is a value ranging from 1 for opaque to 0 for invisible for -%the filled color only. -% -%John A. Bockstege November 2006; -%Example: -% a=rand(1,20);%Vector of random data -% b=a+2*rand(1,20);%2nd vector of data points; -% x=1:20;%horizontal vector -% [ph,msg]=jbfill(x,a,b,rand(1,3),rand(1,3),0,rand(1,1)) -% grid on -% legend('Datr') -if nargin<7;alpha=.5;end %default is to have a transparency of .5 -if nargin<6;edge='k';end %dfault edge color is black -if nargin<5;color='b';end %default color is blue - -if length(left)==length(right) && length(right)==length(ypoints) - msg=''; - filled=[left,fliplr(right)]; - ypoints=[ypoints,fliplr(ypoints)]; - fillhandle=fill(filled,ypoints,color, 'Parent', ax);%plot the data - set(fillhandle,'EdgeColor',edge,'FaceAlpha',alpha,'EdgeAlpha',alpha);%set edge color -else - msg='Error: Must use the same number of points in each vector'; -end diff --git a/cb-tools/jsonlab/AUTHORS.txt b/cb-tools/jsonlab/AUTHORS.txt deleted file mode 100644 index 80446a89..00000000 --- a/cb-tools/jsonlab/AUTHORS.txt +++ /dev/null @@ -1,36 +0,0 @@ -The author of "jsonlab" toolbox is Qianqian Fang. Qianqian -is currently an Assistant Professor at Massachusetts General Hospital, -Harvard Medical School. - -Address: Martinos Center for Biomedical Imaging, - Massachusetts General Hospital, - Harvard Medical School - Bldg 149, 13th St, Charlestown, MA 02129, USA -URL: http://nmr.mgh.harvard.edu/~fangq/ -Email: or - - -The script loadjson.m was built upon previous works by - -- Nedialko Krouchev: http://www.mathworks.com/matlabcentral/fileexchange/25713 - date: 2009/11/02 -- François Glineur: http://www.mathworks.com/matlabcentral/fileexchange/23393 - date: 2009/03/22 -- Joel Feenstra: http://www.mathworks.com/matlabcentral/fileexchange/20565 - date: 2008/07/03 - - -This toolbox contains patches submitted by the following contributors: - -- Blake Johnson - part of revision 341 - -- Niclas Borlin - various fixes in revision 394, including - - loadjson crashes for all-zero sparse matrix. - - loadjson crashes for empty sparse matrix. - - Non-zero size of 0-by-N and N-by-0 empty matrices is lost after savejson/loadjson. - - loadjson crashes for sparse real column vector. - - loadjson crashes for sparse complex column vector. - - Data is corrupted by savejson for sparse real row vector. - - savejson crashes for sparse complex row vector. diff --git a/cb-tools/jsonlab/ChangeLog.txt b/cb-tools/jsonlab/ChangeLog.txt deleted file mode 100644 index f409e767..00000000 --- a/cb-tools/jsonlab/ChangeLog.txt +++ /dev/null @@ -1,47 +0,0 @@ -============================================================================ - - JSONlab - a toolbox to encode/decode JSON/UBJSON files in MATLAB/Octave - ----------------------------------------------------------------------------- - -JSONlab ChangeLog (key features marked by *): - -== JSONlab 0.9.8 (codename: Optimus - alpha), FangQ == - 2013/08/23 *Universal Binary JSON (UBJSON) support, including both saveubjson and loadubjson - -== JSONlab 0.9.1 (codename: Rodimus, update 1), FangQ == - 2012/12/18 *handling of various empty and sparse matrices (fixes submitted by Niclas Borlin) - -== JSONlab 0.9.0 (codename: Rodimus), FangQ == - - 2012/06/17 *new format for an invalid leading char, unpacking hex code in savejson - 2012/06/01 support JSONP in savejson - 2012/05/25 fix the empty cell bug (reported by Cyril Davin) - 2012/04/05 savejson can save to a file (suggested by Patrick Rapin) - -== JSONlab 0.8.1 (codename: Sentiel, Update 1), FangQ == - - 2012/02/28 loadjson quotation mark escape bug, see http://bit.ly/yyk1nS - 2012/01/25 patch to handle root-less objects, contributed by Blake Johnson - -== JSONlab 0.8.0 (codename: Sentiel), FangQ == - - 2012/01/13 *speed up loadjson by 20 fold when parsing large data arrays in matlab - 2012/01/11 remove row bracket if an array has 1 element, suggested by Mykel Kochenderfer - 2011/12/22 *accept sequence of 'param',value input in savejson and loadjson - 2011/11/18 fix struct array bug reported by Mykel Kochenderfer - -== JSONlab 0.5.1 (codename: Nexus Update 1), FangQ == - - 2011/10/21 fix a bug in loadjson, previous code does not use any of the acceleration - 2011/10/20 loadjson supports JSON collections - concatenated JSON objects - -== JSONlab 0.5.0 (codename: Nexus), FangQ == - - 2011/10/16 package and release jsonlab 0.5.0 - 2011/10/15 *add json demo and regression test, support cpx numbers, fix double quote bug - 2011/10/11 *speed up readjson dramatically, interpret _Array* tags, show data in root level - 2011/10/10 create jsonlab project, start jsonlab website, add online documentation - 2011/10/07 *speed up savejson by 25x using sprintf instead of mat2str, add options support - 2011/10/06 *savejson works for structs, cells and arrays - 2011/09/09 derive loadjson from JSON parser from MATLAB Central, draft savejson.m diff --git a/cb-tools/jsonlab/LICENSE_BSD.txt b/cb-tools/jsonlab/LICENSE_BSD.txt deleted file mode 100644 index cc5fb4d6..00000000 --- a/cb-tools/jsonlab/LICENSE_BSD.txt +++ /dev/null @@ -1,25 +0,0 @@ -Copyright 2011 Qianqian Fang . All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, are -permitted provided that the following conditions are met: - - 1. Redistributions of source code must retain the above copyright notice, this list of - conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above copyright notice, this list - of conditions and the following disclaimer in the documentation and/or other materials - provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ''AS IS'' AND ANY EXPRESS OR IMPLIED -WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND -FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS -OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF -ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -The views and conclusions contained in the software and documentation are those of the -authors and should not be interpreted as representing official policies, either expressed -or implied, of the copyright holders. diff --git a/cb-tools/jsonlab/LICENSE_GPLv3.txt b/cb-tools/jsonlab/LICENSE_GPLv3.txt deleted file mode 100644 index 94a9ed02..00000000 --- a/cb-tools/jsonlab/LICENSE_GPLv3.txt +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/cb-tools/jsonlab/README.txt b/cb-tools/jsonlab/README.txt deleted file mode 100644 index 83616b81..00000000 --- a/cb-tools/jsonlab/README.txt +++ /dev/null @@ -1,335 +0,0 @@ -=============================================================================== -= JSONlab = -= An open-source MATLAB/Octave JSON encoder and decoder = -=============================================================================== - -*Copyright (c) 2011-2013 Qianqian Fang -*License: BSD or GNU General Public License version 3 (GPL v3), see License*.txt -*Version: 0.9.8 (Optimus - alpha) - -------------------------------------------------------------------------------- - -Table of Content: - -I. Introduction -II. Installation -III.Using JSONlab -IV. Known Issues and TODOs -V. Contribution and feedback - -------------------------------------------------------------------------------- - -I. Introduction - -JSON ([http://www.json.org/ JavaScript Object Notation]) is a highly portable, -human-readable and "[http://en.wikipedia.org/wiki/JSON fat-free]" text format -to represent complex and hierarchical data. It is as powerful as -[http://en.wikipedia.org/wiki/XML XML], but less verbose. JSON format is widely -used for data-exchange in applications, and is essential for the wild success -of [http://en.wikipedia.org/wiki/Ajax_(programming) Ajax] and -[http://en.wikipedia.org/wiki/Web_2.0 Web2.0]. - -UBJSON (Universal Binary JSON) is a binary JSON format, specifically -optimized for compact file size and better performance while keeping -the semantics as simple as the text-based JSON format. Using the UBJSON -format allows to wrap complex binary data in a flexible and extensible -structure, making it possible to process complex and large dataset -without accuracy loss due to text conversions. - -We envision that both JSON and its binary version will serve as part of -the mainstream data-exchange formats for scientific research in the future. -It will provide the flexibility and generality achieved by other popular -general-purpose file specifications, such as -[http://www.hdfgroup.org/HDF5/whatishdf5.html HDF5], with significantly -reduced complexity and enhanced performance. - -JSONlab is a free and open-source implementation of a JSON/UBJSON encoder -and a decoder in the native MATLAB language. It can be used to convert a MATLAB -data structure (array, struct, cell, struct array and cell array) into -JSON/UBJSON formatted strings, or to decode a JSON/UBJSON file into MATLAB -data structure. JSONlab supports both MATLAB and -[http://www.gnu.org/software/octave/ GNU Octave] (a free MATLAB clone). - -------------------------------------------------------------------------------- - -II. Installation - -The installation of JSONlab is no different than any other simple -MATLAB toolbox. You only need to download/unzip the JSONlab package -to a folder, and add the folder's path to MATLAB/Octave's path list -by using the following command: - - addpath('/path/to/jsonlab'); - -If you want to add this path permanently, you need to type "pathtool", -browse to the jsonlab root folder and add to the list, then click "Save". -Then, run "rehash" in MATLAB, and type "which loadjson", if you see an -output, that means JSONlab is installed for MATLAB/Octave. - -------------------------------------------------------------------------------- - -III.Using JSONlab - -JSONlab provides two functions, loadjson.m -- a MATLAB->JSON decoder, -and savejson.m -- a MATLAB->JSON encoder, for the text-based JSON, and -two equivallent functions -- loadubjson and saveubjson for the binary -JSON. The detailed help info for the four functions can be found below: - -=== loadjson.m === -

-  data=loadjson(fname,opt)
-     or
-  data=loadjson(fname,'param1',value1,'param2',value2,...)
- 
-  parse a JSON (JavaScript Object Notation) file or string
- 
-  authors:Qianqian Fang (fangq nmr.mgh.harvard.edu)
-             date: 2011/09/09
-          Nedialko Krouchev: http://www.mathworks.com/matlabcentral/fileexchange/25713
-             date: 2009/11/02
-          Fran�ois Glineur: http://www.mathworks.com/matlabcentral/fileexchange/23393
-             date: 2009/03/22
-          Joel Feenstra:
-          http://www.mathworks.com/matlabcentral/fileexchange/20565
-             date: 2008/07/03
- 
-  $Id: loadjson.m 394 2012-12-18 17:58:11Z fangq $
- 
-  input:
-       fname: input file name, if fname contains "{}" or "[]", fname
-              will be interpreted as a JSON string
-       opt: a struct to store parsing options, opt can be replaced by 
-            a list of ('param',value) pairs. The param string is equivallent
-            to a field in opt.
- 
-  output:
-       dat: a cell array, where {...} blocks are converted into cell arrays,
-            and [...] are converted to arrays
-
- -=== savejson.m === - -
-  json=savejson(rootname,obj,filename)
-     or
-  json=savejson(rootname,obj,opt)
-  json=savejson(rootname,obj,'param1',value1,'param2',value2,...)
- 
-  convert a MATLAB object (cell, struct or array) into a JSON (JavaScript
-  Object Notation) string
- 
-  author: Qianqian Fang (fangq nmr.mgh.harvard.edu)
-             created on 2011/09/09
- 
-  $Id: savejson.m 394 2012-12-18 17:58:11Z fangq $
- 
-  input:
-       rootname: name of the root-object, if set to '', will use variable name
-       obj: a MATLAB object (array, cell, cell array, struct, struct array)
-       filename: a string for the file name to save the output JSON data
-       opt: a struct for additional options, use [] if all use default
-         opt can have the following fields (first in [.|.] is the default)
- 
-         opt.FileName [''|string]: a file name to save the output JSON data
-         opt.FloatFormat ['%.10g'|string]: format to show each numeric element
-                          of a 1D/2D array;
-         opt.ArrayIndent [1|0]: if 1, output explicit data array with
-                          precedent indentation; if 0, no indentation
-         opt.ArrayToStruct[0|1]: when set to 0, savejson outputs 1D/2D
-                          array in JSON array format; if sets to 1, an
-                          array will be shown as a struct with fields
-                          "_ArrayType_", "_ArraySize_" and "_ArrayData_"; for
-                          sparse arrays, the non-zero elements will be
-                          saved to _ArrayData_ field in triplet-format i.e.
-                          (ix,iy,val) and "_ArrayIsSparse_" will be added
-                          with a value of 1; for a complex array, the 
-                          _ArrayData_ array will include two columns 
-                          (4 for sparse) to record the real and imaginary 
-                          parts, and also "_ArrayIsComplex_":1 is added. 
-         opt.ParseLogical [0|1]: if this is set to 1, logical array elem
-                          will use true/false rather than 1/0.
-         opt.NoRowBracket [1|0]: if this is set to 1, arrays with a single
-                          numerical element will be shown without a square
-                          bracket, unless it is the root object; if 0, square
-                          brackets are forced for any numerical arrays.
-         opt.ForceRootName [0|1]: when set to 1 and rootname is empty, savejson
-                          will use the name of the passed obj variable as the 
-                          root object name; if obj is an expression and 
-                          does not have a name, 'root' will be used; if this 
-                          is set to 0 and rootname is empty, the root level 
-                          will be merged down to the lower level.
-         opt.Inf ['"$1_Inf_"'|string]: a customized regular expression pattern
-                          to represent +/-Inf. The matched pattern is '([-+]*)Inf'
-                          and $1 represents the sign. For those who want to use
-                          1e999 to represent Inf, they can set opt.Inf to '$11e999'
-         opt.NaN ['"_NaN_"'|string]: a customized regular expression pattern
-                          to represent NaN
-         opt.JSONP [''|string]: to generate a JSONP output (JSON with padding),
-                          for example, if opt.JSON='foo', the JSON data is
-                          wrapped inside a function call as 'foo(...);'
-         opt.UnpackHex [1|0]: conver the 0x[hex code] output by loadjson 
-                          back to the string form
-         opt can be replaced by a list of ('param',value) pairs. The param 
-         string is equivallent to a field in opt.
-  output:
-       json: a string in the JSON format (see http://json.org)
- 
-  examples:
-       a=struct('node',[1  9  10; 2 1 1.2], 'elem',[9 1;1 2;2 3],...
-            'face',[9 01 2; 1 2 3; NaN,Inf,-Inf], 'author','FangQ');
-       savejson('mesh',a)
-       savejson('',a,'ArrayIndent',0,'FloatFormat','\t%.5g')
-
- -=== loadubjson.m === - -
-  data=loadubjson(fname,opt)
-     or
-  data=loadubjson(fname,'param1',value1,'param2',value2,...)
- 
-  parse a JSON (JavaScript Object Notation) file or string
- 
-  authors:Qianqian Fang (fangq nmr.mgh.harvard.edu)
-             date: 2013/08/01
- 
-  $Id: loadubjson.m 410 2013-08-24 03:33:18Z fangq $
- 
-  input:
-       fname: input file name, if fname contains "{}" or "[]", fname
-              will be interpreted as a UBJSON string
-       opt: a struct to store parsing options, opt can be replaced by 
-            a list of ('param',value) pairs. The param string is equivallent
-            to a field in opt.
- 
-  output:
-       dat: a cell array, where {...} blocks are converted into cell arrays,
-            and [...] are converted to arrays
-
- -=== saveubjson.m === - -
-  json=saveubjson(rootname,obj,filename)
-     or
-  json=saveubjson(rootname,obj,opt)
-  json=saveubjson(rootname,obj,'param1',value1,'param2',value2,...)
- 
-  convert a MATLAB object (cell, struct or array) into a Universal 
-  Binary JSON (UBJSON) binary string
- 
-  author: Qianqian Fang (fangq nmr.mgh.harvard.edu)
-             created on 2013/08/17
- 
-  $Id: saveubjson.m 410 2013-08-24 03:33:18Z fangq $
- 
-  input:
-       rootname: name of the root-object, if set to '', will use variable name
-       obj: a MATLAB object (array, cell, cell array, struct, struct array)
-       filename: a string for the file name to save the output JSON data
-       opt: a struct for additional options, use [] if all use default
-         opt can have the following fields (first in [.|.] is the default)
- 
-         opt.FileName [''|string]: a file name to save the output JSON data
-         opt.ArrayToStruct[0|1]: when set to 0, saveubjson outputs 1D/2D
-                          array in JSON array format; if sets to 1, an
-                          array will be shown as a struct with fields
-                          "_ArrayType_", "_ArraySize_" and "_ArrayData_"; for
-                          sparse arrays, the non-zero elements will be
-                          saved to _ArrayData_ field in triplet-format i.e.
-                          (ix,iy,val) and "_ArrayIsSparse_" will be added
-                          with a value of 1; for a complex array, the 
-                          _ArrayData_ array will include two columns 
-                          (4 for sparse) to record the real and imaginary 
-                          parts, and also "_ArrayIsComplex_":1 is added. 
-         opt.ParseLogical [1|0]: if this is set to 1, logical array elem
-                          will use true/false rather than 1/0.
-         opt.NoRowBracket [1|0]: if this is set to 1, arrays with a single
-                          numerical element will be shown without a square
-                          bracket, unless it is the root object; if 0, square
-                          brackets are forced for any numerical arrays.
-         opt.ForceRootName [0|1]: when set to 1 and rootname is empty, saveubjson
-                          will use the name of the passed obj variable as the 
-                          root object name; if obj is an expression and 
-                          does not have a name, 'root' will be used; if this 
-                          is set to 0 and rootname is empty, the root level 
-                          will be merged down to the lower level.
-         opt.JSONP [''|string]: to generate a JSONP output (JSON with padding),
-                          for example, if opt.JSON='foo', the JSON data is
-                          wrapped inside a function call as 'foo(...);'
-         opt.UnpackHex [1|0]: conver the 0x[hex code] output by loadjson 
-                          back to the string form
-         opt can be replaced by a list of ('param',value) pairs. The param 
-         string is equivallent to a field in opt.
-  output:
-       json: a string in the JSON format (see http://json.org)
- 
-  examples:
-       a=struct('node',[1  9  10; 2 1 1.2], 'elem',[9 1;1 2;2 3],...
-            'face',[9 01 2; 1 2 3; NaN,Inf,-Inf], 'author','FangQ');
-       saveubjson('mesh',a)
-       saveubjson('',a,'ArrayIndent',0,'FloatFormat','\t%.5g')
-
- - -=== examples === - -Under the "examples" folder, you can find several scripts to demonstrate the -basic utilities of JSONlab. Running the "demo_jsonlab_basic.m" script, you -will see the conversions from MATLAB data structure to JSON text and backward. -In "jsonlab_selftest.m", we load complex JSON files downloaded from the Internet -and validate the loadjson/savejson functions for regression testing purposes. -Similarly, a "demo_ubjson_basic.m" script is provided to test the saveubjson -and loadubjson pairs for various matlab data structures. - -Please run these examples and understand how JSONlab works before you use -it to process your data. - -------------------------------------------------------------------------------- - -IV. Known Issues and TODOs - -JSONlab has several known limitations. We are striving to make it more general -and robust. Hopefully in a few future releases, the limitations become less. - -Here are the known issues: - -# Any high-dimensional cell-array will be converted to a 1D array; -# When processing names containing multi-byte characters, Octave and MATLAB \ -can give different field-names; you can use feature('DefaultCharacterSet','latin1') \ -in MATLAB to get consistant results -# Can not handle classes. -# saveubjson has not yet supported arbitrary data ([H] in the UBJSON specification) -# saveubjson now converts a logical array into a uint8 ([U]) array for now -# an unofficial N-D array count syntax is implemented in saveubjson. We are \ -actively communicating with the UBJSON spec maintainer to investigate the \ -possibility of making it upstream - -------------------------------------------------------------------------------- - -V. Contribution and feedback - -JSONlab is an open-source project. This means you can not only use it and modify -it as you wish, but also you can contribute your changes back to JSONlab so -that everyone else can enjoy the improvement. For anyone who want to contribute, -please download JSONlab source code from it's subversion repository by using the -following command: - - svn co https://iso2mesh.svn.sourceforge.net/svnroot/iso2mesh/trunk/jsonlab jsonlab - -You can make changes to the files as needed. Once you are satisfied with your -changes, and ready to share it with others, please cd the root directory of -JSONlab, and type - - svn diff > yourname_featurename.patch - -You then email the .patch file to JSONlab's maintainer, Qianqian Fang, at -the email address shown in the beginning of this file. Qianqian will review -the changes and commit it to the subversion if they are satisfactory. - -We appreciate any suggestions and feedbacks from you. Please use iso2mesh's -mailing list to report any questions you may have with JSONlab: - -http://groups.google.com/group/iso2mesh-users?hl=en&pli=1 - -(Subscription to the mailing list is needed in order to post messages). diff --git a/cb-tools/jsonlab/examples/demo_jsonlab_basic.m b/cb-tools/jsonlab/examples/demo_jsonlab_basic.m deleted file mode 100644 index d094a64e..00000000 --- a/cb-tools/jsonlab/examples/demo_jsonlab_basic.m +++ /dev/null @@ -1,161 +0,0 @@ -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Demonstration of Basic Utilities of JSONlab -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -rngstate = rand ('state'); -randseed=hex2dec('623F9A9E'); -clear data2json json2data - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a simple scalar value \n') -fprintf(1,'%%=================================================\n\n') - -data2json=pi -savejson('',data2json) -json2data=loadjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a complex number\n') -fprintf(1,'%%=================================================\n\n') - -clear i; -data2json=1+2*i -savejson('',data2json) -json2data=loadjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a complex matrix\n') -fprintf(1,'%%=================================================\n\n') - -data2json=magic(6); -data2json=data2json(:,1:3)+data2json(:,4:6)*i -savejson('',data2json) -json2data=loadjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% MATLAB special constants\n') -fprintf(1,'%%=================================================\n\n') - -data2json=[NaN Inf -Inf] -savejson('specials',data2json) -json2data=loadjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a real sparse matrix\n') -fprintf(1,'%%=================================================\n\n') - -data2json=sprand(10,10,0.1) -savejson('sparse',data2json) -json2data=loadjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a complex sparse matrix\n') -fprintf(1,'%%=================================================\n\n') - -data2json=data2json-data2json*i -savejson('complex_sparse',data2json) -json2data=loadjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% an all-zero sparse matrix\n') -fprintf(1,'%%=================================================\n\n') - -data2json=sparse(2,3); -savejson('all_zero_sparse',data2json) -json2data=loadjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% an empty sparse matrix\n') -fprintf(1,'%%=================================================\n\n') - -data2json=sparse([]); -savejson('empty_sparse',data2json) -json2data=loadjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% an empty 0-by-0 real matrix\n') -fprintf(1,'%%=================================================\n\n') - -data2json=[]; -savejson('empty_0by0_real',data2json) -json2data=loadjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% an empty 0-by-3 real matrix\n') -fprintf(1,'%%=================================================\n\n') - -data2json=zeros(0,3); -savejson('empty_0by3_real',data2json) -json2data=loadjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a sparse real column vector\n') -fprintf(1,'%%=================================================\n\n') - -data2json=sparse([0,3,0,1,4]'); -savejson('sparse_column_vector',data2json) -json2data=loadjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a sparse complex column vector\n') -fprintf(1,'%%=================================================\n\n') - -data2json=data2json-1i*data2json; -savejson('complex_sparse_column_vector',data2json) -json2data=loadjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a sparse real row vector\n') -fprintf(1,'%%=================================================\n\n') - -data2json=sparse([0,3,0,1,4]); -savejson('sparse_row_vector',data2json) -json2data=loadjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a sparse complex row vector\n') -fprintf(1,'%%=================================================\n\n') - -data2json=data2json-1i*data2json; -savejson('complex_sparse_row_vector',data2json) -json2data=loadjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a structure\n') -fprintf(1,'%%=================================================\n\n') - -data2json=struct('name','Think Different','year',1997,'magic',magic(3),... - 'misfits',[Inf,NaN],'embedded',struct('left',true,'right',false)) -savejson('astruct',data2json,struct('ParseLogical',1)) -json2data=loadjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a structure array\n') -fprintf(1,'%%=================================================\n\n') - -data2json=struct('name','Nexus Prime','rank',9); -data2json(2)=struct('name','Sentinel Prime','rank',9); -data2json(3)=struct('name','Optimus Prime','rank',9); -savejson('Supreme Commander',data2json) -json2data=loadjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a cell array\n') -fprintf(1,'%%=================================================\n\n') - -data2json=cell(3,1); -data2json{1}=struct('buzz',1.1,'rex',1.2,'bo',1.3,'hamm',2.0,'slink',2.1,'potato',2.2,... - 'woody',3.0,'sarge',3.1,'etch',4.0,'lenny',5.0,'squeeze',6.0,'wheezy',7.0); -data2json{2}=struct('Ubuntu',['Kubuntu';'Xubuntu';'Lubuntu']); -data2json{3}=[10.04,10.10,11.04,11.10] -savejson('debian',data2json,struct('FloatFormat','%.2f')) -json2data=loadjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% invalid field-name handling\n') -fprintf(1,'%%=================================================\n\n') - -json2data=loadjson('{"ValidName":1, "_InvalidName":2, ":Field:":3, "项目":"绝密"}') - -rand ('state',rngstate); - diff --git a/cb-tools/jsonlab/examples/demo_ubjson_basic.m b/cb-tools/jsonlab/examples/demo_ubjson_basic.m deleted file mode 100644 index fd5096c3..00000000 --- a/cb-tools/jsonlab/examples/demo_ubjson_basic.m +++ /dev/null @@ -1,161 +0,0 @@ -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Demonstration of Basic Utilities of JSONlab -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -rngstate = rand ('state'); -randseed=hex2dec('623F9A9E'); -clear data2json json2data - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a simple scalar value \n') -fprintf(1,'%%=================================================\n\n') - -data2json=pi -saveubjson('',data2json) -json2data=loadubjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a complex number\n') -fprintf(1,'%%=================================================\n\n') - -clear i; -data2json=1+2*i -saveubjson('',data2json) -json2data=loadubjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a complex matrix\n') -fprintf(1,'%%=================================================\n\n') - -data2json=magic(6); -data2json=data2json(:,1:3)+data2json(:,4:6)*i -saveubjson('',data2json) -json2data=loadubjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% MATLAB special constants\n') -fprintf(1,'%%=================================================\n\n') - -data2json=[NaN Inf -Inf] -saveubjson('specials',data2json) -json2data=loadubjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a real sparse matrix\n') -fprintf(1,'%%=================================================\n\n') - -data2json=sprand(10,10,0.1) -saveubjson('sparse',data2json) -json2data=loadubjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a complex sparse matrix\n') -fprintf(1,'%%=================================================\n\n') - -data2json=data2json-data2json*i -saveubjson('complex_sparse',data2json) -json2data=loadubjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% an all-zero sparse matrix\n') -fprintf(1,'%%=================================================\n\n') - -data2json=sparse(2,3); -saveubjson('all_zero_sparse',data2json) -json2data=loadubjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% an empty sparse matrix\n') -fprintf(1,'%%=================================================\n\n') - -data2json=sparse([]); -saveubjson('empty_sparse',data2json) -json2data=loadubjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% an empty 0-by-0 real matrix\n') -fprintf(1,'%%=================================================\n\n') - -data2json=[]; -saveubjson('empty_0by0_real',data2json) -json2data=loadubjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% an empty 0-by-3 real matrix\n') -fprintf(1,'%%=================================================\n\n') - -data2json=zeros(0,3); -saveubjson('empty_0by3_real',data2json) -json2data=loadubjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a sparse real column vector\n') -fprintf(1,'%%=================================================\n\n') - -data2json=sparse([0,3,0,1,4]'); -saveubjson('sparse_column_vector',data2json) -json2data=loadubjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a sparse complex column vector\n') -fprintf(1,'%%=================================================\n\n') - -data2json=data2json-1i*data2json; -saveubjson('complex_sparse_column_vector',data2json) -json2data=loadubjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a sparse real row vector\n') -fprintf(1,'%%=================================================\n\n') - -data2json=sparse([0,3,0,1,4]); -saveubjson('sparse_row_vector',data2json) -json2data=loadubjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a sparse complex row vector\n') -fprintf(1,'%%=================================================\n\n') - -data2json=data2json-1i*data2json; -saveubjson('complex_sparse_row_vector',data2json) -json2data=loadubjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a structure\n') -fprintf(1,'%%=================================================\n\n') - -data2json=struct('name','Think Different','year',1997,'magic',magic(3),... - 'misfits',[Inf,NaN],'embedded',struct('left',true,'right',false)) -saveubjson('astruct',data2json,struct('ParseLogical',1)) -json2data=loadubjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a structure array\n') -fprintf(1,'%%=================================================\n\n') - -data2json=struct('name','Nexus Prime','rank',9); -data2json(2)=struct('name','Sentinel Prime','rank',9); -data2json(3)=struct('name','Optimus Prime','rank',9); -saveubjson('Supreme Commander',data2json) -json2data=loadubjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% a cell array\n') -fprintf(1,'%%=================================================\n\n') - -data2json=cell(3,1); -data2json{1}=struct('buzz',1.1,'rex',1.2,'bo',1.3,'hamm',2.0,'slink',2.1,'potato',2.2,... - 'woody',3.0,'sarge',3.1,'etch',4.0,'lenny',5.0,'squeeze',6.0,'wheezy',7.0); -data2json{2}=struct('Ubuntu',['Kubuntu';'Xubuntu';'Lubuntu']); -data2json{3}=[10.04,10.10,11.04,11.10] -saveubjson('debian',data2json,struct('FloatFormat','%.2f')) -json2data=loadubjson(ans) - -fprintf(1,'\n%%=================================================\n') -fprintf(1,'%% invalid field-name handling\n') -fprintf(1,'%%=================================================\n\n') - -json2data=loadubjson(saveubjson('',loadjson('{"ValidName":1, "_InvalidName":2, ":Field:":3, "项目":"绝密"}'))) - -rand ('state',rngstate); - diff --git a/cb-tools/jsonlab/examples/example1.json b/cb-tools/jsonlab/examples/example1.json deleted file mode 100644 index be0993ef..00000000 --- a/cb-tools/jsonlab/examples/example1.json +++ /dev/null @@ -1,23 +0,0 @@ - { - "firstName": "John", - "lastName": "Smith", - "age": 25, - "address": - { - "streetAddress": "21 2nd Street", - "city": "New York", - "state": "NY", - "postalCode": "10021" - }, - "phoneNumber": - [ - { - "type": "home", - "number": "212 555-1234" - }, - { - "type": "fax", - "number": "646 555-4567" - } - ] - } diff --git a/cb-tools/jsonlab/examples/example2.json b/cb-tools/jsonlab/examples/example2.json deleted file mode 100644 index eacfbf5e..00000000 --- a/cb-tools/jsonlab/examples/example2.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "glossary": { - "title": "example glossary", - "GlossDiv": { - "title": "S", - "GlossList": { - "GlossEntry": { - "ID": "SGML", - "SortAs": "SGML", - "GlossTerm": "Standard Generalized Markup Language", - "Acronym": "SGML", - "Abbrev": "ISO 8879:1986", - "GlossDef": { - "para": "A meta-markup language, used to create markup languages such as DocBook.", - "GlossSeeAlso": ["GML", "XML"] - }, - "GlossSee": "markup" - } - } - } - } -} diff --git a/cb-tools/jsonlab/examples/example3.json b/cb-tools/jsonlab/examples/example3.json deleted file mode 100644 index b7ca9411..00000000 --- a/cb-tools/jsonlab/examples/example3.json +++ /dev/null @@ -1,11 +0,0 @@ -{"menu": { - "id": "file", - "value": "_&File", - "popup": { - "menuitem": [ - {"value": "_&New", "onclick": "CreateNewDoc(\"\"\")"}, - {"value": "_&Open", "onclick": "OpenDoc()"}, - {"value": "_&Close", "onclick": "CloseDoc()"} - ] - } -}} diff --git a/cb-tools/jsonlab/examples/example4.json b/cb-tools/jsonlab/examples/example4.json deleted file mode 100644 index 66deeafb..00000000 --- a/cb-tools/jsonlab/examples/example4.json +++ /dev/null @@ -1,34 +0,0 @@ -[ - { - "sample" : { - "rho" : 1 - } - }, - { - "sample" : { - "rho" : 2 - } - }, - [ - { - "_ArrayType_" : "double", - "_ArraySize_" : [1,2], - "_ArrayData_" : [1,0] - }, - { - "_ArrayType_" : "double", - "_ArraySize_" : [1,2], - "_ArrayData_" : [1,1] - }, - { - "_ArrayType_" : "double", - "_ArraySize_" : [1,2], - "_ArrayData_" : [1,2] - } - ], - [ - "Paper", - "Scissors", - "Stone" - ] -] diff --git a/cb-tools/jsonlab/examples/jsonlab_basictest.matlab b/cb-tools/jsonlab/examples/jsonlab_basictest.matlab deleted file mode 100644 index ff8380a1..00000000 --- a/cb-tools/jsonlab/examples/jsonlab_basictest.matlab +++ /dev/null @@ -1,361 +0,0 @@ - - < M A T L A B > - Copyright 1984-2007 The MathWorks, Inc. - Version 7.4.0.287 (R2007a) - January 29, 2007 - - - To get started, type one of these: helpwin, helpdesk, or demo. - For product information, visit www.mathworks.com. - ->> >> >> >> >> >> >> >> >> -%================================================= ->> % a simple scalar value ->> %================================================= - ->> >> -data2json = - - 3.1416 - ->> -ans = - -[3.141592654] - - ->> -json2data = - - 3.1416 - ->> >> -%================================================= ->> % a complex number ->> %================================================= - ->> >> >> -data2json = - - 1.0000 + 2.0000i - ->> -ans = - -{ - "_ArrayType_": "double", - "_ArraySize_": [1,1], - "_ArrayIsComplex_": 1, - "_ArrayData_": [1,2] -} - - ->> -json2data = - - 1.0000 + 2.0000i - ->> >> -%================================================= ->> % a complex matrix ->> %================================================= - ->> >> >> -data2json = - - 35.0000 +26.0000i 1.0000 +19.0000i 6.0000 +24.0000i - 3.0000 +21.0000i 32.0000 +23.0000i 7.0000 +25.0000i - 31.0000 +22.0000i 9.0000 +27.0000i 2.0000 +20.0000i - 8.0000 +17.0000i 28.0000 +10.0000i 33.0000 +15.0000i - 30.0000 +12.0000i 5.0000 +14.0000i 34.0000 +16.0000i - 4.0000 +13.0000i 36.0000 +18.0000i 29.0000 +11.0000i - ->> -ans = - -{ - "_ArrayType_": "double", - "_ArraySize_": [6,3], - "_ArrayIsComplex_": 1, - "_ArrayData_": [ - [35,26], - [3,21], - [31,22], - [8,17], - [30,12], - [4,13], - [1,19], - [32,23], - [9,27], - [28,10], - [5,14], - [36,18], - [6,24], - [7,25], - [2,20], - [33,15], - [34,16], - [29,11] - ] -} - - ->> -json2data = - - 35.0000 +26.0000i 1.0000 +19.0000i 6.0000 +24.0000i - 3.0000 +21.0000i 32.0000 +23.0000i 7.0000 +25.0000i - 31.0000 +22.0000i 9.0000 +27.0000i 2.0000 +20.0000i - 8.0000 +17.0000i 28.0000 +10.0000i 33.0000 +15.0000i - 30.0000 +12.0000i 5.0000 +14.0000i 34.0000 +16.0000i - 4.0000 +13.0000i 36.0000 +18.0000i 29.0000 +11.0000i - ->> >> -%================================================= ->> % MATLAB special constants ->> %================================================= - ->> >> -data2json = - - NaN Inf -Inf - ->> -ans = - -{ - "specials": ["_NaN_","_Inf_","-_Inf_"] -} - - ->> -json2data = - - specials: [NaN Inf -Inf] - ->> >> -%================================================= ->> % a real sparse matrix ->> %================================================= - ->> >> -data2json = - - (1,2) 0.6557 - (9,2) 0.7577 - (3,5) 0.8491 - (10,5) 0.7431 - (10,8) 0.3922 - (7,9) 0.6787 - (2,10) 0.0357 - (6,10) 0.9340 - (10,10) 0.6555 - ->> -ans = - -{ - "sparse": { - "_ArrayType_": "double", - "_ArraySize_": [10,10], - "_ArrayIsSparse_": 1, - "_ArrayData_": [ - [1,2,0.6557406992], - [9,2,0.7577401306], - [3,5,0.8491293059], - [10,5,0.7431324681], - [10,8,0.3922270195], - [7,9,0.6787351549], - [2,10,0.03571167857], - [6,10,0.9339932478], - [10,10,0.6554778902] - ] - } -} - - ->> -json2data = - - sparse: [10x10 double] - ->> >> -%================================================= ->> % a complex sparse matrix ->> %================================================= - ->> >> -data2json = - - (1,2) 0.6557 - 0.6557i - (9,2) 0.7577 - 0.7577i - (3,5) 0.8491 - 0.8491i - (10,5) 0.7431 - 0.7431i - (10,8) 0.3922 - 0.3922i - (7,9) 0.6787 - 0.6787i - (2,10) 0.0357 - 0.0357i - (6,10) 0.9340 - 0.9340i - (10,10) 0.6555 - 0.6555i - ->> -ans = - -{ - "complex_sparse": { - "_ArrayType_": "double", - "_ArraySize_": [10,10], - "_ArrayIsComplex_": 1, - "_ArrayIsSparse_": 1, - "_ArrayData_": [ - [1,2,0.6557406992,-0.6557406992], - [9,2,0.7577401306,-0.7577401306], - [3,5,0.8491293059,-0.8491293059], - [10,5,0.7431324681,-0.7431324681], - [10,8,0.3922270195,-0.3922270195], - [7,9,0.6787351549,-0.6787351549], - [2,10,0.03571167857,-0.03571167857], - [6,10,0.9339932478,-0.9339932478], - [10,10,0.6554778902,-0.6554778902] - ] - } -} - - ->> -json2data = - - complex_sparse: [10x10 double] - ->> >> -%================================================= ->> % a structure ->> %================================================= - ->> >> -data2json = - - name: 'Think Different' - year: 1997 - magic: [3x3 double] - misfits: [Inf NaN] - embedded: [1x1 struct] - ->> -ans = - -{ - "astruct": { - "name": "Think Different", - "year": 1997, - "magic": [ - [8,1,6], - [3,5,7], - [4,9,2] - ], - "misfits": ["_Inf_","_NaN_"], - "embedded": { - "left": true, - "right": false - } - } -} - - ->> -json2data = - - astruct: [1x1 struct] - ->> >> -%================================================= ->> % a structure array ->> %================================================= - ->> >> >> >> >> -ans = - -{ - "Supreme Commander": [ - { - "name": "Nexus Prime", - "rank": 9 - }, - { - "name": "Sentinel Prime", - "rank": 9 - }, - { - "name": "Optimus Prime", - "rank": 9 - } - ] -} - - ->> -json2data = - - Supreme_0x20_Commander: [1x3 struct] - ->> >> -%================================================= ->> % a cell array ->> %================================================= - ->> >> >> >> >> -data2json = - - [1x1 struct] - [1x1 struct] - [1x4 double] - ->> -ans = - -{ - "debian": [ - { - "buzz": 1.10, - "rex": 1.20, - "bo": 1.30, - "hamm": 2.00, - "slink": 2.10, - "potato": 2.20, - "woody": 3.00, - "sarge": 3.10, - "etch": 4.00, - "lenny": 5.00, - "squeeze": 6.00, - "wheezy": 7.00 - }, - { - "Ubuntu": [ - "Kubuntu", - "Xubuntu", - "Lubuntu" - ] - }, - [10.04,10.10,11.04,11.10] - ] -} - - ->> -json2data = - - debian: {[1x1 struct] [1x1 struct] [10.0400 10.1000 11.0400 11.1000]} - ->> >> -%================================================= ->> % invalid field-name handling ->> %================================================= - ->> >> -json2data = - - ValidName: 1 - x0x5F_InvalidName: 2 - x0x3A_Field_0x3A_: 3 - x0xE9A1B9__0xE79BAE_: '绝密' - ->> >> >> >> \ No newline at end of file diff --git a/cb-tools/jsonlab/examples/jsonlab_selftest.m b/cb-tools/jsonlab/examples/jsonlab_selftest.m deleted file mode 100644 index c29ffbe3..00000000 --- a/cb-tools/jsonlab/examples/jsonlab_selftest.m +++ /dev/null @@ -1,12 +0,0 @@ -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Regression Test Unit of loadjson and savejson -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -for i=1:4 - fname=sprintf('example%d.json',i); - if(exist(fname,'file')==0) break; end - fprintf(1,'===============================================\n>> %s\n',fname); - json=savejson('data',loadjson(fname)); - fprintf(1,'%s\n',json); - data=loadjson(json); -end diff --git a/cb-tools/jsonlab/examples/jsonlab_selftest.matlab b/cb-tools/jsonlab/examples/jsonlab_selftest.matlab deleted file mode 100644 index 84e66437..00000000 --- a/cb-tools/jsonlab/examples/jsonlab_selftest.matlab +++ /dev/null @@ -1,121 +0,0 @@ - - < M A T L A B > - Copyright 1984-2007 The MathWorks, Inc. - Version 7.4.0.287 (R2007a) - January 29, 2007 - - - To get started, type one of these: helpwin, helpdesk, or demo. - For product information, visit www.mathworks.com. - ->> >> >> >> >> =============================================== ->> example1.json -{ - "data": { - "firstName": "John", - "lastName": "Smith", - "age": 25, - "address": { - "streetAddress": "21 2nd Street", - "city": "New York", - "state": "NY", - "postalCode": "10021" - }, - "phoneNumber": [ - { - "type": "home", - "number": "212 555-1234" - }, - { - "type": "fax", - "number": "646 555-4567" - } - ] - } -} - -=============================================== ->> example2.json -{ - "data": { - "glossary": { - "title": "example glossary", - "GlossDiv": { - "title": "S", - "GlossList": { - "GlossEntry": { - "ID": "SGML", - "SortAs": "SGML", - "GlossTerm": "Standard Generalized Markup Language", - "Acronym": "SGML", - "Abbrev": "ISO 8879:1986", - "GlossDef": { - "para": "A meta-markup language, used to create markup languages such as DocBook.", - "GlossSeeAlso": [ - "GML", - "XML" - ] - }, - "GlossSee": "markup" - } - } - } - } - } -} - -=============================================== ->> example3.json -{ - "data": { - "menu": { - "id": "file", - "value": "_&File", - "popup": { - "menuitem": [ - { - "value": "_&New", - "onclick": "CreateNewDoc(\"\"\")" - }, - { - "value": "_&Open", - "onclick": "OpenDoc()" - }, - { - "value": "_&Close", - "onclick": "CloseDoc()" - } - ] - } - } - } -} - -=============================================== ->> example4.json -{ - "data": [ - { - "sample": { - "rho": 1 - } - }, - { - "sample": { - "rho": 2 - } - }, - [ - [1,0], - [1,1], - [1,2] - ], - [ - "Paper", - "Scissors", - "Stone" - ] - ] -} - ->> \ No newline at end of file diff --git a/cb-tools/jsonlab/examples/jsonlab_speedtest.m b/cb-tools/jsonlab/examples/jsonlab_speedtest.m deleted file mode 100644 index 4990fba0..00000000 --- a/cb-tools/jsonlab/examples/jsonlab_speedtest.m +++ /dev/null @@ -1,21 +0,0 @@ -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Benchmarking processing speed of savejson and loadjson -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -datalen=[1e3 1e4 1e5 1e6]; -len=length(datalen); -tsave=zeros(len,1); -tload=zeros(len,1); -for i=1:len - tic; - json=savejson('data',struct('d1',rand(datalen(i),3),'d2',rand(datalen(i),3)>0.5)); - tsave(i)=toc; - data=loadjson(json); - tload(i)=toc-tsave(i); - fprintf(1,'matrix size: %d\n',datalen(i)); -end - -loglog(datalen,tsave,'o-',datalen,tload,'r*-'); -legend('savejson runtime (s)','loadjson runtime (s)'); -xlabel('array size'); -ylabel('running time (s)'); diff --git a/cb-tools/jsonlab/jsonopt.m b/cb-tools/jsonlab/jsonopt.m deleted file mode 100644 index 4b541d33..00000000 --- a/cb-tools/jsonlab/jsonopt.m +++ /dev/null @@ -1,32 +0,0 @@ -function val=jsonopt(key,default,varargin) -% -% val=jsonopt(key,default,optstruct) -% -% setting options based on a struct. The struct can be produced -% by varargin2struct from a list of 'param','value' pairs -% -% authors:Qianqian Fang (fangq nmr.mgh.harvard.edu) -% -% $Id: loadjson.m 371 2012-06-20 12:43:06Z fangq $ -% -% input: -% key: a string with which one look up a value from a struct -% default: if the key does not exist, return default -% optstruct: a struct where each sub-field is a key -% -% output: -% val: if key exists, val=optstruct.key; otherwise val=default -% -% license: -% BSD or GPL version 3, see LICENSE_{BSD,GPLv3}.txt files for details -% -% -- this function is part of jsonlab toolbox (http://iso2mesh.sf.net/cgi-bin/index.cgi?jsonlab) -% - -val=default; -if(nargin<=2) return; end -opt=varargin{1}; -if(isstruct(opt) && isfield(opt,key)) - val=getfield(opt,key); -end - diff --git a/cb-tools/jsonlab/loadjson.m b/cb-tools/jsonlab/loadjson.m deleted file mode 100644 index 2c5d77db..00000000 --- a/cb-tools/jsonlab/loadjson.m +++ /dev/null @@ -1,520 +0,0 @@ -function data = loadjson(fname,varargin) -% -% data=loadjson(fname,opt) -% or -% data=loadjson(fname,'param1',value1,'param2',value2,...) -% -% parse a JSON (JavaScript Object Notation) file or string -% -% authors:Qianqian Fang (fangq nmr.mgh.harvard.edu) -% date: 2011/09/09 -% Nedialko Krouchev: http://www.mathworks.com/matlabcentral/fileexchange/25713 -% date: 2009/11/02 -% François Glineur: http://www.mathworks.com/matlabcentral/fileexchange/23393 -% date: 2009/03/22 -% Joel Feenstra: -% http://www.mathworks.com/matlabcentral/fileexchange/20565 -% date: 2008/07/03 -% -% $Id: loadjson.m 394 2012-12-18 17:58:11Z fangq $ -% -% input: -% fname: input file name, if fname contains "{}" or "[]", fname -% will be interpreted as a JSON string -% opt: a struct to store parsing options, opt can be replaced by -% a list of ('param',value) pairs. The param string is equivallent -% to a field in opt. -% -% output: -% dat: a cell array, where {...} blocks are converted into cell arrays, -% and [...] are converted to arrays -% -% license: -% BSD or GPL version 3, see LICENSE_{BSD,GPLv3}.txt files for details -% -% -- this function is part of jsonlab toolbox (http://iso2mesh.sf.net/cgi-bin/index.cgi?jsonlab) -% - -global pos inStr len esc index_esc len_esc isoct arraytoken - -if(regexp(fname,'[\{\}\]\[]','once')) - string=fname; -elseif(exist(fname,'file')) - fid = fopen(fname,'rt'); - string = fscanf(fid,'%c'); - fclose(fid); -else - error('input file does not exist'); -end - -pos = 1; len = length(string); inStr = string; -isoct=false;%this slows things down a lot! exist('OCTAVE_VERSION'); -arraytoken=find(inStr=='[' | inStr==']' | inStr=='"'); -jstr=regexprep(inStr,'\\\\',' '); -escquote=regexp(jstr,'\\"'); -arraytoken=sort([arraytoken escquote]); - -% String delimiters and escape chars identified to improve speed: -esc = find(inStr=='"' | inStr=='\' ); % comparable to: regexp(inStr, '["\\]'); -index_esc = 1; len_esc = length(esc); - -opt=varargin2struct(varargin{:}); -jsoncount=1; -while pos <= len - switch(next_char) - case '{' - data{jsoncount} = parse_object(opt); - case '[' - data{jsoncount} = parse_array(opt); - otherwise - error_pos('Outer level structure must be an object or an array'); - end - jsoncount=jsoncount+1; -end % while - -jsoncount=length(data); -if(jsoncount==1 && iscell(data)) - data=data{1}; -end - -if(~isempty(data)) - if(isstruct(data)) % data can be a struct array - data=jstruct2array(data); - elseif(iscell(data)) - data=jcell2array(data); - end -end - - -%% -function newdata=parse_collection(id,data,obj) - -if(jsoncount>0 && exist('data','var')) - if(~iscell(data)) - newdata=cell(1); - newdata{1}=data; - data=newdata; - end -end - -%% -function newdata=jcell2array(data) -len=length(data); -newdata=data; -for i=1:len - if(isstruct(data{i})) - newdata{i}=jstruct2array(data{i}); - elseif(iscell(data{i})) - newdata{i}=jcell2array(data{i}); - end -end - -%%------------------------------------------------------------------------- -function newdata=jstruct2array(data) -fn=fieldnames(data); -newdata=data; -len=length(data); -for i=1:length(fn) % depth-first - for j=1:len - if(isstruct(getfield(data(j),fn{i}))) - newdata(j)=setfield(newdata(j),fn{i},jstruct2array(getfield(data(j),fn{i}))); - end - end -end -if(~isempty(strmatch('x0x5F_ArrayType_',fn)) && ~isempty(strmatch('x0x5F_ArrayData_',fn))) - newdata=cell(len,1); - for j=1:len - ndata=cast(data(j).x0x5F_ArrayData_,data(j).x0x5F_ArrayType_); - iscpx=0; - if(~isempty(strmatch('x0x5F_ArrayIsComplex_',fn))) - if(data(j).x0x5F_ArrayIsComplex_) - iscpx=1; - end - end - if(~isempty(strmatch('x0x5F_ArrayIsSparse_',fn))) - if(data(j).x0x5F_ArrayIsSparse_) - if(~isempty(strmatch('x0x5F_ArraySize_',fn))) - dim=data(j).x0x5F_ArraySize_; - if(iscpx && size(ndata,2)==4-any(dim==1)) - ndata(:,end-1)=complex(ndata(:,end-1),ndata(:,end)); - end - if isempty(ndata) - % All-zeros sparse - ndata=sparse(dim(1),prod(dim(2:end))); - elseif dim(1)==1 - % Sparse row vector - ndata=sparse(1,ndata(:,1),ndata(:,2),dim(1),prod(dim(2:end))); - elseif dim(2)==1 - % Sparse column vector - ndata=sparse(ndata(:,1),1,ndata(:,2),dim(1),prod(dim(2:end))); - else - % Generic sparse array. - ndata=sparse(ndata(:,1),ndata(:,2),ndata(:,3),dim(1),prod(dim(2:end))); - end - else - if(iscpx && size(ndata,2)==4) - ndata(:,3)=complex(ndata(:,3),ndata(:,4)); - end - ndata=sparse(ndata(:,1),ndata(:,2),ndata(:,3)); - end - end - elseif(~isempty(strmatch('x0x5F_ArraySize_',fn))) - if(iscpx && size(ndata,2)==2) - ndata=complex(ndata(:,1),ndata(:,2)); - end - ndata=reshape(ndata(:),data(j).x0x5F_ArraySize_); - end - newdata{j}=ndata; - end - if(len==1) - newdata=newdata{1}; - end -end - -%%------------------------------------------------------------------------- -function object = parse_object(varargin) - parse_char('{'); - object = []; - if next_char ~= '}' - while 1 - str = parseStr(varargin{:}); - if isempty(str) - error_pos('Name of value at position %d cannot be empty'); - end - parse_char(':'); - val = parse_value(varargin{:}); - eval( sprintf( 'object.%s = val;', valid_field(str) ) ); - if next_char == '}' - break; - end - parse_char(','); - end - end - parse_char('}'); - -%%------------------------------------------------------------------------- - -function object = parse_array(varargin) % JSON array is written in row-major order -global pos inStr isoct - parse_char('['); - object = cell(0, 1); - dim2=[]; - if next_char ~= ']' - [endpos e1l e1r maxlevel]=matching_bracket(inStr,pos); - arraystr=['[' inStr(pos:endpos)]; - arraystr=regexprep(arraystr,'"_NaN_"','NaN'); - arraystr=regexprep(arraystr,'"([-+]*)_Inf_"','$1Inf'); - arraystr(find(arraystr==sprintf('\n')))=[]; - arraystr(find(arraystr==sprintf('\r')))=[]; - %arraystr=regexprep(arraystr,'\s*,',','); % this is slow,sometimes needed - if(~isempty(e1l) && ~isempty(e1r)) % the array is in 2D or higher D - astr=inStr((e1l+1):(e1r-1)); - astr=regexprep(astr,'"_NaN_"','NaN'); - astr=regexprep(astr,'"([-+]*)_Inf_"','$1Inf'); - astr(find(astr==sprintf('\n')))=[]; - astr(find(astr==sprintf('\r')))=[]; - astr(find(astr==' '))=''; - if(isempty(find(astr=='[', 1))) % array is 2D - dim2=length(sscanf(astr,'%f,',[1 inf])); - end - else % array is 1D - astr=arraystr(2:end-1); - astr(find(astr==' '))=''; - [obj count errmsg nextidx]=sscanf(astr,'%f,',[1,inf]); - if(nextidx>=length(astr)-1) - object=obj; - pos=endpos; - parse_char(']'); - return; - end - end - if(~isempty(dim2)) - astr=arraystr; - astr(find(astr=='['))=''; - astr(find(astr==']'))=''; - astr(find(astr==' '))=''; - [obj count errmsg nextidx]=sscanf(astr,'%f,',inf); - if(nextidx>=length(astr)-1) - object=reshape(obj,dim2,numel(obj)/dim2)'; - pos=endpos; - parse_char(']'); - return; - end - end - arraystr=regexprep(arraystr,'\]\s*,','];'); - try - if(isoct && regexp(arraystr,'"','once')) - error('Octave eval can produce empty cells for JSON-like input'); - end - object=eval(arraystr); - pos=endpos; - catch - while 1 - val = parse_value(varargin{:}); - object{end+1} = val; - if next_char == ']' - break; - end - parse_char(','); - end - end - end - % 2014-02-13 CB added check to not "simplify" cells of strings - if jsonopt('SimplifyCell',0,varargin{:}) == 1 && ~iscellstr(object) - try - oldobj=object; - object=cell2mat(object')'; - if(iscell(oldobj) && isstruct(object) && numel(object)>1 && jsonopt('SimplifyCellArray',1,varargin{:})==0) - object=oldobj; - elseif(size(object,1)>1 && ndims(object)==2) - object=object'; - end - catch - end - end - parse_char(']'); - -%%------------------------------------------------------------------------- - -function parse_char(c) - global pos inStr len - skip_whitespace; - if pos > len || inStr(pos) ~= c - error_pos(sprintf('Expected %c at position %%d', c)); - else - pos = pos + 1; - skip_whitespace; - end - -%%------------------------------------------------------------------------- - -function c = next_char - global pos inStr len - skip_whitespace; - if pos > len - c = []; - else - c = inStr(pos); - end - -%%------------------------------------------------------------------------- - -function skip_whitespace - global pos inStr len - while pos <= len && isspace(inStr(pos)) - pos = pos + 1; - end - -%%------------------------------------------------------------------------- -function str = parseStr(varargin) - global pos inStr len esc index_esc len_esc - % len, ns = length(inStr), keyboard - if inStr(pos) ~= '"' - error_pos('String starting with " expected at position %d'); - else - pos = pos + 1; - end - str = ''; - while pos <= len - while index_esc <= len_esc && esc(index_esc) < pos - index_esc = index_esc + 1; - end - if index_esc > len_esc - str = [str inStr(pos:len)]; - pos = len + 1; - break; - else - str = [str inStr(pos:esc(index_esc)-1)]; - pos = esc(index_esc); - end - nstr = length(str); switch inStr(pos) - case '"' - pos = pos + 1; - if(~isempty(str)) - if(strcmp(str,'_Inf_')) - str=Inf; - elseif(strcmp(str,'-_Inf_')) - str=-Inf; - elseif(strcmp(str,'_NaN_')) - str=NaN; - end - end - return; - case '\' - if pos+1 > len - error_pos('End of file reached right after escape character'); - end - pos = pos + 1; - switch inStr(pos) - case {'"' '\' '/'} - str(nstr+1) = inStr(pos); - pos = pos + 1; - case {'b' 'f' 'n' 'r' 't'} - str(nstr+1) = sprintf(['\' inStr(pos)]); - pos = pos + 1; - case 'u' - if pos+4 > len - error_pos('End of file reached in escaped unicode character'); - end - str(nstr+(1:6)) = inStr(pos-1:pos+4); - pos = pos + 5; - end - otherwise % should never happen - str(nstr+1) = inStr(pos), keyboard - pos = pos + 1; - end - end - error_pos('End of file while expecting end of inStr'); - -%%------------------------------------------------------------------------- - -function num = parse_number(varargin) - global pos inStr len isoct - currstr=inStr(pos:end); - numstr=0; - if(isoct~=0) - numstr=regexp(currstr,'^\s*-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+\-]?\d+)?','end'); - [num, one] = sscanf(currstr, '%f', 1); - delta=numstr+1; - else - [num, one, err, delta] = sscanf(currstr, '%f', 1); - if ~isempty(err) - error_pos('Error reading number at position %d'); - end - end - pos = pos + delta-1; - -%%------------------------------------------------------------------------- - -function val = parse_value(varargin) - global pos inStr len - true = 1; false = 0; - - switch(inStr(pos)) - case '"' - val = parseStr(varargin{:}); - return; - case '[' - val = parse_array(varargin{:}); - return; - case '{' - val = parse_object(varargin{:}); - return; - case {'-','0','1','2','3','4','5','6','7','8','9'} - val = parse_number(varargin{:}); - return; - case 't' - if pos+3 <= len && strcmpi(inStr(pos:pos+3), 'true') - val = true; - pos = pos + 4; - return; - end - case 'f' - if pos+4 <= len && strcmpi(inStr(pos:pos+4), 'false') - val = false; - pos = pos + 5; - return; - end - case 'n' - if pos+3 <= len && strcmpi(inStr(pos:pos+3), 'null') - val = []; - pos = pos + 4; - return; - end - end - error_pos('Value expected at position %d'); -%%------------------------------------------------------------------------- - -function error_pos(msg) - global pos inStr len - poShow = max(min([pos-15 pos-1 pos pos+20],len),1); - if poShow(3) == poShow(2) - poShow(3:4) = poShow(2)+[0 -1]; % display nothing after - end - msg = [sprintf(msg, pos) ': ' ... - inStr(poShow(1):poShow(2)) '' inStr(poShow(3):poShow(4)) ]; - error( ['JSONparser:invalidFormat: ' msg] ); - -%%------------------------------------------------------------------------- - -function str = valid_field(str) -global isoct -% From MATLAB doc: field names must begin with a letter, which may be -% followed by any combination of letters, digits, and underscores. -% Invalid characters will be converted to underscores, and the prefix -% "x0x[Hex code]_" will be added if the first character is not a letter. - pos=regexp(str,'^[^A-Za-z]','once'); - if(~isempty(pos)) - if(~isoct) - str=regexprep(str,'^([^A-Za-z])','x0x${sprintf(''%X'',unicode2native($1))}_','once'); - else - str=sprintf('x0x%X_%s',char(str(1)),str(2:end)); - end - end - if(isempty(regexp(str,'[^0-9A-Za-z_]', 'once' ))) return; end - if(~isoct) - str=regexprep(str,'([^0-9A-Za-z_])','_0x${sprintf(''%X'',unicode2native($1))}_'); - else - pos=regexp(str,'[^0-9A-Za-z_]'); - if(isempty(pos)) return; end - str0=str; - pos0=[0 pos(:)' length(str)]; - str=''; - for i=1:length(pos) - str=[str str0(pos0(i)+1:pos(i)-1) sprintf('_0x%X_',str0(pos(i)))]; - end - if(pos(end)~=length(str)) - str=[str str0(pos0(end-1)+1:pos0(end))]; - end - end - %str(~isletter(str) & ~('0' <= str & str <= '9')) = '_'; - -%%------------------------------------------------------------------------- -function endpos = matching_quote(str,pos) -len=length(str); -while(pos1 && str(pos-1)=='\')) - endpos=pos; - return; - end - end - pos=pos+1; -end -error('unmatched quotation mark'); -%%------------------------------------------------------------------------- -function [endpos e1l e1r maxlevel] = matching_bracket(str,pos) -global arraytoken -level=1; -maxlevel=level; -endpos=0; -bpos=arraytoken(arraytoken>=pos); -tokens=str(bpos); -len=length(tokens); -pos=1; -e1l=[]; -e1r=[]; -while(pos<=len) - c=tokens(pos); - if(c==']') - level=level-1; - if(isempty(e1r)) e1r=bpos(pos); end - if(level==0) - endpos=bpos(pos); - return - end - end - if(c=='[') - if(isempty(e1l)) e1l=bpos(pos); end - level=level+1; - maxlevel=max(maxlevel,level); - end - if(c=='"') - pos=matching_quote(tokens,pos+1); - end - pos=pos+1; -end -if(endpos==0) - error('unmatched "]"'); -end - diff --git a/cb-tools/jsonlab/loadubjson.m b/cb-tools/jsonlab/loadubjson.m deleted file mode 100644 index 5ea53461..00000000 --- a/cb-tools/jsonlab/loadubjson.m +++ /dev/null @@ -1,478 +0,0 @@ -function data = loadubjson(fname,varargin) -% -% data=loadubjson(fname,opt) -% or -% data=loadubjson(fname,'param1',value1,'param2',value2,...) -% -% parse a JSON (JavaScript Object Notation) file or string -% -% authors:Qianqian Fang (fangq nmr.mgh.harvard.edu) -% date: 2013/08/01 -% -% $Id: loadubjson.m 410 2013-08-24 03:33:18Z fangq $ -% -% input: -% fname: input file name, if fname contains "{}" or "[]", fname -% will be interpreted as a UBJSON string -% opt: a struct to store parsing options, opt can be replaced by -% a list of ('param',value) pairs. The param string is equivallent -% to a field in opt. -% -% output: -% dat: a cell array, where {...} blocks are converted into cell arrays, -% and [...] are converted to arrays -% -% license: -% BSD or GPL version 3, see LICENSE_{BSD,GPLv3}.txt files for details -% -% -- this function is part of jsonlab toolbox (http://iso2mesh.sf.net/cgi-bin/index.cgi?jsonlab) -% - -global pos inStr len esc index_esc len_esc isoct arraytoken - -if(regexp(fname,'[\{\}\]\[]','once')) - string=fname; -elseif(exist(fname,'file')) - fid = fopen(fname,'rt'); - string = fscanf(fid,'%c'); - fclose(fid); -else - error('input file does not exist'); -end - -pos = 1; len = length(string); inStr = string; -isoct=exist('OCTAVE_VERSION'); -arraytoken=find(inStr=='[' | inStr==']' | inStr=='"'); -jstr=regexprep(inStr,'\\\\',' '); -escquote=regexp(jstr,'\\"'); -arraytoken=sort([arraytoken escquote]); - -% String delimiters and escape chars identified to improve speed: -esc = find(inStr=='"' | inStr=='\' ); % comparable to: regexp(inStr, '["\\]'); -index_esc = 1; len_esc = length(esc); - -opt=varargin2struct(varargin{:}); -jsoncount=1; -while pos <= len - switch(next_char) - case '{' - data{jsoncount} = parse_object(opt); - case '[' - data{jsoncount} = parse_array(opt); - otherwise - error_pos('Outer level structure must be an object or an array'); - end - jsoncount=jsoncount+1; -end % while - -jsoncount=length(data); -if(jsoncount==1 && iscell(data)) - data=data{1}; -end - -if(~isempty(data)) - if(isstruct(data)) % data can be a struct array - data=jstruct2array(data); - elseif(iscell(data)) - data=jcell2array(data); - end -end - - -%% -function newdata=parse_collection(id,data,obj) - -if(jsoncount>0 && exist('data','var')) - if(~iscell(data)) - newdata=cell(1); - newdata{1}=data; - data=newdata; - end -end - -%% -function newdata=jcell2array(data) -len=length(data); -newdata=data; -for i=1:len - if(isstruct(data{i})) - newdata{i}=jstruct2array(data{i}); - elseif(iscell(data{i})) - newdata{i}=jcell2array(data{i}); - end -end - -%%------------------------------------------------------------------------- -function newdata=jstruct2array(data) -fn=fieldnames(data); -newdata=data; -len=length(data); -for i=1:length(fn) % depth-first - for j=1:len - if(isstruct(getfield(data(j),fn{i}))) - newdata(j)=setfield(newdata(j),fn{i},jstruct2array(getfield(data(j),fn{i}))); - end - end -end -if(~isempty(strmatch('x0x5F_ArrayType_',fn)) && ~isempty(strmatch('x0x5F_ArrayData_',fn))) - newdata=cell(len,1); - for j=1:len - ndata=cast(data(j).x0x5F_ArrayData_,data(j).x0x5F_ArrayType_); - iscpx=0; - if(~isempty(strmatch('x0x5F_ArrayIsComplex_',fn))) - if(data(j).x0x5F_ArrayIsComplex_) - iscpx=1; - end - end - if(~isempty(strmatch('x0x5F_ArrayIsSparse_',fn))) - if(data(j).x0x5F_ArrayIsSparse_) - if(~isempty(strmatch('x0x5F_ArraySize_',fn))) - dim=double(data(j).x0x5F_ArraySize_); - if(iscpx && size(ndata,2)==4-any(dim==1)) - ndata(:,end-1)=complex(ndata(:,end-1),ndata(:,end)); - end - if isempty(ndata) - % All-zeros sparse - ndata=sparse(dim(1),prod(dim(2:end))); - elseif dim(1)==1 - % Sparse row vector - ndata=sparse(1,ndata(:,1),ndata(:,2),dim(1),prod(dim(2:end))); - elseif dim(2)==1 - % Sparse column vector - ndata=sparse(ndata(:,1),1,ndata(:,2),dim(1),prod(dim(2:end))); - else - % Generic sparse array. - ndata=sparse(ndata(:,1),ndata(:,2),ndata(:,3),dim(1),prod(dim(2:end))); - end - else - if(iscpx && size(ndata,2)==4) - ndata(:,3)=complex(ndata(:,3),ndata(:,4)); - end - ndata=sparse(ndata(:,1),ndata(:,2),ndata(:,3)); - end - end - elseif(~isempty(strmatch('x0x5F_ArraySize_',fn))) - if(iscpx && size(ndata,2)==2) - ndata=complex(ndata(:,1),ndata(:,2)); - end - ndata=reshape(ndata(:),data(j).x0x5F_ArraySize_); - end - newdata{j}=ndata; - end - if(len==1) - newdata=newdata{1}; - end -end - -%%------------------------------------------------------------------------- -function object = parse_object(varargin) - parse_char('{'); - object = []; - if next_char ~= '}' - while 1 - str = parseStr(varargin{:}); - if isempty(str) - error_pos('Name of value at position %d cannot be empty'); - end - %parse_char(':'); - val = parse_value(varargin{:}); - eval( sprintf( 'object.%s = val;', valid_field(str) ) ); - if next_char == '}' - break; - end - %parse_char(','); - end - end - parse_char('}'); - -%%------------------------------------------------------------------------- -function [cid,len]=elem_info(type) -id=strfind('iUIlLdD',type); -dataclass={'int8','uint8','int16','int32','int64','single','double'}; -bytelen=[1,1,2,4,8,4,8]; -if(id>0) - cid=dataclass{id}; - len=bytelen(id); -else - error_pos('unsupported type at position %d'); -end -%%------------------------------------------------------------------------- - - -function [data adv]=parse_block(type,count,varargin) -global pos inStr isoct -[cid,len]=elem_info(type); -if(isoct) - data=typecast(int8(inStr(pos:pos+len*count-1)),cid); -else - data=typecast(uint8(inStr(pos:pos+len*count-1)),cid); -end -adv=double(len*count); - -%%------------------------------------------------------------------------- - - -function object = parse_array(varargin) % JSON array is written in row-major order -global pos inStr isoct - parse_char('['); - object = cell(0, 1); - dim=[]; - type=''; - count=-1; - if(next_char == '$') - type=inStr(pos+1); - pos=pos+2; - end - if(next_char == '#') - pos=pos+1; - if(next_char=='[') - dim=parse_array(varargin{:}); - count=prod(double(dim)); - else - count=double(parse_number()); - end - end - if(~isempty(type)) - if(count>=0) - [object adv]=parse_block(type,count,varargin{:}); - if(~isempty(dim)) - object=reshape(object,dim); - end - pos=pos+adv; - return; - else - endpos=matching_bracket(inStr,pos); - [cid,len]=elem_info(type); - count=(endpos-pos)/len; - [object adv]=parse_block(type,count,varargin{:}); - pos=pos+adv; - parse_char(']'); - return; - end - end - - if next_char ~= ']' - while 1 - val = parse_value(varargin{:}); - object{end+1} = val; - if next_char == ']' - break; - end - %parse_char(','); - end - end - if(jsonopt('SimplifyCell',0,varargin{:})==1) - try - oldobj=object; - object=cell2mat(object')'; - if(iscell(oldobj) && isstruct(object) && numel(object)>1 && jsonopt('SimplifyCellArray',1,varargin{:})==0) - object=oldobj; - elseif(size(object,1)>1 && ndims(object)==2) - object=object'; - end - catch - end - end - if(count==-1) - parse_char(']'); - end - -%%------------------------------------------------------------------------- - -function parse_char(c) - global pos inStr len - skip_whitespace; - if pos > len || inStr(pos) ~= c - error_pos(sprintf('Expected %c at position %%d', c)); - else - pos = pos + 1; - skip_whitespace; - end - -%%------------------------------------------------------------------------- - -function c = next_char - global pos inStr len - skip_whitespace; - if pos > len - c = []; - else - c = inStr(pos); - end - -%%------------------------------------------------------------------------- - -function skip_whitespace - global pos inStr len - while pos <= len && isspace(inStr(pos)) - pos = pos + 1; - end - -%%------------------------------------------------------------------------- -function str = parseStr(varargin) - global pos inStr esc index_esc len_esc - % len, ns = length(inStr), keyboard - type=inStr(pos); - if type ~= 'S' && type ~= 'C' - error_pos('String starting with S expected at position %d'); - else - pos = pos + 1; - end - if(type == 'C') - str=inStr(pos); - pos=pos+1; - return; - end - bytelen=double(parse_number()); - if(length(inStr)>=pos+bytelen-1) - str=inStr(pos:pos+bytelen-1); - pos=pos+bytelen; - else - error_pos('End of file while expecting end of inStr'); - end - -%%------------------------------------------------------------------------- - -function num = parse_number(varargin) - global pos inStr len isoct - id=strfind('iUIlLdD',inStr(pos)); - if(isempty(id)) - error_pos('expecting a number at position %d'); - end - type={'int8','uint8','int16','int32','int64','single','double'}; - bytelen=[1,1,2,4,8,4,8]; - if(isoct) - num=typecast(int8(inStr(pos+1:pos+bytelen(id))),type{id}); - else - num=typecast(uint8(inStr(pos+1:pos+bytelen(id))),type{id}); - end - pos = pos + bytelen(id)+1; - -%%------------------------------------------------------------------------- - -function val = parse_value(varargin) - global pos inStr len - true = 1; false = 0; - - switch(inStr(pos)) - case {'S','C'} - val = parseStr(varargin{:}); - return; - case '[' - val = parse_array(varargin{:}); - return; - case '{' - val = parse_object(varargin{:}); - return; - case {'i','U','I','l','L','d','D'} - val = parse_number(varargin{:}); - return; - case 'T' - val = true; - pos = pos + 1; - return; - case 'F' - val = false; - pos = pos + 1; - return; - case {'Z','N'} - val = []; - pos = pos + 1; - return; - end - error_pos('Value expected at position %d'); -%%------------------------------------------------------------------------- - -function error_pos(msg) - global pos inStr len - poShow = max(min([pos-15 pos-1 pos pos+20],len),1); - if poShow(3) == poShow(2) - poShow(3:4) = poShow(2)+[0 -1]; % display nothing after - end - msg = [sprintf(msg, pos) ': ' ... - inStr(poShow(1):poShow(2)) '' inStr(poShow(3):poShow(4)) ]; - error( ['JSONparser:invalidFormat: ' msg] ); - -%%------------------------------------------------------------------------- - -function str = valid_field(str) -global isoct -% From MATLAB doc: field names must begin with a letter, which may be -% followed by any combination of letters, digits, and underscores. -% Invalid characters will be converted to underscores, and the prefix -% "x0x[Hex code]_" will be added if the first character is not a letter. - pos=regexp(str,'^[^A-Za-z]','once'); - if(~isempty(pos)) - if(~isoct) - str=regexprep(str,'^([^A-Za-z])','x0x${sprintf(''%X'',unicode2native($1))}_','once'); - else - str=sprintf('x0x%X_%s',char(str(1)),str(2:end)); - end - end - if(isempty(regexp(str,'[^0-9A-Za-z_]', 'once' ))) return; end - if(~isoct) - str=regexprep(str,'([^0-9A-Za-z_])','_0x${sprintf(''%X'',unicode2native($1))}_'); - else - pos=regexp(str,'[^0-9A-Za-z_]'); - if(isempty(pos)) return; end - str0=str; - pos0=[0 pos(:)' length(str)]; - str=''; - for i=1:length(pos) - str=[str str0(pos0(i)+1:pos(i)-1) sprintf('_0x%X_',str0(pos(i)))]; - end - if(pos(end)~=length(str)) - str=[str str0(pos0(end-1)+1:pos0(end))]; - end - end - %str(~isletter(str) & ~('0' <= str & str <= '9')) = '_'; - -%%------------------------------------------------------------------------- -function endpos = matching_quote(str,pos) -len=length(str); -while(pos1 && str(pos-1)=='\')) - endpos=pos; - return; - end - end - pos=pos+1; -end -error('unmatched quotation mark'); -%%------------------------------------------------------------------------- -function [endpos e1l e1r maxlevel] = matching_bracket(str,pos) -global arraytoken -level=1; -maxlevel=level; -endpos=0; -bpos=arraytoken(arraytoken>=pos); -tokens=str(bpos); -len=length(tokens); -pos=1; -e1l=[]; -e1r=[]; -while(pos<=len) - c=tokens(pos); - if(c==']') - level=level-1; - if(isempty(e1r)) e1r=bpos(pos); end - if(level==0) - endpos=bpos(pos); - return - end - end - if(c=='[') - if(isempty(e1l)) e1l=bpos(pos); end - level=level+1; - maxlevel=max(maxlevel,level); - end - if(c=='"') - pos=matching_quote(tokens,pos+1); - end - pos=pos+1; -end -if(endpos==0) - error('unmatched "]"'); -end - diff --git a/cb-tools/jsonlab/mergestruct.m b/cb-tools/jsonlab/mergestruct.m deleted file mode 100644 index ce344ad2..00000000 --- a/cb-tools/jsonlab/mergestruct.m +++ /dev/null @@ -1,33 +0,0 @@ -function s=mergestruct(s1,s2) -% -% s=mergestruct(s1,s2) -% -% merge two struct objects into one -% -% authors:Qianqian Fang (fangq nmr.mgh.harvard.edu) -% date: 2012/12/22 -% -% input: -% s1,s2: a struct object, s1 and s2 can not be arrays -% -% output: -% s: the merged struct object. fields in s1 and s2 will be combined in s. -% -% license: -% BSD or GPL version 3, see LICENSE_{BSD,GPLv3}.txt files for details -% -% -- this function is part of jsonlab toolbox (http://iso2mesh.sf.net/cgi-bin/index.cgi?jsonlab) -% - -if(~isstruct(s1) || ~isstruct(s2)) - error('input parameters contain non-struct'); -end -if(length(s1)>1 || length(s2)>1) - error('can not merge struct arrays'); -end -fn=fieldnames(s2); -s=s1; -for i=1:length(fn) - s=setfield(s,fn{i},getfield(s2,fn{i})); -end - diff --git a/cb-tools/jsonlab/origsavejson.m b/cb-tools/jsonlab/origsavejson.m deleted file mode 100644 index a4860ee0..00000000 --- a/cb-tools/jsonlab/origsavejson.m +++ /dev/null @@ -1,386 +0,0 @@ -function json=savejson(rootname,obj,varargin) -% -% json=savejson(rootname,obj,filename) -% or -% json=savejson(rootname,obj,opt) -% json=savejson(rootname,obj,'param1',value1,'param2',value2,...) -% -% convert a MATLAB object (cell, struct or array) into a JSON (JavaScript -% Object Notation) string -% -% author: Qianqian Fang (fangq nmr.mgh.harvard.edu) -% created on 2011/09/09 -% -% $Id: savejson.m 394 2012-12-18 17:58:11Z fangq $ -% -% input: -% rootname: name of the root-object, if set to '', will use variable name -% obj: a MATLAB object (array, cell, cell array, struct, struct array) -% filename: a string for the file name to save the output JSON data -% opt: a struct for additional options, use [] if all use default -% opt can have the following fields (first in [.|.] is the default) -% -% opt.FileName [''|string]: a file name to save the output JSON data -% opt.FloatFormat ['%.10g'|string]: format to show each numeric element -% of a 1D/2D array; -% opt.ArrayIndent [1|0]: if 1, output explicit data array with -% precedent indentation; if 0, no indentation -% opt.ArrayToStruct[0|1]: when set to 0, savejson outputs 1D/2D -% array in JSON array format; if sets to 1, an -% array will be shown as a struct with fields -% "_ArrayType_", "_ArraySize_" and "_ArrayData_"; for -% sparse arrays, the non-zero elements will be -% saved to _ArrayData_ field in triplet-format i.e. -% (ix,iy,val) and "_ArrayIsSparse_" will be added -% with a value of 1; for a complex array, the -% _ArrayData_ array will include two columns -% (4 for sparse) to record the real and imaginary -% parts, and also "_ArrayIsComplex_":1 is added. -% opt.ParseLogical [0|1]: if this is set to 1, logical array elem -% will use true/false rather than 1/0. -% opt.NoRowBracket [1|0]: if this is set to 1, arrays with a single -% numerical element will be shown without a square -% bracket, unless it is the root object; if 0, square -% brackets are forced for any numerical arrays. -% opt.ForceRootName [0|1]: when set to 1 and rootname is empty, savejson -% will use the name of the passed obj variable as the -% root object name; if obj is an expression and -% does not have a name, 'root' will be used; if this -% is set to 0 and rootname is empty, the root level -% will be merged down to the lower level. -% opt.Inf ['"$1_Inf_"'|string]: a customized regular expression pattern -% to represent +/-Inf. The matched pattern is '([-+]*)Inf' -% and $1 represents the sign. For those who want to use -% 1e999 to represent Inf, they can set opt.Inf to '$11e999' -% opt.NaN ['"_NaN_"'|string]: a customized regular expression pattern -% to represent NaN -% opt.JSONP [''|string]: to generate a JSONP output (JSON with padding), -% for example, if opt.JSON='foo', the JSON data is -% wrapped inside a function call as 'foo(...);' -% opt.UnpackHex [1|0]: conver the 0x[hex code] output by loadjson -% back to the string form -% opt can be replaced by a list of ('param',value) pairs. The param -% string is equivallent to a field in opt. -% output: -% json: a string in the JSON format (see http://json.org) -% -% examples: -% a=struct('node',[1 9 10; 2 1 1.2], 'elem',[9 1;1 2;2 3],... -% 'face',[9 01 2; 1 2 3; NaN,Inf,-Inf], 'author','FangQ'); -% savejson('mesh',a) -% savejson('',a,'ArrayIndent',0,'FloatFormat','\t%.5g') -% -% license: -% BSD or GPL version 3, see LICENSE_{BSD,GPLv3}.txt files for details -% -% -- this function is part of jsonlab toolbox (http://iso2mesh.sf.net/cgi-bin/index.cgi?jsonlab) -% - -if(nargin==1) - varname=inputname(1); - obj=rootname; - if(isempty(varname)) - varname='root'; - end - rootname=varname; -else - varname=inputname(2); -end -if(length(varargin)==1 && ischar(varargin{1})) - opt=struct('FileName',varargin{1}); -else - opt=varargin2struct(varargin{:}); -end -opt.IsOctave=exist('OCTAVE_VERSION'); -rootisarray=0; -rootlevel=1; -forceroot=jsonopt('ForceRootName',0,opt); -if((isnumeric(obj) || islogical(obj) || ischar(obj) || isstruct(obj) || iscell(obj)) && isempty(rootname) && forceroot==0) - rootisarray=1; - rootlevel=0; -else - if(isempty(rootname)) - rootname=varname; - end -end -if((isstruct(obj) || iscell(obj))&& isempty(rootname) && forceroot) - rootname='root'; -end -json=obj2json(rootname,obj,rootlevel,opt); -if(rootisarray) - json=sprintf('%s\n',json); -else - json=sprintf('{\n%s\n}\n',json); -end - -jsonp=jsonopt('JSONP','',opt); -if(~isempty(jsonp)) - json=sprintf('%s(%s);\n',jsonp,json); -end - -% save to a file if FileName is set, suggested by Patrick Rapin -if(~isempty(jsonopt('FileName','',opt))) - fid = fopen(opt.FileName, 'wt'); - fwrite(fid,json,'char'); - fclose(fid); -end - -%%------------------------------------------------------------------------- -function txt=obj2json(name,item,level,varargin) - -if(iscell(item)) - txt=cell2json(name,item,level,varargin{:}); -elseif(isstruct(item)) - txt=struct2json(name,item,level,varargin{:}); -elseif(ischar(item)) - txt=str2json(name,item,level,varargin{:}); -else - txt=mat2json(name,item,level,varargin{:}); -end - -%%------------------------------------------------------------------------- -function txt=cell2json(name,item,level,varargin) -txt=''; -if(~iscell(item)) - error('input is not a cell'); -end - -dim=size(item); -len=numel(item); % let's handle 1D cell first -level -padding1=repmat(sprintf('\t'),1,level-1); -padding0=repmat(sprintf('\t'),1,level); -if(len>1) - if(~isempty(name)) - txt=sprintf('%s"%s": [\n',padding0, checkname(name,varargin{:})); name=''; - else - txt=sprintf('%s[\n',padding0); - end -elseif(len==0) - if(~isempty(name)) - txt=sprintf('%s"%s": null',padding0, checkname(name,varargin{:})); name=''; - else - txt=sprintf('%snull',padding0); - end -end -for i=1:len - txt=sprintf('%s%s%s',txt,padding1,obj2json(name,item{i},level+(len>1),varargin{:})); - if(i1) txt=sprintf('%s\n%s]',txt,padding0); end - -%%------------------------------------------------------------------------- -function txt=struct2json(name,item,level,varargin) -txt=''; -if(~isstruct(item)) - error('input is not a struct'); -end -len=numel(item); -padding1=repmat(sprintf('\t'),1,level-1); -padding0=repmat(sprintf('\t'),1,level); -sep=','; - -if(~isempty(name)) - if(len>1) txt=sprintf('%s"%s": [\n',padding0,checkname(name,varargin{:})); end -else - if(len>1) txt=sprintf('%s[\n',padding0); end -end -for e=1:len - names = fieldnames(item(e)); - if(~isempty(name) && len==1) - txt=sprintf('%s%s"%s": {\n',txt,repmat(sprintf('\t'),1,level+(len>1)), checkname(name,varargin{:})); - else - txt=sprintf('%s%s{\n',txt,repmat(sprintf('\t'),1,level+(len>1))); - end - if(~isempty(names)) - for i=1:length(names) - txt=sprintf('%s%s',txt,obj2json(names{i},getfield(item(e),... - names{i}),level+1+(len>1),varargin{:})); - if(i1))); - if(e==len) sep=''; end - if(e1) txt=sprintf('%s\n%s]',txt,padding0); end - -%%------------------------------------------------------------------------- -function txt=str2json(name,item,level,varargin) -txt=''; -if(~ischar(item)) - error('input is not a string'); -end -item=reshape(item, max(size(item),[1 0])); -len=size(item,1); -sep=sprintf(',\n'); - -padding1=repmat(sprintf('\t'),1,level); -padding0=repmat(sprintf('\t'),1,level+1); - -if(~isempty(name)) - if(len>1) txt=sprintf('%s"%s": [\n',padding1,checkname(name,varargin{:})); end -else - if(len>1) txt=sprintf('%s[\n',padding1); end -end -isoct=jsonopt('IsOctave',0,varargin{:}); -for e=1:len - if(isoct) - val=regexprep(item(e,:),'\\','\\'); - val=regexprep(val,'"','\"'); - val=regexprep(val,'^"','\"'); - else - val=regexprep(item(e,:),'\\','\\\\'); - val=regexprep(val,'"','\\"'); - val=regexprep(val,'^"','\\"'); - end - if(len==1) - obj=['"' checkname(name,varargin{:}) '": ' '"',val,'"']; - if(isempty(name)) obj=['"',val,'"']; end - txt=sprintf('%s%s%s%s',txt,repmat(sprintf('\t'),1,level),obj); - else - txt=sprintf('%s%s%s%s',txt,repmat(sprintf('\t'),1,level+1),['"',val,'"']); - end - if(e==len) sep=''; end - txt=sprintf('%s%s',txt,sep); -end -if(len>1) txt=sprintf('%s\n%s%s',txt,padding1,']'); end - -%%------------------------------------------------------------------------- -function txt=mat2json(name,item,level,varargin) -if(~isnumeric(item) && ~islogical(item)) - error('input is not an array'); -end - -padding1=repmat(sprintf('\t'),1,level); -padding0=repmat(sprintf('\t'),1,level+1); - -if(length(size(item))>2 || issparse(item) || ~isreal(item) || ... - isempty(item) ||jsonopt('ArrayToStruct',0,varargin{:})) - if(isempty(name)) - txt=sprintf('%s{\n%s"_ArrayType_": "%s",\n%s"_ArraySize_": %s,\n',... - padding1,padding0,class(item),padding0,regexprep(mat2str(size(item)),'\s+',',') ); - else - txt=sprintf('%s"%s": {\n%s"_ArrayType_": "%s",\n%s"_ArraySize_": %s,\n',... - padding1,checkname(name,varargin{:}),padding0,class(item),padding0,regexprep(mat2str(size(item)),'\s+',',') ); - end -else - if(isempty(name)) - txt=sprintf('%s%s',padding1,matdata2json(item,level+1,varargin{:})); - else - if(numel(item)==1 && jsonopt('NoRowBracket',1,varargin{:})==1) - numtxt=regexprep(regexprep(matdata2json(item,level+1,varargin{:}),'^\[',''),']',''); - txt=sprintf('%s"%s": %s',padding1,checkname(name,varargin{:}),numtxt); - else - txt=sprintf('%s"%s": %s',padding1,checkname(name,varargin{:}),matdata2json(item,level+1,varargin{:})); - end - end - return; -end -dataformat='%s%s%s%s%s'; - -if(issparse(item)) - [ix,iy]=find(item); - data=full(item(find(item))); - if(~isreal(item)) - data=[real(data(:)),imag(data(:))]; - if(size(item,1)==1) - % Kludge to have data's 'transposedness' match item's. - % (Necessary for complex row vector handling below.) - data=data'; - end - txt=sprintf(dataformat,txt,padding0,'"_ArrayIsComplex_": ','1', sprintf(',\n')); - end - txt=sprintf(dataformat,txt,padding0,'"_ArrayIsSparse_": ','1', sprintf(',\n')); - if(size(item,1)==1) - % Row vector, store only column indices. - txt=sprintf(dataformat,txt,padding0,'"_ArrayData_": ',... - matdata2json([iy(:),data'],level+2,varargin{:}), sprintf('\n')); - elseif(size(item,2)==1) - % Column vector, store only row indices. - txt=sprintf(dataformat,txt,padding0,'"_ArrayData_": ',... - matdata2json([ix,data],level+2,varargin{:}), sprintf('\n')); - else - % General case, store row and column indices. - txt=sprintf(dataformat,txt,padding0,'"_ArrayData_": ',... - matdata2json([ix,iy,data],level+2,varargin{:}), sprintf('\n')); - end -else - if(isreal(item)) - txt=sprintf(dataformat,txt,padding0,'"_ArrayData_": ',... - matdata2json(item(:)',level+2,varargin{:}), sprintf('\n')); - else - txt=sprintf(dataformat,txt,padding0,'"_ArrayIsComplex_": ','1', sprintf(',\n')); - txt=sprintf(dataformat,txt,padding0,'"_ArrayData_": ',... - matdata2json([real(item(:)) imag(item(:))],level+2,varargin{:}), sprintf('\n')); - end -end -txt=sprintf('%s%s%s',txt,padding1,'}'); - -%%------------------------------------------------------------------------- -function txt=matdata2json(mat,level,varargin) -if(size(mat,1)==1) - pre=''; - post=''; - level=level-1; -else - pre=sprintf('[\n'); - post=sprintf('\n%s]',repmat(sprintf('\t'),1,level-1)); -end -if(isempty(mat)) - txt='null'; - return; -end -floatformat=jsonopt('FloatFormat','%.10g',varargin{:}); -formatstr=['[' repmat([floatformat ','],1,size(mat,2)-1) [floatformat sprintf('],\n')]]; - -if(nargin>=2 && size(mat,1)>1 && jsonopt('ArrayIndent',1,varargin{:})==1) - formatstr=[repmat(sprintf('\t'),1,level) formatstr]; -end -txt=sprintf(formatstr,mat'); -txt(end-1:end)=[]; -if(islogical(mat) && jsonopt('ParseLogical',0,varargin{:})==1) - txt=regexprep(txt,'1','true'); - txt=regexprep(txt,'0','false'); -end -%txt=regexprep(mat2str(mat),'\s+',','); -%txt=regexprep(txt,';',sprintf('],\n[')); -% if(nargin>=2 && size(mat,1)>1) -% txt=regexprep(txt,'\[',[repmat(sprintf('\t'),1,level) '[']); -% end -txt=[pre txt post]; -if(any(isinf(mat(:)))) - txt=regexprep(txt,'([-+]*)Inf',jsonopt('Inf','"$1_Inf_"',varargin{:})); -end -if(any(isnan(mat(:)))) - txt=regexprep(txt,'NaN',jsonopt('NaN','"_NaN_"',varargin{:})); -end - -%%------------------------------------------------------------------------- -function newname=checkname(name,varargin) -isunpack=jsonopt('UnpackHex',1,varargin{:}); -newname=name; -if(isempty(regexp(name,'0x([0-9a-fA-F]+)_','once'))) - return -end -if(isunpack) - isoct=jsonopt('IsOctave',0,varargin{:}); - if(~isoct) - newname=regexprep(name,'(^x|_){1}0x([0-9a-fA-F]+)_','${native2unicode(hex2dec($2))}'); - else - pos=regexp(name,'(^x|_){1}0x([0-9a-fA-F]+)_','start'); - pend=regexp(name,'(^x|_){1}0x([0-9a-fA-F]+)_','end'); - if(isempty(pos)) return; end - str0=name; - pos0=[0 pend(:)' length(name)]; - newname=''; - for i=1:length(pos) - newname=[newname str0(pos0(i)+1:pos(i)-1) char(hex2dec(str0(pos(i)+3:pend(i)-1)))]; - end - if(pos(end)~=length(name)) - newname=[newname str0(pos0(end-1)+1:pos0(end))]; - end - end -end - diff --git a/cb-tools/jsonlab/savejson.m b/cb-tools/jsonlab/savejson.m deleted file mode 100644 index 5fde6935..00000000 --- a/cb-tools/jsonlab/savejson.m +++ /dev/null @@ -1,435 +0,0 @@ -function json=savejson(rootname,obj,varargin) -% -% json=savejson(rootname,obj,filename) -% or -% json=savejson(rootname,obj,opt) -% json=savejson(rootname,obj,'param1',value1,'param2',value2,...) -% -% convert a MATLAB object (cell, struct or array) into a JSON (JavaScript -% Object Notation) string -% -% author: Qianqian Fang (fangq nmr.mgh.harvard.edu) -% created on 2011/09/09 -% -% $Id: savejson.m 394 2012-12-18 17:58:11Z fangq $ -% -% input: -% rootname: name of the root-object, if set to '', will use variable name -% obj: a MATLAB object (array, cell, cell array, struct, struct array) -% filename: a string for the file name to save the output JSON data -% opt: a struct for additional options, use [] if all use default -% opt can have the following fields (first in [.|.] is the default) -% -% opt.FileName [''|string]: a file name to save the output JSON data -% opt.FloatFormat ['%.10g'|string]: format to show each numeric element -% of a 1D/2D array; -% opt.ArrayIndent [1|0]: if 1, output explicit data array with -% precedent indentation; if 0, no indentation -% opt.ArrayToStruct[0|1]: when set to 0, savejson outputs 1D/2D -% array in JSON array format; if sets to 1, an -% array will be shown as a struct with fields -% "_ArrayType_", "_ArraySize_" and "_ArrayData_"; for -% sparse arrays, the non-zero elements will be -% saved to _ArrayData_ field in triplet-format i.e. -% (ix,iy,val) and "_ArrayIsSparse_" will be added -% with a value of 1; for a complex array, the -% _ArrayData_ array will include two columns -% (4 for sparse) to record the real and imaginary -% parts, and also "_ArrayIsComplex_":1 is added. -% opt.ParseLogical [0|1]: if this is set to 1, logical array elem -% will use true/false rather than 1/0. -% opt.NoRowBracket [1|0]: if this is set to 1, arrays with a single -% numerical element will be shown without a square -% bracket, unless it is the root object; if 0, square -% brackets are forced for any numerical arrays. -% opt.ForceRootName [0|1]: when set to 1 and rootname is empty, savejson -% will use the name of the passed obj variable as the -% root object name; if obj is an expression and -% does not have a name, 'root' will be used; if this -% is set to 0 and rootname is empty, the root level -% will be merged down to the lower level. -% opt.Inf ['"$1_Inf_"'|string]: a customized regular expression pattern -% to represent +/-Inf. The matched pattern is '([-+]*)Inf' -% and $1 represents the sign. For those who want to use -% 1e999 to represent Inf, they can set opt.Inf to '$11e999' -% opt.NaN ['"_NaN_"'|string]: a customized regular expression pattern -% to represent NaN -% opt.JSONP [''|string]: to generate a JSONP output (JSON with padding), -% for example, if opt.JSON='foo', the JSON data is -% wrapped inside a function call as 'foo(...);' -% opt.UnpackHex [1|0]: conver the 0x[hex code] output by loadjson -% back to the string form -% opt can be replaced by a list of ('param',value) pairs. The param -% string is equivallent to a field in opt. -% output: -% json: a string in the JSON format (see http://json.org) -% -% examples: -% a=struct('node',[1 9 10; 2 1 1.2], 'elem',[9 1;1 2;2 3],... -% 'face',[9 01 2; 1 2 3; NaN,Inf,-Inf], 'author','FangQ'); -% savejson('mesh',a) -% savejson('',a,'ArrayIndent',0,'FloatFormat','\t%.5g') -% -% license: -% BSD or GPL version 3, see LICENSE_{BSD,GPLv3}.txt files for details -% -% -- this function is part of jsonlab toolbox (http://iso2mesh.sf.net/cgi-bin/index.cgi?jsonlab) -% - -if(nargin==1) - varname=inputname(1); - obj=rootname; - if(isempty(varname)) - varname='root'; - end - rootname=varname; -else - varname=inputname(2); -end -if(length(varargin)==1 && ischar(varargin{1})) - opt=struct('FileName',varargin{1}); -else - opt=varargin2struct(varargin{:}); -end -opt.IsOctave=exist('OCTAVE_VERSION', 'builtin'); -rootisarray=0; -rootlevel=1; -forceroot=jsonopt('ForceRootName',0,opt); - -tab = sprintf('\t'); -newline = sprintf('\n'); -floatformat=jsonopt('FloatFormat','%.10g',opt); -arrayIndent=jsonopt('ArrayIndent',1,opt)==1; -parseLogical=jsonopt('ParseLogical',0,opt)==1; -noRowBracket=jsonopt('NoRowBracket',1,opt)==1; -arrayToStruct=jsonopt('ArrayToStruct',0,opt); -unpackHex=jsonopt('UnpackHex',1,opt); -jsonp=jsonopt('JSONP','',opt); - -% speedup? -maxlevel = 25; % maximum object nesting degree to save padding for -pad = cell(maxlevel+1,1); % idx 1 is zero level padding -for l = 1:maxlevel - pad{l+1} = [pad{l} tab]; -end - -if((isnumeric(obj) || islogical(obj) || ischar(obj) || isstruct(obj) || iscell(obj)) && isempty(rootname) && forceroot==0) - rootisarray=1; - rootlevel=0; -else - if(isempty(rootname)) - rootname=varname; - end -end -if((isstruct(obj) || iscell(obj))&& isempty(rootname) && forceroot) - rootname='root'; -end - -json=obj2json(checkname(rootname),obj,rootlevel); -if(rootisarray) - json=sprintf('%s\n',json); -else - json=sprintf('{\n%s\n}\n',json); -end -if(~isempty(jsonp)) - json=sprintf('%s(%s);\n',jsonp,json); -end -% save to a file if FileName is set, suggested by Patrick Rapin -if ~isempty(jsonopt('FileName','',opt)) - fid = fopen(opt.FileName, 'wt'); - fwrite(fid,json,'char'); - fclose(fid); -end - - %%------------------------------------------------------------------------- - function txt=obj2json(name,item,level) - - if(iscell(item)) - txt=cell2json(name,item,level,opt); - elseif(isstruct(item)) - txt=struct2json(name,item,level); - elseif(ischar(item)) - txt=str2json(name,item,level,opt); - else - txt=mat2json(name,item,level); - end - end - - %%------------------------------------------------------------------------- - function txt=cell2json(name,item,level,varargin) - %% Warning i need some work, and checking that loadjson mirrors this well - txt=''; - if(~iscell(item)) - error('input is not a cell'); - end - - len=numel(item); % let's handle 1D cell first -% padding1=pad{level};%repmat(sprintf('\t'),1,level-1); ??? - padding0=pad{level+1};%repmat(sprintf('\t'),1,level); - if (~isempty(name)) - txt=sprintf('%s"%s": [',padding0, name); name=''; - else - txt=sprintf('%s[',padding0); - end - if len==0 - txt = [txt ']']; - else - for i=1:len - txt=[txt newline obj2json(name,item{i},level+1)]; - if(i1); - names = fieldnames(item); - checkedNames = cellfun(@checkname, names, 'uni', false); - padding0=pad{level+1}; %padding for this level - padding1=pad{bracelevel+1}; %padding for braces, maybe deeper than this - values = struct2cell(item); % all structure values in a table - elemstxt = cell(size(item)); % array to store each elements object text - for e=1:len - elements = cell(1,numel(names)); - for f=1:numel(names) - elements{f} = obj2json(checkedNames{f},values{f,e},bracelevel+1); - end - elementstxt = sprintf('\n%s,', elements{:}); % format all fields together - elemstxt{e} = elementstxt(1:end-1); - end - linepad = [newline padding1]; - intxt = sprintf(['{%s\n' padding1 '},' linepad], elemstxt{:}); % format all elements - intxt = intxt(1:(end-numel(linepad)-1)); % snip off next element tokens/formatting - if(len==1) - if ~isempty(name) - txt=[padding0 '"' name '": ' intxt]; - else - txt=intxt; - end - else - txt=[padding0 '"' name '": [' linepad intxt newline padding0 ']']; - end - end - - %%------------------------------------------------------------------------- - function txt=str2json(name,item,level,varargin) - txt=''; - if(~ischar(item)) - error('input is not a string'); - end - item=reshape(item, max(size(item),[1 0])); - len=size(item,1); - sep=sprintf(',\n'); - - padding1=pad{level+1}; - - if(~isempty(name)) - if(len>1) txt=sprintf('%s"%s": [\n',padding1,name); end - else - if(len>1) txt=sprintf('%s[\n',padding1); end - end - isoct=jsonopt('IsOctave',0,varargin{:}); - for e=1:len - if(isoct) - val=regexprep(item(e,:),'\\','\\'); - val=regexprep(val,'"','\"'); - val=regexprep(val,'^"','\"'); - else - val=regexprep(item(e,:),'\\','\\\\'); - val=regexprep(val,'"','\\"'); - val=regexprep(val,'^"','\\"'); - end - if(len==1) - obj=['"' name '": ' '"',val,'"']; - if(isempty(name)) obj=['"',val,'"']; end - txt=sprintf('%s%s%s%s',txt,padding1,obj); - else - txt=[txt pad{level+2} '"',val,'"']; - end - if(e==len) sep=''; end - txt=sprintf('%s%s',txt,sep); - end - if(len>1) txt=sprintf('%s\n%s%s',txt,padding1,']'); end - end - - %%------------------------------------------------------------------------- - function txt=mat2json(name,item,level) - if(~isnumeric(item) && ~islogical(item)) - error('input is not an array'); - end - - padding1=pad{level+1};%repmat(tab,1,level); - padding0=pad{level+2};%repmat(tab,1,level+1); - - if(length(size(item))>2 || issparse(item) || ~isreal(item) || ... - isempty(item) || arrayToStruct) - if(isempty(name)) - txt=sprintf('%s{\n%s"_ArrayType_": "%s",\n%s"_ArraySize_": %s,\n',... - padding1,padding0,class(item),padding0,regexprep(mat2str(size(item)),'\s+',',') ); - else - txt=sprintf('%s"%s": {\n%s"_ArrayType_": "%s",\n%s"_ArraySize_": %s,\n',... - padding1,name,padding0,class(item),padding0,regexprep(mat2str(size(item)),'\s+',',') ); - end - else - if(isempty(name)) - %% warning i may have broken this part - txt=sprintf('%s%s',padding1,matdata2json(item,level+1)); -% txt=sprintf('%s[%s]',padding1,matdata2json(item,level+1)); - else - txt = [padding1 '"' name '": ' matdata2json(item,level+1)]; -% if(numel(item)==1 && ~noRowBracket) -% %numtxt=regexprep(regexprep(numtxt,'^\[',''),']',''); -% %assume square brackets first + last chars -% numtxt = numtxt(2:end-1); -% end -% txt=[padding1 '"' name '": ' numtxt]; - end - return; - end - dataformat='%s%s%s%s%s'; - - if(issparse(item)) - [ix,iy]=find(item); - data=full(item(find(item))); - if(~isreal(item)) - data=[real(data(:)),imag(data(:))]; - if(size(item,1)==1) - % Kludge to have data's 'transposedness' match item's. - % (Necessary for complex row vector handling below.) - data=data'; - end - txt=sprintf(dataformat,txt,padding0,'"_ArrayIsComplex_": ','1', sprintf(',\n')); - end - txt=sprintf(dataformat,txt,padding0,'"_ArrayIsSparse_": ','1', sprintf(',\n')); - if(size(item,1)==1) - % Row vector, store only column indices. - txt=sprintf(dataformat,txt,padding0,'"_ArrayData_": ',... - matdata2json([iy(:),data'],level+2), sprintf('\n')); - elseif(size(item,2)==1) - % Column vector, store only row indices. - txt=sprintf(dataformat,txt,padding0,'"_ArrayData_": ',... - matdata2json([ix,data],level+2), sprintf('\n')); - else - % General case, store row and column indices. - txt=sprintf(dataformat,txt,padding0,'"_ArrayData_": ',... - matdata2json([ix,iy,data],level+2), sprintf('\n')); - end - else - if(isreal(item)) - txt=sprintf(dataformat,txt,padding0,'"_ArrayData_": ',... - matdata2json(item(:)',level+2), sprintf('\n')); - else - txt=sprintf(dataformat,txt,padding0,'"_ArrayIsComplex_": ','1', sprintf(',\n')); - txt=sprintf(dataformat,txt,padding0,'"_ArrayData_": ',... - matdata2json([real(item(:)) imag(item(:))],level+2), sprintf('\n')); - end - end - txt=sprintf('%s%s%s',txt,padding1,'}'); - end - - %%------------------------------------------------------------------------- - function txt=matdata2json(mat,level) - % handle each case individually for maximum speed - if numel(mat) == 1 - txt = sprintf(floatformat, mat); - if ~noRowBracket - txt = ['[' txt ']']; - end - elseif isempty(mat) - txt = 'null'; - elseif isrow(mat) - txt = sprintf([floatformat ','], mat); - txt = ['[' txt(1:end-1) ']']; - elseif iscolumn(mat) - txt = sprintf([newline pad{level+1} '[' floatformat '],'], mat); - txt = ['[' txt(1:end-1) newline pad{level} ']']; - else % matrix with rows>1 & cols>1 - formatstr=['[' repmat([floatformat ','],1,size(mat,2)-1) floatformat '],' newline]; - if arrayIndent - formatstr = [pad{level+1} formatstr]; - end - txt = sprintf(formatstr,mat'); - txt = ['[' newline txt(1:end-2) newline pad{level} ']']; - end -% if(size(mat,1)==1) -% pre=''; -% post=''; -% level=level-1; -% else -% pre=sprintf('[\n'); -% post=sprintf('\n%s]',pad{level}); -% end -% if(isempty(mat)) -% txt='null'; -% return; -% end -% -% formatstr=['[' repmat([floatformat ','],1,size(mat,2)-1) floatformat '],' newline]; -% -% if(nargin>=2 && size(mat,1)>1 && arrayIndent) -% formatstr=[pad{level+1} formatstr]; -% end -% % intxt=sprintf(formatstr,mat'); -% txt=sprintf(formatstr,mat'); - if(parseLogical && islogical(mat)) - txt=regexprep(txt,'1','true'); - txt=regexprep(txt,'0','false'); - end - if(any(isinf(mat(:)))) - txt=regexprep(txt,'([-+]*)Inf',jsonopt('Inf','"$1_Inf_"',opt)); - end - if(any(isnan(mat(:)))) - txt=regexprep(txt,'NaN',jsonopt('NaN','"_NaN_"',opt)); - end - end - - %%------------------------------------------------------------------------- - function name=checkname(name) - if(isempty(regexp(name,'0x([0-9a-fA-F]+)_','once'))) - return - elseif unpackHex - isoct=jsonopt('IsOctave',0,opt); - if(~isoct) - name=regexprep(name,'(^x|_){1}0x([0-9a-fA-F]+)_','${native2unicode(hex2dec($2))}'); - else - pos=regexp(name,'(^x|_){1}0x([0-9a-fA-F]+)_','start'); - pend=regexp(name,'(^x|_){1}0x([0-9a-fA-F]+)_','end'); - if(isempty(pos)) return; end - str0=name; - pos0=[0 pend(:)' length(name)]; - name=''; - for i=1:length(pos) - name=[name str0(pos0(i)+1:pos(i)-1) char(hex2dec(str0(pos(i)+3:pend(i)-1)))]; - end - if(pos(end)~=length(name)) - name=[name str0(pos0(end-1)+1:pos0(end))]; - end - end - end - end - -end - diff --git a/cb-tools/jsonlab/saveubjson.m b/cb-tools/jsonlab/saveubjson.m deleted file mode 100644 index eb975de0..00000000 --- a/cb-tools/jsonlab/saveubjson.m +++ /dev/null @@ -1,490 +0,0 @@ -function json=saveubjson(rootname,obj,varargin) -% -% json=saveubjson(rootname,obj,filename) -% or -% json=saveubjson(rootname,obj,opt) -% json=saveubjson(rootname,obj,'param1',value1,'param2',value2,...) -% -% convert a MATLAB object (cell, struct or array) into a Universal -% Binary JSON (UBJSON) binary string -% -% author: Qianqian Fang (fangq nmr.mgh.harvard.edu) -% created on 2013/08/17 -% -% $Id: saveubjson.m 410 2013-08-24 03:33:18Z fangq $ -% -% input: -% rootname: name of the root-object, if set to '', will use variable name -% obj: a MATLAB object (array, cell, cell array, struct, struct array) -% filename: a string for the file name to save the output JSON data -% opt: a struct for additional options, use [] if all use default -% opt can have the following fields (first in [.|.] is the default) -% -% opt.FileName [''|string]: a file name to save the output JSON data -% opt.ArrayToStruct[0|1]: when set to 0, saveubjson outputs 1D/2D -% array in JSON array format; if sets to 1, an -% array will be shown as a struct with fields -% "_ArrayType_", "_ArraySize_" and "_ArrayData_"; for -% sparse arrays, the non-zero elements will be -% saved to _ArrayData_ field in triplet-format i.e. -% (ix,iy,val) and "_ArrayIsSparse_" will be added -% with a value of 1; for a complex array, the -% _ArrayData_ array will include two columns -% (4 for sparse) to record the real and imaginary -% parts, and also "_ArrayIsComplex_":1 is added. -% opt.ParseLogical [1|0]: if this is set to 1, logical array elem -% will use true/false rather than 1/0. -% opt.NoRowBracket [1|0]: if this is set to 1, arrays with a single -% numerical element will be shown without a square -% bracket, unless it is the root object; if 0, square -% brackets are forced for any numerical arrays. -% opt.ForceRootName [0|1]: when set to 1 and rootname is empty, saveubjson -% will use the name of the passed obj variable as the -% root object name; if obj is an expression and -% does not have a name, 'root' will be used; if this -% is set to 0 and rootname is empty, the root level -% will be merged down to the lower level. -% opt.JSONP [''|string]: to generate a JSONP output (JSON with padding), -% for example, if opt.JSON='foo', the JSON data is -% wrapped inside a function call as 'foo(...);' -% opt.UnpackHex [1|0]: conver the 0x[hex code] output by loadjson -% back to the string form -% opt can be replaced by a list of ('param',value) pairs. The param -% string is equivallent to a field in opt. -% output: -% json: a string in the JSON format (see http://json.org) -% -% examples: -% a=struct('node',[1 9 10; 2 1 1.2], 'elem',[9 1;1 2;2 3],... -% 'face',[9 01 2; 1 2 3; NaN,Inf,-Inf], 'author','FangQ'); -% saveubjson('mesh',a) -% saveubjson('',a,'ArrayIndent',0,'FloatFormat','\t%.5g') -% -% license: -% BSD or GPL version 3, see LICENSE_{BSD,GPLv3}.txt files for details -% -% -- this function is part of jsonlab toolbox (http://iso2mesh.sf.net/cgi-bin/index.cgi?jsonlab) -% - -if(nargin==1) - varname=inputname(1); - obj=rootname; - if(isempty(varname)) - varname='root'; - end - rootname=varname; -else - varname=inputname(2); -end -if(length(varargin)==1 && ischar(varargin{1})) - opt=struct('FileName',varargin{1}); -else - opt=varargin2struct(varargin{:}); -end -opt.IsOctave=exist('OCTAVE_VERSION'); -rootisarray=0; -rootlevel=1; -forceroot=jsonopt('ForceRootName',0,opt); -if((isnumeric(obj) || islogical(obj) || ischar(obj) || isstruct(obj) || iscell(obj)) && isempty(rootname) && forceroot==0) - rootisarray=1; - rootlevel=0; -else - if(isempty(rootname)) - rootname=varname; - end -end -if((isstruct(obj) || iscell(obj))&& isempty(rootname) && forceroot) - rootname='root'; -end -json=obj2ubjson(rootname,obj,rootlevel,opt); -if(~rootisarray) - json=['{' json '}']; -end - -jsonp=jsonopt('JSONP','',opt); -if(~isempty(jsonp)) - json=[jsonp '(' json ')']; -end - -% save to a file if FileName is set, suggested by Patrick Rapin -if(~isempty(jsonopt('FileName','',opt))) - fid = fopen(opt.FileName, 'wt'); - fwrite(fid,json,'char'); - fclose(fid); -end - -%%------------------------------------------------------------------------- -function txt=obj2ubjson(name,item,level,varargin) - -if(iscell(item)) - txt=cell2ubjson(name,item,level,varargin{:}); -elseif(isstruct(item)) - txt=struct2ubjson(name,item,level,varargin{:}); -elseif(ischar(item)) - txt=str2ubjson(name,item,level,varargin{:}); -else - txt=mat2ubjson(name,item,level,varargin{:}); -end - -%%------------------------------------------------------------------------- -function txt=cell2ubjson(name,item,level,varargin) -txt=''; -if(~iscell(item)) - error('input is not a cell'); -end - -dim=size(item); -len=numel(item); % let's handle 1D cell first -padding1=''; -padding0=''; -if(len>1) - if(~isempty(name)) - txt=[S_(checkname(name,varargin{:})) '[']; name=''; - else - txt='['; - end -elseif(len==0) - if(~isempty(name)) - txt=[S_(checkname(name,varargin{:})) 'Z']; name=''; - else - txt='Z'; - end -end -for i=1:len - txt=[txt obj2ubjson(name,item{i},level+(len>1),varargin{:})]; -end -if(len>1) txt=[txt ']']; end - -%%------------------------------------------------------------------------- -function txt=struct2ubjson(name,item,level,varargin) -txt=''; -if(~isstruct(item)) - error('input is not a struct'); -end -len=numel(item); -padding1=''; -padding0=''; -sep=','; - -if(~isempty(name)) - if(len>1) txt=[S_(checkname(name,varargin{:})) '[']; end -else - if(len>1) txt='['; end -end -for e=1:len - names = fieldnames(item(e)); - if(~isempty(name) && len==1) - txt=[txt S_(checkname(name,varargin{:})) '{']; - else - txt=[txt '{']; - end - if(~isempty(names)) - for i=1:length(names) - txt=[txt obj2ubjson(names{i},getfield(item(e),... - names{i}),level+1+(len>1),varargin{:})]; - end - end - txt=[txt '}']; - if(e==len) sep=''; end -end -if(len>1) txt=[txt ']']; end - -%%------------------------------------------------------------------------- -function txt=str2ubjson(name,item,level,varargin) -txt=''; -if(~ischar(item)) - error('input is not a string'); -end -item=reshape(item, max(size(item),[1 0])); -len=size(item,1); -sep=''; - -padding1=''; -padding0=''; - -if(~isempty(name)) - if(len>1) txt=[S_(checkname(name,varargin{:})) '[']; end -else - if(len>1) txt='['; end -end -isoct=jsonopt('IsOctave',0,varargin{:}); -for e=1:len - val=item(e,:); - if(len==1) - obj=['' S_(checkname(name,varargin{:})) '' '',S_(val),'']; - if(isempty(name)) obj=['',S_(val),'']; end - txt=[txt,'',obj]; - else - txt=[txt,'',['',S_(val),'']]; - end - if(e==len) sep=''; end - txt=[txt sep]; -end -if(len>1) txt=[txt ']']; end - -%%------------------------------------------------------------------------- -function txt=mat2ubjson(name,item,level,varargin) -if(~isnumeric(item) && ~islogical(item)) - error('input is not an array'); -end - -padding1=''; -padding0=''; - -if(length(size(item))>2 || issparse(item) || ~isreal(item) || ... - isempty(item) || jsonopt('ArrayToStruct',0,varargin{:})) - cid=I_(uint32(max(size(item)))); - if(isempty(name)) - txt=['{' S_('_ArrayType_'),S_(class(item)),padding0,S_('_ArraySize_'),I_a(size(item),cid(1)) ]; - else - txt=[S_(checkname(name,varargin{:})),'{',S_('_ArrayType_'),S_(class(item)),padding0,S_('_ArraySize_'),I_a(size(item),cid(1))]; - end -else - if(isempty(name)) - txt=matdata2ubjson(item,level+1,varargin{:}); - else - if(numel(item)==1 && jsonopt('NoRowBracket',1,varargin{:})==1) - numtxt=regexprep(regexprep(matdata2ubjson(item,level+1,varargin{:}),'^\[',''),']',''); - txt=[S_(checkname(name,varargin{:})) numtxt]; - else - txt=[S_(checkname(name,varargin{:})),matdata2ubjson(item,level+1,varargin{:})]; - end - end - return; -end -dataformat='%s%s%s%s%s'; - -if(issparse(item)) - [ix,iy]=find(item); - data=full(item(find(item))); - if(~isreal(item)) - data=[real(data(:)),imag(data(:))]; - if(size(item,1)==1) - % Kludge to have data's 'transposedness' match item's. - % (Necessary for complex row vector handling below.) - data=data'; - end - txt=[txt,S_('_ArrayIsComplex_'),'T']; - end - txt=[txt,S_('_ArrayIsSparse_'),'T']; - if(size(item,1)==1) - % Row vector, store only column indices. - txt=[txt,S_('_ArrayData_'),... - matdata2ubjson([iy(:),data'],level+2,varargin{:})]; - elseif(size(item,2)==1) - % Column vector, store only row indices. - txt=[txt,S_('_ArrayData_'),... - matdata2ubjson([ix,data],level+2,varargin{:})]; - else - % General case, store row and column indices. - txt=[txt,S_('_ArrayData_'),... - matdata2ubjson([ix,iy,data],level+2,varargin{:})]; - end -else - if(isreal(item)) - txt=[txt,S_('_ArrayData_'),... - matdata2ubjson(item(:)',level+2,varargin{:})]; - else - txt=[txt,S_('_ArrayIsComplex_'),'T']; - txt=[txt,S_('_ArrayData_'),... - matdata2ubjson([real(item(:)) imag(item(:))],level+2,varargin{:})]; - end -end -txt=[txt,'}']; - -%%------------------------------------------------------------------------- -function txt=matdata2ubjson(mat,level,varargin) -if(isempty(mat)) - txt='Z'; - return; -end -if(size(mat,1)==1) - level=level-1; -end -type=''; -hasnegtive=find(mat<0); -if(isa(mat,'integer') || (isfloat(mat) && all(mod(mat(:),1) == 0))) - if(isempty(hasnegtive)) - if(max(mat(:))<=2^8) - type='U'; - end - end - if(isempty(type)) - % todo - need to consider negative ones separately - id= histc(abs(max(mat(:))),[0 2^7 2^15 2^31 2^63]); - if(isempty(find(id))) - error('high-precision data is not yet supported'); - end - key='iIlL'; - type=key(find(id)); - end - txt=[I_a(mat(:),type,size(mat))]; -elseif(islogical(mat)) - logicalval='FT'; - if(numel(mat)==1) - txt=logicalval(mat+1); - else - txt=['[$U#' I_a(size(mat),'l') typecast(uint8(mat(:)'),'uint8')]; - end -else - if(numel(mat)==1) - txt=['[' D_(mat) ']']; - else - txt=D_a(mat(:),'D',size(mat)); - end -end - -%txt=regexprep(mat2str(mat),'\s+',','); -%txt=regexprep(txt,';',sprintf('],[')); -% if(nargin>=2 && size(mat,1)>1) -% txt=regexprep(txt,'\[',[repmat(sprintf('\t'),1,level) '[']); -% end -if(any(isinf(mat(:)))) - txt=regexprep(txt,'([-+]*)Inf',jsonopt('Inf','"$1_Inf_"',varargin{:})); -end -if(any(isnan(mat(:)))) - txt=regexprep(txt,'NaN',jsonopt('NaN','"_NaN_"',varargin{:})); -end - -%%------------------------------------------------------------------------- -function newname=checkname(name,varargin) -isunpack=jsonopt('UnpackHex',1,varargin{:}); -newname=name; -if(isempty(regexp(name,'0x([0-9a-fA-F]+)_','once'))) - return -end -if(isunpack) - isoct=jsonopt('IsOctave',0,varargin{:}); - if(~isoct) - newname=regexprep(name,'(^x|_){1}0x([0-9a-fA-F]+)_','${native2unicode(hex2dec($2))}'); - else - pos=regexp(name,'(^x|_){1}0x([0-9a-fA-F]+)_','start'); - pend=regexp(name,'(^x|_){1}0x([0-9a-fA-F]+)_','end'); - if(isempty(pos)) return; end - str0=name; - pos0=[0 pend(:)' length(name)]; - newname=''; - for i=1:length(pos) - newname=[newname str0(pos0(i)+1:pos(i)-1) char(hex2dec(str0(pos(i)+3:pend(i)-1)))]; - end - if(pos(end)~=length(name)) - newname=[newname str0(pos0(end-1)+1:pos0(end))]; - end - end -end -%%------------------------------------------------------------------------- -function val=S_(str) -if(length(str)==1) - val=['C' str]; -else - val=['S' I_(int32(length(str))) str]; -end -%%------------------------------------------------------------------------- -function val=I_(num) -if(~isinteger(num)) - error('input is not an integer'); -end -if(num>=0 && num<255) - val=['U' data2byte(cast(num,'uint8'),'uint8')]; - return; -end -key='iIlL'; -cid={'int8','int16','int32','int64'}; -for i=1:4 - if((num>0 && num<2^(i*8-1)) || (num<0 && num>=-2^(i*8-1))) - val=[key(i) data2byte(cast(num,cid{i}),'uint8')]; - return; - end -end -error('unsupported integer'); - -%%------------------------------------------------------------------------- -function val=D_(num) -if(~isfloat(num)) - error('input is not a float'); -end - -if(isa(num,'single')) - val=['d' data2byte(num,'uint8')]; -else - val=['D' data2byte(num,'uint8')]; -end -%%------------------------------------------------------------------------- -function data=I_a(num,type,dim,format) -id=find(ismember('iUIlL',type)); - -if(id==0) - error('unsupported integer array'); -end - -if(id==1) - data=data2byte(int8(num),'uint8'); - blen=1; -elseif(id==2) - data=data2byte(uint8(num),'uint8'); - blen=1; -elseif(id==3) - data=data2byte(int16(num),'uint8'); - blen=2; -elseif(id==4) - data=data2byte(int32(num),'uint8'); - blen=4; -elseif(id==5) - data=data2byte(int64(num),'uint8'); - blen=8; -end - -if(nargin>=3 && length(dim)>=2 && prod(dim)~=dim(2)) - format='opt'; -end -if((nargin<4 || strcmp(format,'opt')) && numel(num)>1) - if(nargin>=3 && (length(dim)==1 || (length(dim)>=2 && prod(dim)~=dim(2)))) - cid=I_(uint32(max(dim))); - data=['$' type '#' I_a(dim,cid(1)) data(:)']; - else - data=['$' type '#' I_(int32(numel(data)/blen)) data(:)']; - end - data=['[' data(:)']; -else - data=reshape(data,blen,numel(data)/blen); - data(2:blen+1,:)=data; - data(1,:)=type; - data=data(:)'; - data=['[' data(:)' ']']; -end -%%------------------------------------------------------------------------- -function data=D_a(num,type,dim,format) -id=find(ismember('dD',type)); - -if(id==0) - error('unsupported float array'); -end - -if(id==1) - data=data2byte(single(num),'uint8'); -elseif(id==2) - data=data2byte(double(num),'uint8'); -end - -if(nargin>=3 && length(dim)>=2 && prod(dim)~=dim(2)) - format='opt'; -end -if((nargin<4 || strcmp(format,'opt')) && numel(num)>1) - if(nargin>=3 && (length(dim)==1 || (length(dim)>=2 && prod(dim)~=dim(2)))) - cid=I_(uint32(max(dim))); - data=['$' type '#' I_a(dim,cid(1)) data(:)']; - else - data=['$' type '#' I_(int32(numel(data)/(id*4))) data(:)']; - end - data=['[' data]; -else - data=reshape(data,(id*4),length(data)/(id*4)); - data(2:(id*4+1),:)=data; - data(1,:)=type; - data=data(:)'; - data=['[' data(:)' ']']; -end -%%------------------------------------------------------------------------- -function bytes=data2byte(varargin) -bytes=typecast(varargin{:}); -bytes=bytes(:)'; diff --git a/cb-tools/jsonlab/varargin2struct.m b/cb-tools/jsonlab/varargin2struct.m deleted file mode 100644 index 43c27af4..00000000 --- a/cb-tools/jsonlab/varargin2struct.m +++ /dev/null @@ -1,40 +0,0 @@ -function opt=varargin2struct(varargin) -% -% opt=varargin2struct('param1',value1,'param2',value2,...) -% or -% opt=varargin2struct(...,optstruct,...) -% -% convert a series of input parameters into a structure -% -% authors:Qianqian Fang (fangq nmr.mgh.harvard.edu) -% date: 2012/12/22 -% -% input: -% 'param', value: the input parameters should be pairs of a string and a value -% optstruct: if a parameter is a struct, the fields will be merged to the output struct -% -% output: -% opt: a struct where opt.param1=value1, opt.param2=value2 ... -% -% license: -% BSD or GPL version 3, see LICENSE_{BSD,GPLv3}.txt files for details -% -% -- this function is part of jsonlab toolbox (http://iso2mesh.sf.net/cgi-bin/index.cgi?jsonlab) -% - -len=length(varargin); -opt=struct; -if(len==0) return; end -i=1; -while(i<=len) - if(isstruct(varargin{i})) - opt=mergestruct(opt,varargin{i}); - elseif(ischar(varargin{i}) && i cmd=search&db=pubmed&term=wtf+batman -% -% IMPORTANT: This function does not filter parameters, sort them, -% or remove empty inputs (if necessary), this must be done before hand - -if ~exist('encodeOption','var') - encodeOption = 1; -end - -if size(params,2) == 2 && size(params,1) > 1 - params = params'; - params = params(:); -end - -str = ''; -for i=1:2:length(params) - if (i == 1), separator = ''; else separator = '&'; end - switch encodeOption - case 1 - param = urlencode(params{i}); - value = urlencode(params{i+1}); -% case 2 -% param = oauth.percentEncodeString(params{i}); -% value = oauth.percentEncodeString(params{i+1}); -% header = http_getContentTypeHeader(1); - otherwise - error('Case not used') - end - str = [str separator param '=' value]; %#ok -end - -switch encodeOption - case 1 - header = http_createHeader('Content-Type','application/x-www-form-urlencoded'); -end - - -end \ No newline at end of file diff --git a/cb-tools/urlread2/license.txt b/cb-tools/urlread2/license.txt deleted file mode 100644 index 1d783b74..00000000 --- a/cb-tools/urlread2/license.txt +++ /dev/null @@ -1,24 +0,0 @@ -Copyright (c) 2012, Jim Hokanson -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in - the documentation and/or other materials provided with the distribution - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. diff --git a/cb-tools/urlread2/urlread2.m b/cb-tools/urlread2/urlread2.m deleted file mode 100644 index b552861c..00000000 --- a/cb-tools/urlread2/urlread2.m +++ /dev/null @@ -1,371 +0,0 @@ -function [output,extras] = urlread2(urlChar,method,body,headersIn,varargin) -%urlread2 Makes HTTP requests and processes response -% -% [output,extras] = urlread2(urlChar, *method, *body, *headersIn, varargin) -% -% * indicates optional inputs that must be entered in place -% -% UNDOCUMENTED MATLAB VERSION -% -% EXAMPLE CALLING FORMS -% ... = urlread2(urlChar) -% ... = urlread2(urlChar,'GET','',[],prop1,value1,prop2,value2,etc) -% ... = urlread2(urlChar,'POST',body,headers) -% -% FEATURES -% ======================================================================= -% 1) Allows specification of any HTTP method -% 2) Allows specification of any header. Very little is hard-coded -% in for header handling. -% 3) Returns response status and headers -% 4) Should handle unicode properly ... -% -% OUTPUTS -% ======================================================================= -% output : body of the response, either text or binary depending upon -% CAST_OUTPUT property -% extras : (structure) -% .allHeaders - stucture, fields have cellstr values, HTTP headers may -% may be repeated but will have a single field entry, with each -% repeat's value another being another entry in the cellstr, for -% example: -% .Set_Cookie = {'first_value' 'second_value'} -% .firstHeaders - (structure), variable fields, contains the first -% string entry for each field in allHeaders, this -% structure can be used to avoid dereferencing a cell -% for fields you expect not to be repeated ... -% EXAMPLE: -% .Response : 'HTTP/1.1 200 OK'} -% .Server : 'nginx' -% .Date : 'Tue, 29 Nov 2011 02:23:16 GMT' -% .Content_Type : 'text/html; charset=UTF-8' -% .Content_Length : '109155' -% .Connection : 'keep-alive' -% .Vary : 'Accept-Encoding, User-Agent' -% .Cache_Control : 'max-age=60, private' -% .Set_Cookie : 'first_value' -% .status - (structure) -% .value : numeric value of status, ex. 200 -% .msg : message that goes along with status, ex. 'OK' -% .url - eventual url that led to output, this can change from -% the input with redirects, see FOLLOW_REDIRECTS -% .isGood - (logical) I believe this is an indicator of the presence of 400 -% or 500 status codes (see status.value) but more -% testing is needed. In other words, true if status.value < 400. -% In code, set true if the response was obtainable without -% resorting to checking the error stream. -% -% INPUTS -% ======================================================================= -% urlChar : The full url, must include scheme (http, https) -% method : examples: 'GET' 'POST' etc -% body : (vector)(char, uint8 or int8) body to write, generally used -% with POST or PUT, use of uint8 or int8 ensures that the -% body input is not manipulated before sending, char is sent -% via unicode2native function with ENCODING input (see below) -% headersIn : (structure array), use empty [] or '' if no headers are needed -% but varargin property/value pairs are, multiple headers -% may be passed in as a structure array -% .name - (string), name of the header, a name property is used -% instead of a field because the name must match a valid -% header -% .value - (string), value to use -% -% OPTIONAL INPUTS (varargin, property/value pairs) -% ======================================================================= -% CAST_OUTPUT : (default true) output is uint8, useful if the body -% of the response is not text -% ENCODING : (default ''), ENCODING input to function unicode2native -% FOLLOW_REDIRECTS : (default true), if false 3xx status codes will -% be returned and need to be handled by the user, -% note this does not handle javascript or meta tag -% redirects, just server based ones -% READ_TIMEOUT : (default 0), 0 means no timeout, value is in -% milliseconds -% -% EXAMPLES -% ======================================================================= -% GET: -% -------------------------------------------- -% url = 'http://www.mathworks.com/matlabcentral/fileexchange/'; -% query = 'urlread2'; -% params = {'term' query}; -% queryString = http_paramsToString(params,1); -% url = [url '?' queryString]; -% [output,extras] = urlread2(url); -% -% POST: -% -------------------------------------------- -% url = 'http://posttestserver.com/post.php'; -% params = {'testChars' char([2500 30000]) 'new code' '?'}; -% [paramString,header] = http_paramsToString(params,1); -% [output,extras] = urlread2(url,'POST',paramString,header); -% -% From behind a firewall, use the Preferences to set your proxy server. -% -% See Also: -% http_paramsToString -% unicode2native -% native2unicode -% -% Subfunctions: -% fixHeaderCasing - small subfunction to fix case errors encountered in real -% world, requires updating when casing doesn't match expected form, like -% if someone sent the header content-Encoding instead of -% Content-Encoding -% -% Based on original urlread code by Matthew J. Simoneau -% -% VERSION = 1.1 - -in.CAST_OUTPUT = true; -in.FOLLOW_REDIRECTS = true; -in.READ_TIMEOUT = 0; -in.ENCODING = ''; - -%Input handling -%--------------------------------------- -if ~isempty(varargin) - for i = 1:2:numel(varargin) - prop = upper(varargin{i}); - value = varargin{i+1}; - if isfield(in,prop) - in.(prop) = value; - else - error('Unrecognized input to function: %s',prop) - end - end -end - -if ~exist('method','var') || isempty(method), method = 'GET'; end -if ~exist('body','var'), body = ''; end -if ~exist('headersIn','var'), headersIn = []; end - -assert(usejava('jvm'),'Function requires Java') - -import com.mathworks.mlwidgets.io.InterruptibleStreamCopier; -com.mathworks.mlwidgets.html.HTMLPrefs.setProxySettings %Proxy settings need to be set - -%Create a urlConnection. -%----------------------------------- -urlConnection = getURLConnection(urlChar); -%For HTTP uses sun.net.www.protocol.http.HttpURLConnection -%Might use ice.net.HttpURLConnection but this has more overhead - -%SETTING PROPERTIES -%------------------------------------------------------- -urlConnection.setRequestMethod(upper(method)); -urlConnection.setFollowRedirects(in.FOLLOW_REDIRECTS); -urlConnection.setReadTimeout(in.READ_TIMEOUT); - -for iHeader = 1:length(headersIn) - curHeader = headersIn(iHeader); - urlConnection.setRequestProperty(curHeader.name,curHeader.value); -end - -if ~isempty(body) - %Ensure vector? - if size(body,1) > 1 - if size(body,2) > 1 - error('Input parameter to function: body, must be a vector') - else - body = body'; - end - end - - if ischar(body) - %NOTE: '' defaults to Matlab's default encoding scheme - body = unicode2native(body,in.ENCODING); - elseif ~(strcmp(class(body),'uint8') || strcmp(class(body),'int8')) - error('Function input: body, should be of class char, uint8, or int8, detected: %s',class(body)) - end - - urlConnection.setRequestProperty('Content-Length',int2str(length(body))); - urlConnection.setDoOutput(true); - outputStream = urlConnection.getOutputStream; - outputStream.write(body); - outputStream.close; -else - urlConnection.setRequestProperty('Content-Length','0'); -end - -%========================================================================== -% Read the data from the connection. -%========================================================================== -%This should be done first because it tells us if things are ok or not -%NOTE: If there is an error, functions below using urlConnection, notably -%getResponseCode, will fail as well -try - inputStream = urlConnection.getInputStream; - isGood = true; -catch ME - isGood = false; -%NOTE: HTTP error codes will throw an error here, we'll allow those for now -%We might also get another error in which case the inputStream will be -%undefined, those we will throw here - inputStream = urlConnection.getErrorStream; - - if isempty(inputStream) - msg = ME.message; - I = strfind(msg,char([13 10 9])); %see example by setting timeout to 1 - %Should remove the barf of the stack, at ... at ... at ... etc - %Likely that this could be improved ... (generate link with full msg) - if ~isempty(I) - msg = msg(1:I(1)-1); - end - fprintf(2,'Response stream is undefined\n below is a Java Error dump (truncated):\n'); - error(msg) - end -end - -%POPULATING HEADERS -%-------------------------------------------------------------------------- -allHeaders = struct; -allHeaders.Response = {char(urlConnection.getHeaderField(0))}; -done = false; -headerIndex = 0; - -while ~done - headerIndex = headerIndex + 1; - headerValue = char(urlConnection.getHeaderField(headerIndex)); - if ~isempty(headerValue) - headerName = char(urlConnection.getHeaderFieldKey(headerIndex)); - headerName = fixHeaderCasing(headerName); %NOT YET FINISHED - - %Important, for name safety all hyphens are replace with underscores - headerName(headerName == '-') = '_'; - if isfield(allHeaders,headerName) - allHeaders.(headerName) = [allHeaders.(headerName) headerValue]; - else - allHeaders.(headerName) = {headerValue}; - end - else - done = true; - end -end - -firstHeaders = struct; -fn = fieldnames(allHeaders); -for iHeader = 1:length(fn) - curField = fn{iHeader}; - firstHeaders.(curField) = allHeaders.(curField){1}; -end - -status = struct(... - 'value', urlConnection.getResponseCode(),... - 'msg', char(urlConnection.getResponseMessage)); - -%PROCESSING OF OUTPUT -%---------------------------------------------------------- -byteArrayOutputStream = java.io.ByteArrayOutputStream; -% This StreamCopier is unsupported and may change at any time. OH GREAT :/ -isc = InterruptibleStreamCopier.getInterruptibleStreamCopier; -isc.copyStream(inputStream,byteArrayOutputStream); -inputStream.close; -byteArrayOutputStream.close; - -if in.CAST_OUTPUT - charset = ''; - - %Extraction of character set from Content-Type header if possible - if isfield(firstHeaders,'Content_Type') - text = firstHeaders.Content_Type; - %Always open to regexp improvements - charset = regexp(text,'(?<=charset=)[^\s]*','match','once'); - end - - if ~isempty(charset) - output = native2unicode(typecast(byteArrayOutputStream.toByteArray','uint8'),charset); - else - output = char(typecast(byteArrayOutputStream.toByteArray','uint8')); - end -else - %uint8 is more useful for later charecter conversions - %uint8 or int8 is somewhat arbitary at this point - output = typecast(byteArrayOutputStream.toByteArray','uint8'); -end - -extras = struct; -extras.allHeaders = allHeaders; -extras.firstHeaders = firstHeaders; -extras.status = status; -%Gets eventual url even with redirection -extras.url = char(urlConnection.getURL); -extras.isGood = isGood; - - - -end - -function headerNameOut = fixHeaderCasing(headerName) -%fixHeaderCasing Forces standard casing of headers -% -% headerNameOut = fixHeaderCasing(headerName) -% -% This is important for field access in a structure which -% is case sensitive -% -% Not yet finished. -% I've been adding to this function as problems come along - - switch lower(headerName) - case 'location' - headerNameOut = 'Location'; - case 'content_type' - headerNameOut = 'Content_Type'; - otherwise - headerNameOut = headerName; - end -end - -%========================================================================== -%========================================================================== -%========================================================================== - -function urlConnection = getURLConnection(urlChar) -%getURLConnection -% -% urlConnection = getURLConnection(urlChar) - -% Determine the protocol (before the ":"). -protocol = urlChar(1:find(urlChar==':',1)-1); - - -% Try to use the native handler, not the ice.* classes. -try - switch protocol - case 'http' - %http://www.docjar.com/docs/api/sun/net/www/protocol/http/HttpURLConnection.html - handler = sun.net.www.protocol.http.Handler; - case 'https' - handler = sun.net.www.protocol.https.Handler; - end -catch ME - handler = []; -end - -% Create the URL object. -try - if isempty(handler) - url = java.net.URL(urlChar); - else - url = java.net.URL([],urlChar,handler); - end -catch ME - error('Failure to parse URL or protocol not supported for:\nURL: %s',urlChar); -end - -% Get the proxy information using MathWorks facilities for unified proxy -% preference settings. -mwtcp = com.mathworks.net.transport.MWTransportClientPropertiesFactory.create(); -proxy = mwtcp.getProxy(); - -% Open a connection to the URL. -if isempty(proxy) - urlConnection = url.openConnection; -else - urlConnection = url.openConnection(proxy); -end - - -end diff --git a/cb-tools/urlread2/urlread_notes.txt b/cb-tools/urlread2/urlread_notes.txt deleted file mode 100644 index 8257f092..00000000 --- a/cb-tools/urlread2/urlread_notes.txt +++ /dev/null @@ -1,86 +0,0 @@ -========================================================================== - Unicode & Matlab -========================================================================== -native2unicode - works with uint8, fails with char - -Taking a unicode character and encoding as bytes: -unicode2native(char(1002),'UTF-8') -back to the character: -native2unicode(uint8([207 170]),'UTF-8') -this doesn't work: -native2unicode(char([207 170]),'UTF-8') -in documentation: If BYTES is a CHAR vector, it is returned unchanged. - -Java - only supports int8 -Matlab to Java -> uint8 or int8 to bytes -Java to Matlab -> bytes to int8 -char - 16 bit - -Maintenance of underlying bytes: -typecast(java.lang.String(uint8(250)).getBytes,'uint8') = 250 -see documentation: Handling Data Returned from a Java Method - -Command Window difficulty --------------------------------------------------------------------- -The typical font in the Matlab command window will often fail to render -unicode properly. I often can see unicode better in the variable editor -although this may be fixed if you change your font preferences ... -Copying unicode from the command window often results in the -generations of the value 26 (aka substitute) - -More documentation on input/output to urlread2 to follow eventually ... - -small notes to self: -for output -native2unicode(uint8(output),encoding) - -========================================================================== - HTTP Headers -========================================================================== -Handling of repeated http readers is a bit of a tricky situation. Most -headers are not repeated although sometimes http clients will assume this -for too many headers which can result in a problem if you want to see -duplicated headers. I've passed the problem onto the user who can decide -to handle it how they wish instead of providing the right solution, which -after some brief searching, I am not sure exists. - -========================================================================== - PROBLEMS -========================================================================== -1) Page requires following a redirect: -%------------------------------------------- -ref: http://www.mathworks.com/matlabcentral/newsreader/view_thread/302571 -fix: FOLLOW_REDIRECTS is enabled by default, you're fine. - -2) Basic authentication required: -%------------------------------------------ -Create and pass in the following header: -user = 'test'; -password = 'test'; -encoder = sun.misc.BASE64Encoder(); -str = java.lang.String([user ':' password]) %NOTE: format may be -%different for your server -header = http_createHeader('Authorization',char(encoder.encode(str.getBytes()))) -NOTE: Ideally you would make this a function - -3) The text returned doesn't make sense. -%----------------------------------------- -The text may not be encoded correctly. Requires native2unicode function. -See Unicode & Matlab section above. - -4) I get a different result in my web browser than I do in Matlab -%----------------------------------------- -This is generally seen for two reasons. -1 - The easiest and silly reason is user agent filtering. -When you make a request you identify yourself -as being a particular "broswer" or "user agent". Setting a header -with the user agent of the browser may fix the problem. -See: http://en.wikipedia.org/wiki/User_agent -See: http://whatsmyuseragent.com -value = '' -header = http_createHeader('User-Agent',value); -2 - You are not processing cookies and the server is not sending -you information because you haven't sent it cookies (everyone likes em!) -I've implemented cookie support but it requires some extra files that -I need to clean up. Feel free to email me if you'd really like to have them. - diff --git a/cb-tools/urlread2/urlread_todos.txt b/cb-tools/urlread2/urlread_todos.txt deleted file mode 100644 index 0434bcea..00000000 --- a/cb-tools/urlread2/urlread_todos.txt +++ /dev/null @@ -1,13 +0,0 @@ -========================================================================== - IMPROVEMENTS -========================================================================== -1) The function could be improved to support file streaming both in sending -and in receiving (saving) to reduce memory usage but this is very low priority - -2) Implement better casing handling - -3) Choose a better function name than urlread2 -> sorry Chris :) - -4) Support multipart/form-data, this ideally would be handled by a helper function - -5) Allow for throwing an error if the HTTP status code is an error (400) and 500 as well? \ No newline at end of file diff --git a/cb-tools/urlread2/urlread_versionInfo.txt b/cb-tools/urlread2/urlread_versionInfo.txt deleted file mode 100644 index a8448f49..00000000 --- a/cb-tools/urlread2/urlread_versionInfo.txt +++ /dev/null @@ -1,13 +0,0 @@ -===================== -Version 1.1 -3/25/2012 - -Summary: Bug fixes related to Matlab throwing errors when it shouldn't have been. Thanks to Shane Lin for pointing out the problems. - -Bug Fix: Modified code so that http status errors wouldn't throw errors in the code, but rather would be passed on to the user to process - -Bug Fix: Provided GET example code apparently doesn't work for all users, changed to a different example. - -===================== -Version 1 -3/17/2012 \ No newline at end of file diff --git a/+dat/findNextSeqNum.m b/cortexlab/+dat/findNextSeqNum.m similarity index 100% rename from +dat/findNextSeqNum.m rename to cortexlab/+dat/findNextSeqNum.m diff --git a/+dat/subjectSelector.m b/cortexlab/+dat/subjectSelector.m similarity index 100% rename from +dat/subjectSelector.m rename to cortexlab/+dat/subjectSelector.m diff --git a/+hw/DaqLever.m b/cortexlab/+hw/DaqLever.m similarity index 100% rename from +hw/DaqLever.m rename to cortexlab/+hw/DaqLever.m diff --git a/+hw/DaqLick.m b/cortexlab/+hw/DaqLick.m similarity index 100% rename from +hw/DaqLick.m rename to cortexlab/+hw/DaqLick.m diff --git a/+hw/DaqPiezo.m b/cortexlab/+hw/DaqPiezo.m similarity index 100% rename from +hw/DaqPiezo.m rename to cortexlab/+hw/DaqPiezo.m diff --git a/cortexlab/+hw/daqControllerForValve.m b/cortexlab/+hw/daqControllerForValve.m deleted file mode 100644 index 0418cc4c..00000000 --- a/cortexlab/+hw/daqControllerForValve.m +++ /dev/null @@ -1,28 +0,0 @@ -function daqController = daqControllerForValve(daqRewardValve, calibrations, addLaser) -%UNTITLED Summary of this function goes here -% Detailed explanation goes here - -daqController = hw.DaqController; -daqController.ChannelNames = {'rewardValve'}; -daqController.DaqIds = 'Dev1'; -daqController.DaqChannelIds = {daqRewardValve.DaqChannelId}; -daqController.SignalGenerators = hw.RewardValveControl; -daqController.SignalGenerators.ClosedValue = daqRewardValve.ClosedValue; -daqController.SignalGenerators.DefaultValue = daqRewardValve.ClosedValue; -daqController.SignalGenerators.OpenValue = daqRewardValve.OpenValue; -daqController.SignalGenerators.Calibrations = calibrations; -daqController.SignalGenerators.DefaultCommand = daqRewardValve.DefaultRewardSize; - -if nargin > 2 && addLaser - daqController.DaqChannelIds{2} = 'ao1'; - daqController.ChannelNames{2} = {'laserShutter'}; - daqController.SignalGenerators(2) = hw.PulseSwitcher; - daqController.SignalGenerators(2).ClosedValue = 0; - daqController.SignalGenerators(2).DefaultValue = 0; - daqController.SignalGenerators(2).OpenValue = 5; - daqController.SignalGenerators(2).DefaultCommand = 10; - daqController.SignalGenerators(2).ParamsFun = @(sz) deal(10/1000, sz, 25); -end - - -end \ No newline at end of file diff --git a/cortexlab/+srv/RemoteMPEPService.m b/cortexlab/+srv/RemoteMPEPService.m index e71acd0d..457428f4 100644 --- a/cortexlab/+srv/RemoteMPEPService.m +++ b/cortexlab/+srv/RemoteMPEPService.m @@ -114,8 +114,8 @@ function delete(obj) end function obj = addListener(obj, name, listenPort, callback) - if nargin<3; callback = @nop; end - if listenPort==obj.ListenPorts + if nargin<4; callback = @nop; end + if any(listenPort==obj.ListenPorts{:}) error('Listen port already added'); end idx = length(obj.Sockets)+1; @@ -172,18 +172,19 @@ function bind(obj, names) names = ensureCell(names); hosts = arrayfun(@(s)s.Tag, obj.Sockets); idx = cellfun(@(n)find(strcmp(n,hosts)), names); - arrayfun(@fopen, obj.Sockets(idx)) + cellfun(@fopen, obj.Sockets(idx)) end - log('Polling for UDP messages'); + obj.log('Polling for UDP messages'); end function start(obj, ref) % Send start message to remotehost and await confirmation - [expRef, AlyxInstance] = parseAlyxInstance(ref); +% [expRef, AlyxInstance] = parseAlyxInstance(ref); % Convert expRef to MPEP style - [subject, seriesNum, expNum] = dat.expRefToMpep(expRef); +% [subject, seriesNum, expNum] = dat.expRefToMpep(expRef); % Build start message - msg = sprintf('ExpStart %s %d %d', subject, seriesNum, expNum); +% msg = sprintf('ExpStart %s %d %d', subject, seriesNum, expNum); + msg = sprintf('GOGO%s*%s', ref, hostname); % Send the start message obj.confirmedSend(msg, obj.RemoteHost); % Wait for response @@ -248,7 +249,7 @@ function echo(obj, src, ~) fopen(src); fprintf(obj.Socket, obj.LastReceivedMessage); % Send message obj.LastSentMessage = obj.LastReceivedMessage; % Save a copy of the message - disp(['Echo''d message to ' src.Tag]) % Display success + log(obj,'Echo''d message to %s', src.Tag) % Display success end end @@ -273,7 +274,7 @@ function processMsg(obj, src, ~) case {'expstart', 'gogo'} try % Start Timeline - log('Received start request') + log(obj, 'Received start request') obj.LocalStatus = 'starting'; obj.Timeline.start(dat.parseAlyxInstance(msg.expRef)) obj.LocalStatus = 'running'; @@ -297,7 +298,7 @@ function processMsg(obj, src, ~) obj.sendUDP() otherwise % TODO RemoteHost - log(['Received ''' obj.LastReceivedMessage ''' from ' obj.RemoteHost]) + log(obj, ['Received ''' obj.LastReceivedMessage ''' from ' obj.RemoteHost]) end end From 87887288728b2a16dcc3b02e3139ca5939aa7171 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 25 Jan 2018 11:40:30 +0000 Subject: [PATCH 034/507] Update to mat2DStrTo1D This function was never used until now. A couple of the functions called within it have since been renamed. --- cb-tools/burgbox/mat2DStrTo1D.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cb-tools/burgbox/mat2DStrTo1D.m b/cb-tools/burgbox/mat2DStrTo1D.m index e8789e0b..ab79761a 100644 --- a/cb-tools/burgbox/mat2DStrTo1D.m +++ b/cb-tools/burgbox/mat2DStrTo1D.m @@ -9,7 +9,7 @@ % 2013-09 CB created if iscellstr(str) - str = mapToCellArray(@matStr2Lines, str); + str = mapToCell(@mat2DStrTo1D, str); else str = strJoin(deblank(num2cell(str, 2)), '\n'); end From 92cfabd976abee5ec0b5867d4eb9b332be2c1388 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 25 Jan 2018 15:28:52 +0000 Subject: [PATCH 035/507] Fixed clocking pulse bug in Timeline * Clocking pulse now functions correctly * rewardController (obsolete) now removed from code. * DaqSession release added as temp fix for DAQ samples bug --- +hw/DaqController.m | 2 ++ +hw/Timeline.m | 5 +++-- +srv/expServer.m | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/+hw/DaqController.m b/+hw/DaqController.m index cca9f947..4a480fcf 100644 --- a/+hw/DaqController.m +++ b/+hw/DaqController.m @@ -161,6 +161,8 @@ function command(obj, varargin) else startBackground(obj.DaqSession); end + readyWait(obj); + obj.DaqSession.release; elseif any(~analogueChannelsIdx) waveforms = waveforms(~analogueChannelsIdx); for n = 1:length(waveforms) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 49302b58..30169385 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -508,11 +508,12 @@ function init(obj) case 'clock' obj.Sessions('clock') = daq.createSession(obj.DaqVendor); - obj.Sessions('clock').IsContinuous = true; + clockSess = obj.Sessions('clock'); + clockSess.IsContinuous = true; clocked = obj.Sessions('clock').addCounterOutputChannel(obj.DaqIds, out.daqChannelID, out.type); clocked.Frequency = obj.ClockOutputFrequency; clocked.DutyCycle = obj.ClockOutputDutyCycle; - clocked.InitialDelay = out.delay; + clocked.InitialDelay = out.initialDelay; end end %%Create channels for each input diff --git a/+srv/expServer.m b/+srv/expServer.m index d954527c..4e4bec05 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -335,7 +335,7 @@ function setClock(user, clock) end rig.clock = clock; cellfun(@(user) setClock(user, clock),... - {'mouseInput', 'rewardController', 'lickDetector'}); + {'mouseInput', 'lickDetector'}); t = rig.timeline.UseTimeline; if enable From e56802fc2659661847649b8cafd5bd63e47ec74e Mon Sep 17 00:00:00 2001 From: nsteinme Date: Thu, 25 Jan 2018 17:21:10 +0000 Subject: [PATCH 036/507] mpepdatahosts bug fix --- cortexlab/+io/MpepUDPDataHosts.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cortexlab/+io/MpepUDPDataHosts.m b/cortexlab/+io/MpepUDPDataHosts.m index 821b215f..76fae0c7 100644 --- a/cortexlab/+io/MpepUDPDataHosts.m +++ b/cortexlab/+io/MpepUDPDataHosts.m @@ -201,10 +201,10 @@ function expEnded(obj) obj.ExpRef = []; end - function start(obj, ref) + function start(obj, ref) [expRef, ai] = dat.parseAlyxInstance(ref); obj.AlyxInstance = ai; - [subject, seriesNum, expNum] = dat.expRefToMpep(obj.ExpRef); + [subject, seriesNum, expNum] = dat.expRefToMpep(expRef); alyxmsg = sprintf('alyx %s %d %d %s', subject, seriesNum, expNum, ref); confirmedBroadcast(obj, alyxmsg); % equivalent to startExp(expRef) From c2819f0304ba5d84b94dbfba255ddac7fafcb4aa Mon Sep 17 00:00:00 2001 From: nsteinme Date: Thu, 25 Jan 2018 18:56:40 +0000 Subject: [PATCH 037/507] first commit with working chrono and acqLive classes --- +hw/Timeline.m | 268 ++++++++++++++++++++++++------------------ +hw/tlOutput.m | 36 ++++++ +hw/tlOutputAcqLive.m | 52 ++++++++ +hw/tlOutputChrono.m | 89 ++++++++++++++ 4 files changed, 332 insertions(+), 113 deletions(-) create mode 100644 +hw/tlOutput.m create mode 100644 +hw/tlOutputAcqLive.m create mode 100644 +hw/tlOutputChrono.m diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 3739c1bf..0e69607a 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -13,12 +13,13 @@ % (see tl.time() and tl.ptbSecsToTimeline()). In is assumed that the % time between sending the chrono pulse and recieving it is negligible. % -% There are two other available clocking signals: 'acqLive' and 'clock'. +% There are other available clocking signals, for instance: 'acqLive' and 'clock'. % The former outputs a high (+5V) signal the entire time tl is aquiring % (0V otherwise), and can be used to trigger devices with a TTL input. % The 'clock' output is a regular pulse at a frequency of % ClockOutputFrequency and duty cycle of ClockOutputDutyCycle. This can -% be used to trigger a camera at a specific frame rate. +% be used to trigger a camera at a specific frame rate. See "properties" below for +% further details on output configurations. % % Besides the chrono signal, tl can aquire any number of inputs and % record their values on the same clock. For example a photodiode to @@ -68,23 +69,50 @@ DaqIds = 'Dev1' % Device ID can be found with daq.getDevices() DaqSampleRate = 1000 % rate at which daq aquires data in Hz, see Rate DaqSamplesPerNotify % determines the number of data samples to be processed each time, see Timeline.process(), constructor and NotifyWhenDataAvailableExceeds - Outputs % structure of outputs with their type, delays and ports, see constructor + + Outputs % structure of outputs with their names and configuration information + % All outputs require the following fields: + % - name: a string identifier + % - type: one of the supported modes that we know how to + % handle, see below + % - params, the required parameters for the selected type + % + % Available types: + % - chrono: a default type that Timeline uses to monitor that + % acquisition is proceeding normally during a recording. + % Requires: daqChannelID + % + % - acqLive: a digital output that is turned on at the start + % of the timeline recording, turned off at the end + % Requires: daqChannelID, initialDelay + % + % - clock: a regular pulse at a specified frequency and duty + % cycle. Can be used to trigger camera frames, e.g. + % Requires: daqChannelID, initialDelay, frequency, dutyCycle + % + % - startStopSync: a brief pulse at the beginning and at the + % end of a recording. Can be used for synchronization + % - Requires: daqChannelID, initialDelay, pulseDuration + Inputs = struct('name', 'chrono',... 'arrayColumn', -1,... % -1 is default indicating unused, this is update when the channels are added during tl.start() 'daqChannelID', 'ai0',... 'measurement', 'Voltage',... 'terminalConfig', 'SingleEnded') UseInputs = {'chrono'} % array of inputs to record while tl is running - UseOutputs = {'chrono'} % array of output pulses to use while tl is running + %UseOutputs = {'chrono'} % array of output pulses to use while tl is running StopDelay = 2 % currently pauses for at least 2 secs as 'hack' before stopping main DAQ session - MaxExpectedDuration = 2*60*60 % expected experiment time so data structure is initialised to sensible size (in secs) - ClockOutputFrequency = 60 % if using 'clock' output, this specifies the frequency of pulses (Hz) - ClockOutputDutyCycle = 0.2 % if using 'clock' output, this specifies the duty cycle (as a fraction) + MaxExpectedDuration = 2*60*60 % expected experiment time so data structure is initialised to sensible size (in secs) AquiredDataType = 'double' % default data type for the acquired data array (i.e. Data.rawDAQData) UseTimeline = false % used by expServer. If true, timeline is started by default (otherwise can be toggled with the t key) LivePlot = false % if true the data are plotted as the data are aquired LivePlotParams = []; WriteBufferToDisk = false % if true the data buffer is written to disk as they're aquired NB: in the future this will happen by default + + % moved these here so chrono class can access - NS + CurrSysTimeTimelineOffset % difference between the system time when the last chrono flip occured and the timestamp recorded by the DAQ, see tl.process() + LastTimestamp % the last timestamp returned from the daq during the DataAvailable event. Used to check sampling continuity, see tl.process() + LastClockSentSysTime % the mean of the system time before and after the last chrono flip. Used to calculate CurrSysTimeTimelineOffset, see tl.process() end properties (Dependent) @@ -94,11 +122,7 @@ properties (Transient, Access = protected) Listener % holds the listener for 'DataAvailable', see DataAvailable and Timeline.process() - Sessions = containers.Map % map of daq sessions and their channels, created at tl.start() - CurrSysTimeTimelineOffset % difference between the system time when the last chrono flip occured and the timestamp recorded by the DAQ, see tl.process() - LastTimestamp % the last timestamp returned from the daq during the DataAvailable event. Used to check sampling continuity, see tl.process() - LastClockSentSysTime % the mean of the system time before and after the last chrono flip. Used to calculate CurrSysTimeTimelineOffset, see tl.process() - NextChronoSign = 1 % the value to output on the chrono channel, the sign is changed each 'DataAvailable' event (DaqSamplesPerNotify) + Sessions = containers.Map % map of daq sessions and their channels, created at tl.start() Ref % the expRef string. See tl.start() AlyxInstance % a struct contraining the Alyx token, user and url for ile registration. See tl.start() Data % A structure containing timeline data @@ -112,22 +136,43 @@ % Adds chrono, aquireLive and clock to the outputs list, % along with default ports and delays obj.DaqSamplesPerNotify = 1/obj.SamplingInterval; % calculate DaqSamplesPerNotify - defaultOutputs = {'chrono', 'acqLive', 'clock';... % names of each output - 'port1/line0', 'port0/line1', 'ctr3';... % their default ports - 'OutputOnly', 'OutputOnly', 'PulseGeneration'; % default output type - 0, 0, 0}; % the initial delay (useful for ensure all systems are ready) - obj.Outputs = cell2struct(defaultOutputs, {'name', 'daqChannelID', 'type', 'initialDelay'}); + +% defaultOutputs = struct('name', 'type', 'params'); +% defaultOutputs(1).name = 'chrono'; +% defaultOutputs(1).type = 'chrono'; +% defaultOutputs(1).params.daqChannelID = 'port1/line0'; +% +% defaultOutputs(2).name = 'acqLive'; +% defaultOutputs(2).type = 'acqLive'; +% defaultOutputs(2).params.daqChannelID = 'port0/line1'; +% defaultOutputs(2).params.initialDelay = 0; +% +% defaultOutputs(3).name = 'clock'; +% defaultOutputs(3).type = 'clock'; +% defaultOutputs(3).params.daqChannelID = 'ctr3'; +% defaultOutputs(3).params.initialDelay = 0; +% defaultOutputs(3).params.frequency = 60; +% defaultOutputs(3).params.dutyCycle = 0.2; +% +% defaultOutputs(4).name = 'camSync'; +% defaultOutputs(4).type = 'startStopSync'; +% defaultOutputs(4).params.daqChannelID = 'port0/line3'; +% defaultOutputs(4).params.initialDelay = 0; +% defaultOutputs(4).params.pulseDuration = 0.2; + +% obj.Outputs = defaultOutputs; + if nargin % if old tl hardware struct provided, use these to populate properties obj.Inputs = hw.inputs; obj.DaqVendor = hw.daqVendor; obj.DaqIds = hw.daqDevice; obj.DaqSampleRate = hw.daqSampleRate; obj.DaqSamplesPerNotify = hw.daqSamplesPerNotify; - obj.Outputs(1).daqChannelID = hw.chronoOutDaqChannelID; - obj.Outputs(2).daqChannelID = hw.acqLiveDaqChannelID; - obj.Outputs(3).daqChannelID = hw.clockOutputDaqChannelID; - obj.ClockOutputFrequency = hw.clockOutputFrequency; - obj.ClockOutputDutyCycle = hw.clockOutputDutyCycle; +% obj.Outputs(1).daqChannelID = hw.chronoOutDaqChannelID; +% obj.Outputs(2).daqChannelID = hw.acqLiveDaqChannelID; +% obj.Outputs(3).daqChannelID = hw.clockOutputDaqChannelID; +% obj.ClockOutputFrequency = hw.clockOutputFrequency; +% obj.ClockOutputDutyCycle = hw.clockOutputDutyCycle; end end @@ -142,16 +187,7 @@ function start(obj, expRef, Alyx) end obj.Ref = expRef; % set the current experiment ref obj.AlyxInstance = Alyx; % set the current instance of Alyx - init(obj); % start the relevent sessions and add channels - - %%Send a test pulse low, then high to clocking channel & check we read it back - idx = cellfun(@(s2)strcmp('chrono',s2), {obj.Inputs.name}); - outputSingleScan(obj.Sessions('chrono'), false) - x1 = obj.Sessions('main').inputSingleScan; - outputSingleScan(obj.Sessions('chrono'), true) - x2 = obj.Sessions('main').inputSingleScan; - assert(x1(obj.Inputs(idx).arrayColumn) < 2.5 && x2(obj.Inputs(idx).arrayColumn) > 2.5,... - 'The clocking pulse test could not be read back'); + init(obj); % start the relevent sessions and add channels obj.Listener = obj.Sessions('main').addlistener('DataAvailable', @obj.process); % add listener @@ -181,7 +217,6 @@ function start(obj, expRef, Alyx) obj.Data.startDateTimeStr = datestr(obj.Data.startDateTime); %%Start the DAQ acquiring - outputSingleScan(obj.Sessions('chrono'), false) % make sure chrono is low %LastTimestamp is the timestamp of the last acquisition sample, which is %saved to ensure continuity of acquisition. Here it is initialised as if a %previous acquisition had been made in negative time, since the first @@ -189,27 +224,20 @@ function start(obj, expRef, Alyx) obj.LastTimestamp = -obj.SamplingInterval; startBackground(obj.Sessions('main')); % start aquisition - %%Output clocking pulse and wait for first acquisition to complete - % output first clocking high pulse - t = GetSecs; %system time before outputting chrono flip - outputSingleScan(obj.Sessions('chrono'), obj.NextChronoSign > 0); % flip chrono signal - obj.LastClockSentSysTime = (t + GetSecs)/2; % log mean before/after system time - % wait for first acquisition processing to begin while ~obj.IsRunning pause(5e-3); end - if isKey(obj.Sessions, 'acqLive') % is acqLive being used? - % set acquisition live signal to true - pause(obj.Outputs(cellfun(@(s2)strcmp('chrono',s2), {obj.Outputs.name})).initialDelay); - outputSingleScan(obj.Sessions('acqLive'), true); - end - if isKey(obj.Sessions, 'clock') % is the clock output being used? - % start session to send timing output pulses - startBackground(obj.Sessions('clock')); + for outidx = 1:numel(obj.Outputs) + obj.Outputs(outidx).onStart(obj); end +% if isKey(obj.Sessions, 'clock') % is the clock output being used? +% % start session to send timing output pulses +% startBackground(obj.Sessions('clock')); +% end + % Report success fprintf('Timeline started successfully for ''%s''.\n', expRef); end @@ -385,12 +413,11 @@ function stop(obj) return end % kill acquisition output signals - if isKey(obj.Sessions, 'acqLive') - outputSingleScan(obj.Sessions('acqLive'), false); % live -> false - end - for i = 1:length(obj.UseOutputs) - name = obj.UseOutputs{i}; - stop(obj.Sessions(name)); +% if isKey(obj.Sessions, 'acqLive') +% outputSingleScan(obj.Sessions('acqLive'), false); % live -> false +% end + for outidx = 1:numel(obj.Outputs) + obj.Outputs(outidx).onStop(obj); end pause(obj.StopDelay) @@ -400,14 +427,7 @@ function stop(obj) % wait before deleting the listener to ensure most recent samples are % collected pause(1.5); - delete(obj.Listener) % now delete the data listener - - % release hardware resources - sessions = keys(obj.Sessions); % find names of all current sessions - for i = 1:length(sessions) - name = sessions{i}; - release(obj.Sessions(name)); - end + delete(obj.Listener) % now delete the data listener % only keep the used part of the daq input array obj.Data.rawDAQData((obj.Data.rawDAQSampleCount + 1):end,:) = []; @@ -419,15 +439,38 @@ function stop(obj) % replicate old tl data struct for legacy code idx = cellfun(@(s2)strcmp('chrono',s2), {obj.Inputs.name}); arrayChronoColumn = obj.Inputs(idx).arrayColumn; + inputsIdx = cellfun(@(x)find(strcmp({obj.Inputs.name}, x),1), obj.UseInputs); + + % this block finds the daqChannelID for chrono and acqLive if + % they exist + outputClasses = arrayfun(@class, obj.Outputs, 'uni', false); + chronoChan = []; nextChrono = []; acqLiveChan = []; useClock = false; clockF = []; clockD = []; + chronoOutputIdx = find(strcmp(outputClasses, 'hw.tlOutputChrono'),1); + if ~isempty(chronoOutputIdx) + chronoChan = obj.Outputs(chronoOutputIdx).daqChannelID; + nextChrono = obj.Outputs(chronoOutputIdx).NextChronoSign; + end + acqLiveOutputIdx = find(strcmp(outputClasses, 'hw.tlOutputAcqLive'),1); + if ~isempty(acqLiveOutputIdx) + acqLiveChan = obj.Outputs(acqLiveOutputIdx).daqChannelID; + end + clockOutputIdx = find(strcmp(outputClasses, 'hw.tlOutputClock'),1); + if ~isempty(clockOutputIdx) + useClock = true; + clockF = obj.Outputs(clockOutputIdx).frequency; + clockD = obj.Outputs(clockOutputIdx).dutyCycle; + end + obj.Data.hw = struct('daqVendor', obj.DaqVendor, 'daqDevice', obj.DaqIds,... 'daqSampleRate', obj.DaqSampleRate, 'daqSamplesPerNotify', obj.DaqSamplesPerNotify,... - 'chronoOutDaqChannelID', obj.Outputs(1).daqChannelID, 'acqLiveOutDaqChannelID', obj.Outputs(2).daqChannelID,... - 'useClockOutput', any(strcmp('clock', obj.UseOutputs)), 'clockOutputFrequency', obj.ClockOutputFrequency,... - 'clockOutputDutyCycle', obj.ClockOutputDutyCycle, 'samplingInterval', obj.SamplingInterval,... - 'inputs', obj.Inputs(sign([obj.Inputs.arrayColumn])==1), 'arrayChronoColumn', arrayChronoColumn); + 'chronoOutDaqChannelID', chronoChan, 'acqLiveOutDaqChannelID', acqLiveChan,... + 'useClockOutput', useClock, 'clockOutputFrequency', clockF,... + 'clockOutputDutyCycle', clockD, 'samplingInterval', obj.SamplingInterval,... + 'inputs', obj.Inputs(inputsIdx), ... % find the correct inputs, in the correct order + 'arrayChronoColumn', arrayChronoColumn); obj.Data.expRef = obj.Ref; % save experiment ref obj.Data.isRunning = obj.IsRunning; - obj.Data.nextChronoSign = obj.NextChronoSign; + obj.Data.nextChronoSign = nextChrono; obj.Data.lastTimestamp = obj.LastTimestamp; obj.Data.lastClockSentSysTime = obj.LastClockSentSysTime; obj.Data.currSysTimeTimelineOffset = obj.CurrSysTimeTimelineOffset; @@ -480,6 +523,13 @@ function stop(obj) % Report successful stop fprintf('Timeline for ''%s'' stopped and saved successfully.\n', obj.Ref); end + + function s = getSessions(obj, name) + % returns the Sessions property. Some things (e.g. output + % classes) need this. + s = obj.Sessions(name); + end + end methods (Access = private) @@ -490,30 +540,33 @@ function init(obj) % Also add a 'main' session to which all input channels are % added. See daq.createSession - %%Create session objects for chrono and other outputs - [use, idx] = intersect({obj.Outputs.name}, obj.UseOutputs); % find which outputs to use -% assert(numel(idx) == numel(obj.UseOutputs), 'Not all outputs were recognised'); - for i = 1:length(use) - out = obj.Outputs(idx(i)); % get channel info, etc. - switch use{i} - case 'chrono' - obj.Sessions('chrono') = daq.createSession(obj.DaqVendor); - obj.Sessions('chrono').addDigitalChannel(obj.DaqIds, out.daqChannelID, out.type); - - case 'acqLive' - obj.Sessions('acqLive') = daq.createSession(obj.DaqVendor); - obj.Sessions('acqLive').addDigitalChannel(obj.DaqIds, out.daqChannelID, out.type); - outputSingleScan(obj.Sessions('acqLive'), false); % ensure acq live is false - - case 'clock' - obj.Sessions('clock') = daq.createSession(obj.DaqVendor); - obj.Sessions('clock').IsContinuous = true; - clocked = obj.Sessions('clock').addCounterOutputChannel(obj.DaqIds, out.daqChannelID, out.type); - clocked.Frequency = obj.ClockOutputFrequency; - clocked.DutyCycle = obj.ClockOutputDutyCycle; - clocked.InitialDelay = out.delay; - end - end +% %%Create session objects for chrono and other outputs +% [use, idx] = intersect({obj.Outputs.name}, obj.UseOutputs); % find which outputs to use +% % assert(numel(idx) == numel(obj.UseOutputs), 'Not all outputs were recognised'); +% for i = 1:length(use) +% out = obj.Outputs(idx(i)); % get channel info, etc. +% switch use{i} +% case 'chrono' +% obj.Sessions('chrono') = daq.createSession(obj.DaqVendor); +% obj.Sessions('chrono').addDigitalChannel(obj.DaqIds, out.daqChannelID, out.type); +% +% case 'acqLive' +% obj.Sessions('acqLive') = daq.createSession(obj.DaqVendor); +% obj.Sessions('acqLive').addDigitalChannel(obj.DaqIds, out.daqChannelID, out.type); +% outputSingleScan(obj.Sessions('acqLive'), false); % ensure acq live is false +% +% case 'clock' +% obj.Sessions('clock') = daq.createSession(obj.DaqVendor); +% obj.Sessions('clock').IsContinuous = true; +% clocked = obj.Sessions('clock').addCounterOutputChannel(obj.DaqIds, out.daqChannelID, out.type); +% clocked.Frequency = obj.ClockOutputFrequency; +% clocked.DutyCycle = obj.ClockOutputDutyCycle; +% clocked.InitialDelay = out.delay; +% end +% end + + + %%Create channels for each input [use, idx] = intersect({obj.Inputs.name}, obj.UseInputs);% find which inputs to use assert(numel(idx) == numel(obj.UseInputs), 'Not all inputs were recognised'); @@ -524,7 +577,6 @@ function init(obj) obj.Sessions('main') = inputSession; for i = 1:length(use) in = obj.Inputs(strcmp({obj.Inputs.name}, obj.UseInputs(i))); -% in = obj.Inputs(idx(i)); % get channel info, etc. fprintf(1, 'adding channel %s on %s\n', in.name, in.daqChannelID); switch in.measurement @@ -542,6 +594,11 @@ function init(obj) end obj.Inputs(strcmp({obj.Inputs.name}, obj.UseInputs(i))).arrayColumn = i; end + + % Initialize outputs + for outidx = 1:numel(obj.Outputs) + obj.Outputs(outidx).onInit(obj); + end end function process(obj, ~, event) @@ -565,25 +622,10 @@ function process(obj, ~, event) 'Discontinuity of DAQ acquistion detected: last timestamp was %f and this one is %f',... obj.LastTimestamp, event.TimeStamps(1)); - %%% The chrono "out" value is flipped at a recorded time, and - %%% the sample index that this flip is measured is noted - % First, find the index of the flip in the latest chunk of data - idx = elementByName(obj.Inputs, 'chrono'); - clockChangeIdx = find(sign(event.Data(:,obj.Inputs(idx).arrayColumn) - 2.5) == obj.NextChronoSign, 1); - - %Ensure the clocking pulse was detected - if ~isempty(clockChangeIdx) - clockChangeTimestamp = event.TimeStamps(clockChangeIdx); - obj.CurrSysTimeTimelineOffset = obj.LastClockSentSysTime - clockChangeTimestamp; - else - warning('Rigging:Timeline:timing', 'clocking pulse not detected - probably lagging more than one data chunk'); + % CALL ONPROCESS METHODS HERE + for outidx = 1:numel(obj.Outputs) + obj.Outputs(outidx).onProcess(obj, event); end - - %Now send the next clock pulse - obj.NextChronoSign = -obj.NextChronoSign; % flip next chrono - t = GetSecs; % system time before output - outputSingleScan(obj.Sessions('chrono'), obj.NextChronoSign > 0); % send next chrono flip - obj.LastClockSentSysTime = (t + GetSecs)/2; % record mean before/after system time %%% Store new samples into the timeline array prevSampleCount = obj.Data.rawDAQSampleCount; @@ -624,8 +666,9 @@ function livePlot(obj, data) end end - % get the names of the inputs being recorded - names = pick({obj.Inputs.name}, find([obj.Inputs.arrayColumn] > -1), 'cell'); + % get the names of the inputs being recorded (in the correct + % order) + names = pick({obj.Inputs.name}, cellfun(@(x)find(strcmp({obj.Inputs.name}, x),1), obj.UseInputs), 'cell'); nSamps = size(data,1); % Get the number of samples in this chunck nChans = size(data,2); % Get the number of channels traceSep = 7; % unit is Volts - for most channels the max is 5V so this is a good separation @@ -651,8 +694,7 @@ function livePlot(obj, data) % get the measurement type of each channel, since Position-type % inputs are plotted differently. - meas = {obj.Inputs.measurement}; - meas = meas(ismember({obj.Inputs.name}, obj.UseInputs)); + meas = pick({obj.Inputs.measurement}, cellfun(@(x)find(strcmp({obj.Inputs.name}, x),1), obj.UseInputs), 'cell'); for t = 1:length(traces) if strcmp(meas{t}, 'Position') diff --git a/+hw/tlOutput.m b/+hw/tlOutput.m new file mode 100644 index 00000000..faf2b3d6 --- /dev/null +++ b/+hw/tlOutput.m @@ -0,0 +1,36 @@ +classdef tlOutput < matlab.mixin.Heterogeneous & handle + %hw.tlOutput Code to specify an output channel for timeline + % This is an abstract class. + % + % Below is a list of some subclasses and their functions: + % hw.tlOutputClock - clocked output on a counter channel + % hw.tlOutputChrono - the default, flip/flip status check output + % hw.tlOutputAcqLive - a digital channel that is on for the duration + % of the recording + % hw.tlOutputStartStopSync - a digital channel that turns on only at + % the beginning and end of the recording + % + % The timeline object will call the onLoad, onStart, and onStop + % methods. + % + % Part of Rigbox + + % 2018-01 NS created + + properties + name + session + end + + methods (Abstract) + onInit(obj, timeline) + onStart(obj, timeline) + onProcess(obj, timeline, event) + onStop(obj, timeline) + %s = propertiesAsStruct(obj) % recommend we have a method that does this, + % so that we can save out all the properties in a json file. Incl a + % version number? + end + +end + diff --git a/+hw/tlOutputAcqLive.m b/+hw/tlOutputAcqLive.m new file mode 100644 index 00000000..8c1492ad --- /dev/null +++ b/+hw/tlOutputAcqLive.m @@ -0,0 +1,52 @@ +classdef tlOutputAcqLive < hw.tlOutput + %hw.tlOutputAcqLive A digital signal that goes up when the recording starts, + % down when it ends. + % See also hw.tlOutput and hw.Timeline + % + % Part of Rigbox + % 2018-01 NS + + properties + daqDeviceID + daqChannelID + daqVendor = 'ni' + initialDelay = 0 + end + + methods + function obj = tlOutputAcqLive(name, daqDeviceID, daqChannelID) + obj.name = name; + obj.daqDeviceID = daqDeviceID; + obj.daqChannelID = daqChannelID; + end + + function onInit(obj, ~) + fprintf(1, 'initialize acqLive\n'); + obj.session = daq.createSession(obj.daqVendor); + obj.session.addDigitalChannel(obj.daqDeviceID, obj.daqChannelID, 'OutputOnly'); + outputSingleScan(obj.session, false); + end + + function onStart(obj, ~) + fprintf(1, 'start acqLive\n'); + + pause(obj.initialDelay); + outputSingleScan(obj.session, true); + + end + + function onProcess(~, ~, ~) + fprintf(1, 'process acqLive\n'); + + end + + function onStop(obj,~) + fprintf(1, 'stop chrono\n'); + stop(obj.session); + release(obj.session); + end + + end + +end + diff --git a/+hw/tlOutputChrono.m b/+hw/tlOutputChrono.m new file mode 100644 index 00000000..3aa0cc5d --- /dev/null +++ b/+hw/tlOutputChrono.m @@ -0,0 +1,89 @@ +classdef tlOutputChrono < hw.tlOutput + %hw.tlOutputChrono Timeline uses this to monitor that + % acquisition is proceeding normally during a recording. + % See also hw.tlOutput and hw.Timeline + % + % Part of Rigbox + % 2018-01 NS + + properties + daqDeviceID + daqChannelID + daqVendor = 'ni' + NextChronoSign = 1 % the value to output on the chrono channel, the sign is changed each 'Process' event + end + + methods + function obj = tlOutputChrono(name, daqDeviceID, daqChannelID) + obj.name = name; + obj.daqDeviceID = daqDeviceID; + obj.daqChannelID = daqChannelID; + end + + function onInit(obj, timeline) + fprintf(1, 'initialize chrono\n'); + obj.session = daq.createSession(obj.daqVendor); + obj.session.addDigitalChannel(obj.daqDeviceID, obj.daqChannelID, 'OutputOnly'); + + tls = timeline.getSessions('main'); + + %%Send a test pulse low, then high to clocking channel & check we read it back + idx = cellfun(@(s2)strcmp('chrono',s2), {timeline.Inputs.name}); + outputSingleScan(obj.session, false) + x1 = tls.inputSingleScan; + outputSingleScan(obj.session, true) + x2 = tls.inputSingleScan; + assert(x1(timeline.Inputs(idx).arrayColumn) < 2.5 && x2(timeline.Inputs(idx).arrayColumn) > 2.5,... + 'The clocking pulse test could not be read back'); + end + + function onStart(~, ~) + fprintf(1, 'start chrono\n'); + + end + + function onProcess(obj, timeline, event) + fprintf(1, 'process chrono\n'); + + % sign of the chrono signal is + % flipped on each call (at LastClockSentSysTime), and the + % time of the previous flip is found in the data and its + % timestamp noted. This is used by tl.time() to convert + % between system time and acquisition time. + % + % LastTimestamp is the time of the last scan in the previous + % data chunk, and is used to ensure no data samples have been + % lost. + + %%% The chrono "out" value is flipped at a recorded time, and + %%% the sample index that this flip is measured is noted + % First, find the index of the flip in the latest chunk of data + idx = elementByName(timeline.Inputs, 'chrono'); + clockChangeIdx = find(sign(event.Data(:,timeline.Inputs(idx).arrayColumn) - 2.5) == obj.NextChronoSign, 1); + + %Ensure the clocking pulse was detected + if ~isempty(clockChangeIdx) + clockChangeTimestamp = event.TimeStamps(clockChangeIdx); + timeline.CurrSysTimeTimelineOffset = timeline.LastClockSentSysTime - clockChangeTimestamp; + else + warning('Rigging:Timeline:timing', 'clocking pulse not detected - probably lagging more than one data chunk'); + end + + %Now send the next clock pulse + obj.NextChronoSign = -obj.NextChronoSign; % flip next chrono + t = GetSecs; % system time before output + outputSingleScan(obj.session, obj.NextChronoSign > 0); % send next chrono flip + timeline.LastClockSentSysTime = (t + GetSecs)/2; % record mean before/after system time + + end + + function onStop(obj,~) + fprintf(1, 'stop chrono\n'); + stop(obj.session); + release(obj.session); + end + + end + +end + From 92953eecdec7ff3c7aa39618d75937ad3c26f01c Mon Sep 17 00:00:00 2001 From: nsteinme Date: Thu, 25 Jan 2018 19:28:53 +0000 Subject: [PATCH 038/507] finished and tested outputs as classes --- +hw/Timeline.m | 101 +++--------------------------------- +hw/tlOutput.m | 2 +- +hw/tlOutputAcqLive.m | 3 +- +hw/tlOutputChrono.m | 1 + +hw/tlOutputClock.m | 60 +++++++++++++++++++++ +hw/tlOutputStartStopSync.m | 65 +++++++++++++++++++++++ 6 files changed, 137 insertions(+), 95 deletions(-) create mode 100644 +hw/tlOutputClock.m create mode 100644 +hw/tlOutputStartStopSync.m diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 0e69607a..4d0caf1a 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -70,29 +70,8 @@ DaqSampleRate = 1000 % rate at which daq aquires data in Hz, see Rate DaqSamplesPerNotify % determines the number of data samples to be processed each time, see Timeline.process(), constructor and NotifyWhenDataAvailableExceeds - Outputs % structure of outputs with their names and configuration information - % All outputs require the following fields: - % - name: a string identifier - % - type: one of the supported modes that we know how to - % handle, see below - % - params, the required parameters for the selected type - % - % Available types: - % - chrono: a default type that Timeline uses to monitor that - % acquisition is proceeding normally during a recording. - % Requires: daqChannelID - % - % - acqLive: a digital output that is turned on at the start - % of the timeline recording, turned off at the end - % Requires: daqChannelID, initialDelay - % - % - clock: a regular pulse at a specified frequency and duty - % cycle. Can be used to trigger camera frames, e.g. - % Requires: daqChannelID, initialDelay, frequency, dutyCycle - % - % - startStopSync: a brief pulse at the beginning and at the - % end of a recording. Can be used for synchronization - % - Requires: daqChannelID, initialDelay, pulseDuration + Outputs % array of output classes, defining any signals you desire to be sent from the daq. + % see hw.tlOutput, and, e.g. hw.tlOutputClock. Inputs = struct('name', 'chrono',... 'arrayColumn', -1,... % -1 is default indicating unused, this is update when the channels are added during tl.start() @@ -100,7 +79,6 @@ 'measurement', 'Voltage',... 'terminalConfig', 'SingleEnded') UseInputs = {'chrono'} % array of inputs to record while tl is running - %UseOutputs = {'chrono'} % array of output pulses to use while tl is running StopDelay = 2 % currently pauses for at least 2 secs as 'hack' before stopping main DAQ session MaxExpectedDuration = 2*60*60 % expected experiment time so data structure is initialised to sensible size (in secs) AquiredDataType = 'double' % default data type for the acquired data array (i.e. Data.rawDAQData) @@ -135,44 +113,14 @@ % Constructor method % Adds chrono, aquireLive and clock to the outputs list, % along with default ports and delays - obj.DaqSamplesPerNotify = 1/obj.SamplingInterval; % calculate DaqSamplesPerNotify - -% defaultOutputs = struct('name', 'type', 'params'); -% defaultOutputs(1).name = 'chrono'; -% defaultOutputs(1).type = 'chrono'; -% defaultOutputs(1).params.daqChannelID = 'port1/line0'; -% -% defaultOutputs(2).name = 'acqLive'; -% defaultOutputs(2).type = 'acqLive'; -% defaultOutputs(2).params.daqChannelID = 'port0/line1'; -% defaultOutputs(2).params.initialDelay = 0; -% -% defaultOutputs(3).name = 'clock'; -% defaultOutputs(3).type = 'clock'; -% defaultOutputs(3).params.daqChannelID = 'ctr3'; -% defaultOutputs(3).params.initialDelay = 0; -% defaultOutputs(3).params.frequency = 60; -% defaultOutputs(3).params.dutyCycle = 0.2; -% -% defaultOutputs(4).name = 'camSync'; -% defaultOutputs(4).type = 'startStopSync'; -% defaultOutputs(4).params.daqChannelID = 'port0/line3'; -% defaultOutputs(4).params.initialDelay = 0; -% defaultOutputs(4).params.pulseDuration = 0.2; - -% obj.Outputs = defaultOutputs; - + obj.DaqSamplesPerNotify = 1/obj.SamplingInterval; % calculate DaqSamplesPerNotify + obj.Outputs = hw.tlOutputChrono('chrono', obj.DaqIds, 'port0/line1'); if nargin % if old tl hardware struct provided, use these to populate properties obj.Inputs = hw.inputs; obj.DaqVendor = hw.daqVendor; obj.DaqIds = hw.daqDevice; obj.DaqSampleRate = hw.daqSampleRate; obj.DaqSamplesPerNotify = hw.daqSamplesPerNotify; -% obj.Outputs(1).daqChannelID = hw.chronoOutDaqChannelID; -% obj.Outputs(2).daqChannelID = hw.acqLiveDaqChannelID; -% obj.Outputs(3).daqChannelID = hw.clockOutputDaqChannelID; -% obj.ClockOutputFrequency = hw.clockOutputFrequency; -% obj.ClockOutputDutyCycle = hw.clockOutputDutyCycle; end end @@ -233,11 +181,6 @@ function start(obj, expRef, Alyx) obj.Outputs(outidx).onStart(obj); end -% if isKey(obj.Sessions, 'clock') % is the clock output being used? -% % start session to send timing output pulses -% startBackground(obj.Sessions('clock')); -% end - % Report success fprintf('Timeline started successfully for ''%s''.\n', expRef); end @@ -367,9 +310,10 @@ function wiringInfo(obj, name) fprintf('PFI4-7 = port1/line0-3\n') fprintf('ctr0-3 = port1/line0-3\n') else + outputClasses = arrayfun(@class, obj.Outputs, 'uni', false); if strcmp(name, 'chrono') % Chrono wiring info idI = cellfun(@(s2)strcmp('chrono',s2), {obj.Inputs.name}); - idO = cellfun(@(s2)strcmp('chrono',s2), {obj.Outputs.name}); + idO = find(cellfun(@(s2)strcmp('tlOutputChrono',s2), outputClasses),1); fprintf('Bridge terminals %s and %s\n',... obj.Outputs(idO).daqChannelID, obj.Inputs(idI).daqChannelID) elseif any(strcmp(name, {obj.Outputs.name})) % Output wiring info @@ -412,10 +356,8 @@ function stop(obj) warning('Nothing to do, Timeline is not running!') return end + % kill acquisition output signals -% if isKey(obj.Sessions, 'acqLive') -% outputSingleScan(obj.Sessions('acqLive'), false); % live -> false -% end for outidx = 1:numel(obj.Outputs) obj.Outputs(outidx).onStop(obj); end @@ -538,34 +480,7 @@ function init(obj) % TL.INIT() creates all the DAQ sessions % and stores them in the Sessions map by their Outputs name. % Also add a 'main' session to which all input channels are - % added. See daq.createSession - -% %%Create session objects for chrono and other outputs -% [use, idx] = intersect({obj.Outputs.name}, obj.UseOutputs); % find which outputs to use -% % assert(numel(idx) == numel(obj.UseOutputs), 'Not all outputs were recognised'); -% for i = 1:length(use) -% out = obj.Outputs(idx(i)); % get channel info, etc. -% switch use{i} -% case 'chrono' -% obj.Sessions('chrono') = daq.createSession(obj.DaqVendor); -% obj.Sessions('chrono').addDigitalChannel(obj.DaqIds, out.daqChannelID, out.type); -% -% case 'acqLive' -% obj.Sessions('acqLive') = daq.createSession(obj.DaqVendor); -% obj.Sessions('acqLive').addDigitalChannel(obj.DaqIds, out.daqChannelID, out.type); -% outputSingleScan(obj.Sessions('acqLive'), false); % ensure acq live is false -% -% case 'clock' -% obj.Sessions('clock') = daq.createSession(obj.DaqVendor); -% obj.Sessions('clock').IsContinuous = true; -% clocked = obj.Sessions('clock').addCounterOutputChannel(obj.DaqIds, out.daqChannelID, out.type); -% clocked.Frequency = obj.ClockOutputFrequency; -% clocked.DutyCycle = obj.ClockOutputDutyCycle; -% clocked.InitialDelay = out.delay; -% end -% end - - + % added. See daq.createSession %%Create channels for each input [use, idx] = intersect({obj.Inputs.name}, obj.UseInputs);% find which inputs to use diff --git a/+hw/tlOutput.m b/+hw/tlOutput.m index faf2b3d6..fdc2fc79 100644 --- a/+hw/tlOutput.m +++ b/+hw/tlOutput.m @@ -10,7 +10,7 @@ % hw.tlOutputStartStopSync - a digital channel that turns on only at % the beginning and end of the recording % - % The timeline object will call the onLoad, onStart, and onStop + % The timeline object will call the onInit, onStart, onProcess, and onStop % methods. % % Part of Rigbox diff --git a/+hw/tlOutputAcqLive.m b/+hw/tlOutputAcqLive.m index 8c1492ad..7a023147 100644 --- a/+hw/tlOutputAcqLive.m +++ b/+hw/tlOutputAcqLive.m @@ -41,9 +41,10 @@ function onProcess(~, ~, ~) end function onStop(obj,~) - fprintf(1, 'stop chrono\n'); + fprintf(1, 'stop acqLive\n'); stop(obj.session); release(obj.session); + obj.session = []; end end diff --git a/+hw/tlOutputChrono.m b/+hw/tlOutputChrono.m index 3aa0cc5d..4742afa1 100644 --- a/+hw/tlOutputChrono.m +++ b/+hw/tlOutputChrono.m @@ -81,6 +81,7 @@ function onStop(obj,~) fprintf(1, 'stop chrono\n'); stop(obj.session); release(obj.session); + obj.session = []; end end diff --git a/+hw/tlOutputClock.m b/+hw/tlOutputClock.m new file mode 100644 index 00000000..dc65a171 --- /dev/null +++ b/+hw/tlOutputClock.m @@ -0,0 +1,60 @@ +classdef tlOutputClock < hw.tlOutput + %hw.tlOutputClock A a regular pulse at a specified frequency and duty + % cycle. Can be used to trigger camera frames, e.g. + % See also hw.tlOutput and hw.Timeline + % + % Part of Rigbox + % 2018-01 NS + + properties + daqDeviceID + daqChannelID + daqVendor = 'ni' + initialDelay = 0 + frequency = 60; + dutyCycle = 0.2; + clockChan + end + + methods + function obj = tlOutputClock(name, daqDeviceID, daqChannelID) + obj.name = name; + obj.daqDeviceID = daqDeviceID; + obj.daqChannelID = daqChannelID; + end + + function onInit(obj, ~) + fprintf(1, 'initialize Clock\n'); + obj.session = daq.createSession(obj.daqVendor); + obj.session.IsContinuous = true; + clocked = obj.session.addCounterOutputChannel(obj.daqDeviceID, obj.daqChannelID, 'PulseGeneration'); + clocked.Frequency = obj.frequency; + clocked.DutyCycle = obj.dutyCycle; + clocked.InitialDelay = obj.initialDelay; + obj.clockChan = clocked; + + end + + function onStart(obj, ~) + fprintf(1, 'start Clock\n'); + + startBackground(obj.session); + end + + function onProcess(~, ~, ~) + fprintf(1, 'process Clock\n'); + + end + + function onStop(obj,~) + fprintf(1, 'stop Clock\n'); + + stop(obj.session); + release(obj.session); + obj.session = []; + end + + end + +end + diff --git a/+hw/tlOutputStartStopSync.m b/+hw/tlOutputStartStopSync.m new file mode 100644 index 00000000..577b942e --- /dev/null +++ b/+hw/tlOutputStartStopSync.m @@ -0,0 +1,65 @@ +classdef tlOutputStartStopSync < hw.tlOutput + %hw.tlOutputStartStopSync A digital signal that goes up when the recording starts, + % but just briefly, then down again at the end. + % See also hw.tlOutput and hw.Timeline + % + % Part of Rigbox + % 2018-01 NS + + properties + daqDeviceID + daqChannelID + daqVendor = 'ni' + initialDelay = 0 + pulseDuration = 0.2; + end + + methods + function obj = tlOutputStartStopSync(name, daqDeviceID, daqChannelID) + obj.name = name; + obj.daqDeviceID = daqDeviceID; + obj.daqChannelID = daqChannelID; + end + + function onInit(obj, ~) + fprintf(1, 'initialize StartStopSync\n'); + obj.session = daq.createSession(obj.daqVendor); + obj.session.addDigitalChannel(obj.daqDeviceID, obj.daqChannelID, 'OutputOnly'); + outputSingleScan(obj.session, false); % ensure that it starts down + % by the way, if you use this to control a light for + % synchronization, note that you can configure in nidaqMX a + % "default" value for the channel, so for example it will stay + % "false" at all times even if the computer reboots. + end + + function onStart(obj, ~) + fprintf(1, 'start StartStopSync\n'); + + pause(obj.initialDelay); + outputSingleScan(obj.session, true); + pause(obj.pulseDuration); + outputSingleScan(obj.session, false); + + end + + function onProcess(~, ~, ~) + fprintf(1, 'process StartStopSync\n'); + + end + + function onStop(obj,~) + fprintf(1, 'stop StartStopSync\n'); + + outputSingleScan(obj.session, true); + pause(obj.pulseDuration); + outputSingleScan(obj.session, false); + + stop(obj.session); + release(obj.session); + obj.session = []; + end + + end + +end + From de19f8a0ff94034dc1ea9e3d09d1249e564590c2 Mon Sep 17 00:00:00 2001 From: nsteinme Date: Fri, 26 Jan 2018 12:57:36 +0000 Subject: [PATCH 039/507] update output classes with toStr, enable, transients --- +hw/Timeline.m | 11 ++-- +hw/tlOutput.m | 21 +++--- +hw/tlOutputAcqLive.m | 52 +++++++++------ +hw/tlOutputChrono.m | 123 ++++++++++++++++++++---------------- +hw/tlOutputClock.m | 66 +++++++++++-------- +hw/tlOutputStartStopSync.m | 71 ++++++++++++--------- 6 files changed, 200 insertions(+), 144 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 4d0caf1a..2318ae54 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -178,7 +178,7 @@ function start(obj, expRef, Alyx) end for outidx = 1:numel(obj.Outputs) - obj.Outputs(outidx).onStart(obj); + obj.Outputs(outidx).start(obj); end % Report success @@ -359,7 +359,7 @@ function stop(obj) % kill acquisition output signals for outidx = 1:numel(obj.Outputs) - obj.Outputs(outidx).onStop(obj); + obj.Outputs(outidx).stop(obj); end pause(obj.StopDelay) @@ -512,7 +512,7 @@ function init(obj) % Initialize outputs for outidx = 1:numel(obj.Outputs) - obj.Outputs(outidx).onInit(obj); + obj.Outputs(outidx).init(obj); end end @@ -536,10 +536,9 @@ function process(obj, ~, event) assert(abs(event.TimeStamps(1) - obj.LastTimestamp - obj.SamplingInterval) < 1e-8,... 'Discontinuity of DAQ acquistion detected: last timestamp was %f and this one is %f',... obj.LastTimestamp, event.TimeStamps(1)); - - % CALL ONPROCESS METHODS HERE + for outidx = 1:numel(obj.Outputs) - obj.Outputs(outidx).onProcess(obj, event); + obj.Outputs(outidx).process(obj, event); end %%% Store new samples into the timeline array diff --git a/+hw/tlOutput.m b/+hw/tlOutput.m index fdc2fc79..e178fd9d 100644 --- a/+hw/tlOutput.m +++ b/+hw/tlOutput.m @@ -18,18 +18,21 @@ % 2018-01 NS created properties - name - session + name + enable = true % will not do anything with it unless this is true + verbose = false % output status updates. Initialization message outputs regardless of verbose. + end + + properties (Transient) + session end methods (Abstract) - onInit(obj, timeline) - onStart(obj, timeline) - onProcess(obj, timeline, event) - onStop(obj, timeline) - %s = propertiesAsStruct(obj) % recommend we have a method that does this, - % so that we can save out all the properties in a json file. Incl a - % version number? + init(obj, timeline) + start(obj, timeline) + process(obj, timeline, event) + stop(obj, timeline) + s = toStr(obj) % a string that describes the object succintly end end diff --git a/+hw/tlOutputAcqLive.m b/+hw/tlOutputAcqLive.m index 7a023147..a8fae92a 100644 --- a/+hw/tlOutputAcqLive.m +++ b/+hw/tlOutputAcqLive.m @@ -20,31 +20,45 @@ obj.daqChannelID = daqChannelID; end - function onInit(obj, ~) - fprintf(1, 'initialize acqLive\n'); - obj.session = daq.createSession(obj.daqVendor); - obj.session.addDigitalChannel(obj.daqDeviceID, obj.daqChannelID, 'OutputOnly'); - outputSingleScan(obj.session, false); + function init(obj, ~) + if obj.enable + fprintf(1, 'initializing %s\n', obj.toStr); + obj.session = daq.createSession(obj.daqVendor); + obj.session.addDigitalChannel(obj.daqDeviceID, obj.daqChannelID, 'OutputOnly'); + outputSingleScan(obj.session, false); + end end - function onStart(obj, ~) - fprintf(1, 'start acqLive\n'); - - pause(obj.initialDelay); - outputSingleScan(obj.session, true); - + function start(obj, ~) + if obj.enable + if obj.verbose + fprintf(1, 'start %s\n', obj.name); + end + + pause(obj.initialDelay); + outputSingleScan(obj.session, true); + end end - function onProcess(~, ~, ~) - fprintf(1, 'process acqLive\n'); - + function process(~, ~, ~) + %fprintf(1, 'process acqLive\n'); + % -- pass end - function onStop(obj,~) - fprintf(1, 'stop acqLive\n'); - stop(obj.session); - release(obj.session); - obj.session = []; + function stop(obj,~) + if obj.enable + if obj.verbose + fprintf(1, 'stop %s\n', obj.name); + end + stop(obj.session); + release(obj.session); + obj.session = []; + end + end + + function s = toStr(obj) + s = sprintf('"%s" on %s/%s (acqLive, initial delay %.2f)', obj.name, ... + obj.daqDeviceID, obj.daqChannelID, obj.initialDelay); end end diff --git a/+hw/tlOutputChrono.m b/+hw/tlOutputChrono.m index 4742afa1..ad16ff3c 100644 --- a/+hw/tlOutputChrono.m +++ b/+hw/tlOutputChrono.m @@ -20,68 +20,81 @@ obj.daqChannelID = daqChannelID; end - function onInit(obj, timeline) - fprintf(1, 'initialize chrono\n'); - obj.session = daq.createSession(obj.daqVendor); - obj.session.addDigitalChannel(obj.daqDeviceID, obj.daqChannelID, 'OutputOnly'); - - tls = timeline.getSessions('main'); - - %%Send a test pulse low, then high to clocking channel & check we read it back - idx = cellfun(@(s2)strcmp('chrono',s2), {timeline.Inputs.name}); - outputSingleScan(obj.session, false) - x1 = tls.inputSingleScan; - outputSingleScan(obj.session, true) - x2 = tls.inputSingleScan; - assert(x1(timeline.Inputs(idx).arrayColumn) < 2.5 && x2(timeline.Inputs(idx).arrayColumn) > 2.5,... - 'The clocking pulse test could not be read back'); + function init(obj, timeline) + if obj.enable + fprintf(1, 'initializing %s\n', obj.toStr); + obj.session = daq.createSession(obj.daqVendor); + obj.session.addDigitalChannel(obj.daqDeviceID, obj.daqChannelID, 'OutputOnly'); + + tls = timeline.getSessions('main'); + + %%Send a test pulse low, then high to clocking channel & check we read it back + idx = cellfun(@(s2)strcmp('chrono',s2), {timeline.Inputs.name}); + outputSingleScan(obj.session, false) + x1 = tls.inputSingleScan; + outputSingleScan(obj.session, true) + x2 = tls.inputSingleScan; + assert(x1(timeline.Inputs(idx).arrayColumn) < 2.5 && x2(timeline.Inputs(idx).arrayColumn) > 2.5,... + 'The clocking pulse test could not be read back'); + end + end + + function start(~, ~) + %fprintf(1, 'start chrono\n'); + % -- pass end - function onStart(~, ~) - fprintf(1, 'start chrono\n'); - + function process(obj, timeline, event) + if obj.enable + if obj.verbose + fprintf(1, 'process %s\n', obj.name); + end + % sign of the chrono signal is + % flipped on each call (at LastClockSentSysTime), and the + % time of the previous flip is found in the data and its + % timestamp noted. This is used by tl.time() to convert + % between system time and acquisition time. + % + % LastTimestamp is the time of the last scan in the previous + % data chunk, and is used to ensure no data samples have been + % lost. + + %%% The chrono "out" value is flipped at a recorded time, and + %%% the sample index that this flip is measured is noted + % First, find the index of the flip in the latest chunk of data + idx = elementByName(timeline.Inputs, 'chrono'); + clockChangeIdx = find(sign(event.Data(:,timeline.Inputs(idx).arrayColumn) - 2.5) == obj.NextChronoSign, 1); + + %Ensure the clocking pulse was detected + if ~isempty(clockChangeIdx) + clockChangeTimestamp = event.TimeStamps(clockChangeIdx); + timeline.CurrSysTimeTimelineOffset = timeline.LastClockSentSysTime - clockChangeTimestamp; + else + warning('Rigging:Timeline:timing', 'clocking pulse not detected - probably lagging more than one data chunk'); + end + + %Now send the next clock pulse + obj.NextChronoSign = -obj.NextChronoSign; % flip next chrono + t = GetSecs; % system time before output + outputSingleScan(obj.session, obj.NextChronoSign > 0); % send next chrono flip + timeline.LastClockSentSysTime = (t + GetSecs)/2; % record mean before/after system time + end end - function onProcess(obj, timeline, event) - fprintf(1, 'process chrono\n'); - - % sign of the chrono signal is - % flipped on each call (at LastClockSentSysTime), and the - % time of the previous flip is found in the data and its - % timestamp noted. This is used by tl.time() to convert - % between system time and acquisition time. - % - % LastTimestamp is the time of the last scan in the previous - % data chunk, and is used to ensure no data samples have been - % lost. - - %%% The chrono "out" value is flipped at a recorded time, and - %%% the sample index that this flip is measured is noted - % First, find the index of the flip in the latest chunk of data - idx = elementByName(timeline.Inputs, 'chrono'); - clockChangeIdx = find(sign(event.Data(:,timeline.Inputs(idx).arrayColumn) - 2.5) == obj.NextChronoSign, 1); - - %Ensure the clocking pulse was detected - if ~isempty(clockChangeIdx) - clockChangeTimestamp = event.TimeStamps(clockChangeIdx); - timeline.CurrSysTimeTimelineOffset = timeline.LastClockSentSysTime - clockChangeTimestamp; - else - warning('Rigging:Timeline:timing', 'clocking pulse not detected - probably lagging more than one data chunk'); + function stop(obj,~) + if enable + if obj.verbose + fprintf(1, 'stop %s\n', obj.name); + end + stop(obj.session); + release(obj.session); + obj.session = []; end - - %Now send the next clock pulse - obj.NextChronoSign = -obj.NextChronoSign; % flip next chrono - t = GetSecs; % system time before output - outputSingleScan(obj.session, obj.NextChronoSign > 0); % send next chrono flip - timeline.LastClockSentSysTime = (t + GetSecs)/2; % record mean before/after system time - end - function onStop(obj,~) - fprintf(1, 'stop chrono\n'); - stop(obj.session); - release(obj.session); - obj.session = []; + function s = toStr(obj) + s = sprintf('"%s" on %s/%s (chrono)', obj.name, ... + obj.daqDeviceID, obj.daqChannelID); end end diff --git a/+hw/tlOutputClock.m b/+hw/tlOutputClock.m index dc65a171..f5e206fb 100644 --- a/+hw/tlOutputClock.m +++ b/+hw/tlOutputClock.m @@ -12,8 +12,11 @@ daqVendor = 'ni' initialDelay = 0 frequency = 60; - dutyCycle = 0.2; - clockChan + dutyCycle = 0.2; + end + + properties (Transient) + clockChan end methods @@ -23,37 +26,50 @@ obj.daqChannelID = daqChannelID; end - function onInit(obj, ~) - fprintf(1, 'initialize Clock\n'); - obj.session = daq.createSession(obj.daqVendor); - obj.session.IsContinuous = true; - clocked = obj.session.addCounterOutputChannel(obj.daqDeviceID, obj.daqChannelID, 'PulseGeneration'); - clocked.Frequency = obj.frequency; - clocked.DutyCycle = obj.dutyCycle; - clocked.InitialDelay = obj.initialDelay; - obj.clockChan = clocked; - + function init(obj, ~) + if obj.enable + fprintf(1, 'initializing %s\n', obj.toStr); + + obj.session = daq.createSession(obj.daqVendor); + obj.session.IsContinuous = true; + clocked = obj.session.addCounterOutputChannel(obj.daqDeviceID, obj.daqChannelID, 'PulseGeneration'); + clocked.Frequency = obj.frequency; + clocked.DutyCycle = obj.dutyCycle; + clocked.InitialDelay = obj.initialDelay; + obj.clockChan = clocked; + end end - function onStart(obj, ~) - fprintf(1, 'start Clock\n'); - - startBackground(obj.session); + function start(obj, ~) + if obj.enable + if obj.verbose + fprintf(1, 'start %s\n', obj.name); + end + startBackground(obj.session); + end end - function onProcess(~, ~, ~) - fprintf(1, 'process Clock\n'); - + function process(~, ~, ~) + %fprintf(1, 'process Clock\n'); + % -- pass end - function onStop(obj,~) - fprintf(1, 'stop Clock\n'); - - stop(obj.session); - release(obj.session); - obj.session = []; + function stop(obj,~) + if obj.enable + if obj.verbose + fprintf(1, 'stop %s\n', obj.name); + end + + stop(obj.session); + release(obj.session); + obj.session = []; + end end + function s = toStr(obj) + s = sprintf('"%s" on %s/%s (clock, %dHz, %.2f duty cycle)', obj.name, ... + obj.daqDeviceID, obj.daqChannelID, obj.frequency, obj.dutyCycle); + end end end diff --git a/+hw/tlOutputStartStopSync.m b/+hw/tlOutputStartStopSync.m index 577b942e..3222ab46 100644 --- a/+hw/tlOutputStartStopSync.m +++ b/+hw/tlOutputStartStopSync.m @@ -21,42 +21,53 @@ obj.daqChannelID = daqChannelID; end - function onInit(obj, ~) - fprintf(1, 'initialize StartStopSync\n'); - obj.session = daq.createSession(obj.daqVendor); - obj.session.addDigitalChannel(obj.daqDeviceID, obj.daqChannelID, 'OutputOnly'); - outputSingleScan(obj.session, false); % ensure that it starts down - % by the way, if you use this to control a light for - % synchronization, note that you can configure in nidaqMX a - % "default" value for the channel, so for example it will stay - % "false" at all times even if the computer reboots. + function init(obj, ~) + if obj.enable + fprintf(1, 'initializing %s\n', obj.toStr); + obj.session = daq.createSession(obj.daqVendor); + obj.session.addDigitalChannel(obj.daqDeviceID, obj.daqChannelID, 'OutputOnly'); + outputSingleScan(obj.session, false); % ensure that it starts down + % by the way, if you use this to control a light for + % synchronization, note that you can configure in nidaqMX a + % "default" value for the channel, so for example it will stay + % "false" at all times even if the computer reboots. + end end - function onStart(obj, ~) - fprintf(1, 'start StartStopSync\n'); - - pause(obj.initialDelay); - outputSingleScan(obj.session, true); - pause(obj.pulseDuration); - outputSingleScan(obj.session, false); - + function start(obj, ~) + if obj.enable + if obj.verbose + fprintf(1, 'start %s\n', obj.name); + end + pause(obj.initialDelay); + outputSingleScan(obj.session, true); + pause(obj.pulseDuration); + outputSingleScan(obj.session, false); + end end - function onProcess(~, ~, ~) - fprintf(1, 'process StartStopSync\n'); - + function process(~, ~, ~) + %fprintf(1, 'process StartStopSync\n'); + % -- pass end - function onStop(obj,~) - fprintf(1, 'stop StartStopSync\n'); - - outputSingleScan(obj.session, true); - pause(obj.pulseDuration); - outputSingleScan(obj.session, false); - - stop(obj.session); - release(obj.session); - obj.session = []; + function stop(obj,~) + if obj.enable + fprintf(1, 'stop %s\n', obj.name); + + outputSingleScan(obj.session, true); + pause(obj.pulseDuration); + outputSingleScan(obj.session, false); + + stop(obj.session); + release(obj.session); + obj.session = []; + end + end + + function s = toStr(obj) + s = sprintf('"%s" on %s/%s (StartStopSync, pulse duration %.2f)', obj.name, ... + obj.daqDeviceID, obj.daqChannelID, obj.pulseDuration); end end From 8b36d7cf5bcabbd2f6e820a75a8a0f448bca3dd5 Mon Sep 17 00:00:00 2001 From: nsteinme Date: Fri, 26 Jan 2018 13:44:55 +0000 Subject: [PATCH 040/507] add saving out Outputs as struct --- +hw/Timeline.m | 17 +++++++++++++---- +hw/tlOutputChrono.m | 15 ++++++++++----- +hw/tlOutputStartStopSync.m | 4 +++- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 2318ae54..25c0781f 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -70,7 +70,8 @@ DaqSampleRate = 1000 % rate at which daq aquires data in Hz, see Rate DaqSamplesPerNotify % determines the number of data samples to be processed each time, see Timeline.process(), constructor and NotifyWhenDataAvailableExceeds - Outputs % array of output classes, defining any signals you desire to be sent from the daq. + Outputs = hw.tlOutputChrono('chrono', 'Dev1', 'port0/line1') + % array of output classes, defining any signals you desire to be sent from the daq. % see hw.tlOutput, and, e.g. hw.tlOutputClock. Inputs = struct('name', 'chrono',... @@ -79,6 +80,7 @@ 'measurement', 'Voltage',... 'terminalConfig', 'SingleEnded') UseInputs = {'chrono'} % array of inputs to record while tl is running + StopDelay = 2 % currently pauses for at least 2 secs as 'hack' before stopping main DAQ session MaxExpectedDuration = 2*60*60 % expected experiment time so data structure is initialised to sensible size (in secs) AquiredDataType = 'double' % default data type for the acquired data array (i.e. Data.rawDAQData) @@ -86,8 +88,11 @@ LivePlot = false % if true the data are plotted as the data are aquired LivePlotParams = []; WriteBufferToDisk = false % if true the data buffer is written to disk as they're aquired NB: in the future this will happen by default - - % moved these here so chrono class can access - NS + + end + + properties (Transient) + % moved these here (i.e. unprotected) so chrono class can access - NS CurrSysTimeTimelineOffset % difference between the system time when the last chrono flip occured and the timestamp recorded by the DAQ, see tl.process() LastTimestamp % the last timestamp returned from the daq during the DataAvailable event. Used to check sampling continuity, see tl.process() LastClockSentSysTime % the mean of the system time before and after the last chrono flip. Used to calculate CurrSysTimeTimelineOffset, see tl.process() @@ -114,7 +119,6 @@ % Adds chrono, aquireLive and clock to the outputs list, % along with default ports and delays obj.DaqSamplesPerNotify = 1/obj.SamplingInterval; % calculate DaqSamplesPerNotify - obj.Outputs = hw.tlOutputChrono('chrono', obj.DaqIds, 'port0/line1'); if nargin % if old tl hardware struct provided, use these to populate properties obj.Inputs = hw.inputs; obj.DaqVendor = hw.daqVendor; @@ -122,6 +126,7 @@ obj.DaqSampleRate = hw.daqSampleRate; obj.DaqSamplesPerNotify = hw.daqSamplesPerNotify; end + end function start(obj, expRef, Alyx) @@ -417,6 +422,10 @@ function stop(obj) obj.Data.lastClockSentSysTime = obj.LastClockSentSysTime; obj.Data.currSysTimeTimelineOffset = obj.CurrSysTimeTimelineOffset; + for outIdx = 1:numel(obj.Outputs) + obj.Data.hw.Outputs{outIdx} = struct(obj.Outputs(outIdx)); + end + % save tl to all paths superSave(obj.Data.savePaths, struct('Timeline', obj.Data)); diff --git a/+hw/tlOutputChrono.m b/+hw/tlOutputChrono.m index ad16ff3c..766a6f39 100644 --- a/+hw/tlOutputChrono.m +++ b/+hw/tlOutputChrono.m @@ -39,13 +39,18 @@ function init(obj, timeline) end end - function start(~, ~) - %fprintf(1, 'start chrono\n'); - % -- pass + function start(obj, ~) + if obj.enable + if obj.verbose + fprintf(1, 'start %s\n', obj.name); + end + + outputSingleScan(obj.session, false) % this will be the clocking pulse detected the first time process is called + end end function process(obj, timeline, event) - if obj.enable + if obj.enable && timeline.IsRunning && ~isempty(obj.session) if obj.verbose fprintf(1, 'process %s\n', obj.name); end @@ -82,7 +87,7 @@ function process(obj, timeline, event) end function stop(obj,~) - if enable + if obj.enable if obj.verbose fprintf(1, 'stop %s\n', obj.name); end diff --git a/+hw/tlOutputStartStopSync.m b/+hw/tlOutputStartStopSync.m index 3222ab46..15b975ff 100644 --- a/+hw/tlOutputStartStopSync.m +++ b/+hw/tlOutputStartStopSync.m @@ -53,7 +53,9 @@ function process(~, ~, ~) function stop(obj,~) if obj.enable - fprintf(1, 'stop %s\n', obj.name); + if obj.verbose + fprintf(1, 'stop %s\n', obj.name); + end outputSingleScan(obj.session, true); pause(obj.pulseDuration); From dbbcd8a7207ddeb0317f8728bde11f12efd4c299 Mon Sep 17 00:00:00 2001 From: ArminLak Date: Mon, 29 Jan 2018 11:46:47 +0000 Subject: [PATCH 041/507] Updated paths server 1 now 'zubjects' --- +dat/paths.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/+dat/paths.m b/+dat/paths.m index 9d69d38e..94c564f8 100644 --- a/+dat/paths.m +++ b/+dat/paths.m @@ -13,7 +13,7 @@ rig = thishost; end -server1Name = '\\zserver.cortexlab.net'; +server1Name = '\\zubjects.cortexlab.net'; % server2Name = '\\zserver2.cortexlab.net'; % server3Name = '\\zserver3.cortexlab.net'; % 2017-02-18 MW - Currently % unused by Rigbox @@ -29,7 +29,7 @@ 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'); +p.mainRepository = fullfile(server1Name, 'Subjects'); % Repository for info about experiments, i.e. stimulus, behavioural, % Timeline etc p.expInfoRepository = p.mainRepository; From bed3a79eb67edc4836687fdfbc09001f1ef2322e Mon Sep 17 00:00:00 2001 From: ArminLak Date: Mon, 29 Jan 2018 11:49:36 +0000 Subject: [PATCH 042/507] Changed dat.paths server 1 name now 'zubjects' --- +dat/paths.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/+dat/paths.m b/+dat/paths.m index 222cbf5e..a9bb73fe 100644 --- a/+dat/paths.m +++ b/+dat/paths.m @@ -13,7 +13,7 @@ rig = thishost; end -server1Name = '\\zserver.cortexlab.net'; +server1Name = '\\zubjects.cortexlab.net'; % server2Name = '\\zserver2.cortexlab.net'; % server3Name = '\\zserver3.cortexlab.net'; % 2017-02-18 MW - Currently % unused by Rigbox @@ -27,7 +27,7 @@ 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'); +p.mainRepository = fullfile(server1Name, 'Subjects'); % Repository for info about experiments, i.e. stimulus, behavioural, % Timeline etc p.expInfoRepository = p.mainRepository; From 64dc8d462a070c917d8be4e9ef8659ed832a388f Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 29 Jan 2018 11:59:14 +0000 Subject: [PATCH 043/507] new paths --- +dat/paths.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/+dat/paths.m b/+dat/paths.m index 9d69d38e..94c564f8 100644 --- a/+dat/paths.m +++ b/+dat/paths.m @@ -13,7 +13,7 @@ rig = thishost; end -server1Name = '\\zserver.cortexlab.net'; +server1Name = '\\zubjects.cortexlab.net'; % server2Name = '\\zserver2.cortexlab.net'; % server3Name = '\\zserver3.cortexlab.net'; % 2017-02-18 MW - Currently % unused by Rigbox @@ -29,7 +29,7 @@ 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'); +p.mainRepository = fullfile(server1Name, 'Subjects'); % Repository for info about experiments, i.e. stimulus, behavioural, % Timeline etc p.expInfoRepository = p.mainRepository; From a9afa12ee75244fce0cb98094985809b5ed02360 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 29 Jan 2018 12:51:56 +0000 Subject: [PATCH 044/507] fix timeline synchronization with expServer --- +hw/Timeline.m | 8 ++++---- +hw/tlOutputChrono.m | 17 +++++++++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 25c0781f..de73332b 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -93,7 +93,7 @@ properties (Transient) % moved these here (i.e. unprotected) so chrono class can access - NS - CurrSysTimeTimelineOffset % difference between the system time when the last chrono flip occured and the timestamp recorded by the DAQ, see tl.process() + CurrSysTimeTimelineOffset = 0 % difference between the system time when the last chrono flip occured and the timestamp recorded by the DAQ, see tl.process() LastTimestamp % the last timestamp returned from the daq during the DataAvailable event. Used to check sampling continuity, see tl.process() LastClockSentSysTime % the mean of the system time before and after the last chrono flip. Used to calculate CurrSysTimeTimelineOffset, see tl.process() end @@ -422,9 +422,9 @@ function stop(obj) obj.Data.lastClockSentSysTime = obj.LastClockSentSysTime; obj.Data.currSysTimeTimelineOffset = obj.CurrSysTimeTimelineOffset; - for outIdx = 1:numel(obj.Outputs) - obj.Data.hw.Outputs{outIdx} = struct(obj.Outputs(outIdx)); - end +% for outIdx = 1:numel(obj.Outputs) +% obj.Data.hw.Outputs{outIdx} = struct(obj.Outputs(outIdx)); +% end % save tl to all paths superSave(obj.Data.savePaths, struct('Timeline', obj.Data)); diff --git a/+hw/tlOutputChrono.m b/+hw/tlOutputChrono.m index 766a6f39..e98d74a0 100644 --- a/+hw/tlOutputChrono.m +++ b/+hw/tlOutputChrono.m @@ -36,16 +36,19 @@ function init(obj, timeline) x2 = tls.inputSingleScan; assert(x1(timeline.Inputs(idx).arrayColumn) < 2.5 && x2(timeline.Inputs(idx).arrayColumn) > 2.5,... 'The clocking pulse test could not be read back'); + + timeline.CurrSysTimeTimelineOffset = GetSecs; end end - function start(obj, ~) + function start(obj, timeline) if obj.enable if obj.verbose fprintf(1, 'start %s\n', obj.name); end - + t = GetSecs; % system time before output outputSingleScan(obj.session, false) % this will be the clocking pulse detected the first time process is called + timeline.LastClockSentSysTime = (t + GetSecs)/2; end end @@ -70,6 +73,11 @@ function process(obj, timeline, event) idx = elementByName(timeline.Inputs, 'chrono'); clockChangeIdx = find(sign(event.Data(:,timeline.Inputs(idx).arrayColumn) - 2.5) == obj.NextChronoSign, 1); + if obj.verbose + fprintf(1, ' CurrOffset=%.2f, LastClock=%.2f\n', ... + timeline.CurrSysTimeTimelineOffset, timeline.LastClockSentSysTime); + end + %Ensure the clocking pulse was detected if ~isempty(clockChangeIdx) clockChangeTimestamp = event.TimeStamps(clockChangeIdx); @@ -83,6 +91,11 @@ function process(obj, timeline, event) t = GetSecs; % system time before output outputSingleScan(obj.session, obj.NextChronoSign > 0); % send next chrono flip timeline.LastClockSentSysTime = (t + GetSecs)/2; % record mean before/after system time + if obj.verbose + fprintf(1, ' CurrOffset=%.2f, LastClock=%.2f\n', ... + timeline.CurrSysTimeTimelineOffset, timeline.LastClockSentSysTime); + end + end end From 405f2ba10bddff4b6de321a067e5cabe061eea24 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 29 Jan 2018 13:32:33 +0000 Subject: [PATCH 045/507] Fix'd MPEP UDP Listeners error --- cortexlab/+io/MpepUDPDataHosts.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cortexlab/+io/MpepUDPDataHosts.m b/cortexlab/+io/MpepUDPDataHosts.m index 821b215f..0c239943 100644 --- a/cortexlab/+io/MpepUDPDataHosts.m +++ b/cortexlab/+io/MpepUDPDataHosts.m @@ -204,12 +204,12 @@ function expEnded(obj) function start(obj, ref) [expRef, ai] = dat.parseAlyxInstance(ref); obj.AlyxInstance = ai; - [subject, seriesNum, expNum] = dat.expRefToMpep(obj.ExpRef); + [subject, seriesNum, expNum] = dat.expRefToMpep(expRef); alyxmsg = sprintf('alyx %s %d %d %s', subject, seriesNum, expNum, ref); confirmedBroadcast(obj, alyxmsg); + % equivalent to startExp(expRef) expStarted(obj, expRef); - end function stop(obj) From de92c0cac77b0dd75c2f7c2797ddbbf010288a61 Mon Sep 17 00:00:00 2001 From: nsteinme Date: Mon, 29 Jan 2018 14:06:53 +0000 Subject: [PATCH 046/507] add lots of comments --- +hw/Timeline.m | 7 ++++++- +hw/tlOutput.m | 14 +++++++------- +hw/tlOutputAcqLive.m | 12 ++++++++---- +hw/tlOutputChrono.m | 14 ++++++++++---- +hw/tlOutputClock.m | 10 +++++++--- +hw/tlOutputStartStopSync.m | 8 ++++++-- 6 files changed, 44 insertions(+), 21 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index ed705fea..d259c25c 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -182,6 +182,7 @@ function start(obj, expRef, Alyx) pause(5e-3); end + % Start each output for outidx = 1:numel(obj.Outputs) obj.Outputs(outidx).start(obj); end @@ -389,7 +390,8 @@ function stop(obj) inputsIdx = cellfun(@(x)find(strcmp({obj.Inputs.name}, x),1), obj.UseInputs); % this block finds the daqChannelID for chrono and acqLive if - % they exist + % they exist, plus some clock parameters - all for legacy + % metadata saving, see below outputClasses = arrayfun(@class, obj.Outputs, 'uni', false); chronoChan = []; nextChrono = []; acqLiveChan = []; useClock = false; clockF = []; clockD = []; chronoOutputIdx = find(strcmp(outputClasses, 'hw.tlOutputChrono'),1); @@ -408,6 +410,7 @@ function stop(obj) clockD = obj.Outputs(clockOutputIdx).dutyCycle; end + % legacy metadata obj.Data.hw = struct('daqVendor', obj.DaqVendor, 'daqDevice', obj.DaqIds,... 'daqSampleRate', obj.DaqSampleRate, 'daqSamplesPerNotify', obj.DaqSamplesPerNotify,... 'chronoOutDaqChannelID', chronoChan, 'acqLiveOutDaqChannelID', acqLiveChan,... @@ -422,6 +425,7 @@ function stop(obj) obj.Data.lastClockSentSysTime = obj.LastClockSentSysTime; obj.Data.currSysTimeTimelineOffset = obj.CurrSysTimeTimelineOffset; + % saving hardware metadata for each output % for outIdx = 1:numel(obj.Outputs) % obj.Data.hw.Outputs{outIdx} = struct(obj.Outputs(outIdx)); % end @@ -547,6 +551,7 @@ function process(obj, ~, event) 'Discontinuity of DAQ acquistion detected: last timestamp was %f and this one is %f',... obj.LastTimestamp, event.TimeStamps(1)); + % process methods for outputs for outidx = 1:numel(obj.Outputs) obj.Outputs(outidx).process(obj, event); end diff --git a/+hw/tlOutput.m b/+hw/tlOutput.m index e178fd9d..697b2041 100644 --- a/+hw/tlOutput.m +++ b/+hw/tlOutput.m @@ -4,13 +4,13 @@ % % Below is a list of some subclasses and their functions: % hw.tlOutputClock - clocked output on a counter channel - % hw.tlOutputChrono - the default, flip/flip status check output + % hw.tlOutputChrono - the default, flip/flop status check output % hw.tlOutputAcqLive - a digital channel that is on for the duration % of the recording % hw.tlOutputStartStopSync - a digital channel that turns on only at % the beginning and end of the recording % - % The timeline object will call the onInit, onStart, onProcess, and onStop + % The timeline object will call the init, start, process, and stop % methods. % % Part of Rigbox @@ -18,7 +18,7 @@ % 2018-01 NS created properties - name + name % user choice, text enable = true % will not do anything with it unless this is true verbose = false % output status updates. Initialization message outputs regardless of verbose. end @@ -28,10 +28,10 @@ end methods (Abstract) - init(obj, timeline) - start(obj, timeline) - process(obj, timeline, event) - stop(obj, timeline) + init(obj, timeline) % called when timeline is initialized (see hw.Timeline/init), e.g. to open daq session and set parameters + start(obj, timeline) % called when timeline is started (see hw.Timeline/start), e.g. to start outputs + process(obj, timeline, event) % called every time Timeline processes a chunk of data, in case output needs to react to it + stop(obj, timeline) % called when timeline is stopped (see hw.Timeline/stop), to close and clean up s = toStr(obj) % a string that describes the object succintly end diff --git a/+hw/tlOutputAcqLive.m b/+hw/tlOutputAcqLive.m index a8fae92a..089e1710 100644 --- a/+hw/tlOutputAcqLive.m +++ b/+hw/tlOutputAcqLive.m @@ -10,7 +10,7 @@ daqDeviceID daqChannelID daqVendor = 'ni' - initialDelay = 0 + initialDelay = 0 % sec, time to wait before starting end methods @@ -21,31 +21,35 @@ end function init(obj, ~) + % called when timeline is initialized (see hw.Timeline/init) if obj.enable fprintf(1, 'initializing %s\n', obj.toStr); obj.session = daq.createSession(obj.daqVendor); obj.session.addDigitalChannel(obj.daqDeviceID, obj.daqChannelID, 'OutputOnly'); - outputSingleScan(obj.session, false); + outputSingleScan(obj.session, false); % start in the off/false state end end function start(obj, ~) + % called when timeline is started (see hw.Timeline/start) if obj.enable if obj.verbose fprintf(1, 'start %s\n', obj.name); end - pause(obj.initialDelay); - outputSingleScan(obj.session, true); + pause(obj.initialDelay); % wait for some duration before starting + outputSingleScan(obj.session, true); % set digital output true: acquisition is "live" end end function process(~, ~, ~) + % called every time Timeline processes a chunk of data %fprintf(1, 'process acqLive\n'); % -- pass end function stop(obj,~) + % called when timeline is stopped (see hw.Timeline/stop) if obj.enable if obj.verbose fprintf(1, 'stop %s\n', obj.name); diff --git a/+hw/tlOutputChrono.m b/+hw/tlOutputChrono.m index e98d74a0..f0c9f230 100644 --- a/+hw/tlOutputChrono.m +++ b/+hw/tlOutputChrono.m @@ -1,6 +1,8 @@ classdef tlOutputChrono < hw.tlOutput %hw.tlOutputChrono Timeline uses this to monitor that - % acquisition is proceeding normally during a recording. + % acquisition is proceeding normally during a recording and to update + % the synchronization between the system time and the timeline time (to + % prevent drift between daq and computer clock). % See also hw.tlOutput and hw.Timeline % % Part of Rigbox @@ -21,6 +23,7 @@ end function init(obj, timeline) + % called when timeline is initialized (see hw.Timeline/init) if obj.enable fprintf(1, 'initializing %s\n', obj.toStr); obj.session = daq.createSession(obj.daqVendor); @@ -37,22 +40,24 @@ function init(obj, timeline) assert(x1(timeline.Inputs(idx).arrayColumn) < 2.5 && x2(timeline.Inputs(idx).arrayColumn) > 2.5,... 'The clocking pulse test could not be read back'); - timeline.CurrSysTimeTimelineOffset = GetSecs; + timeline.CurrSysTimeTimelineOffset = GetSecs; % to initialize this, will be a bit off but fixed after the first pulse end end - function start(obj, timeline) + function start(obj, timeline) + % called when timeline is started (see hw.Timeline/start) if obj.enable if obj.verbose fprintf(1, 'start %s\n', obj.name); end t = GetSecs; % system time before output outputSingleScan(obj.session, false) % this will be the clocking pulse detected the first time process is called - timeline.LastClockSentSysTime = (t + GetSecs)/2; + timeline.LastClockSentSysTime = (t + GetSecs)/2; end end function process(obj, timeline, event) + % called every time Timeline processes a chunk of data if obj.enable && timeline.IsRunning && ~isempty(obj.session) if obj.verbose fprintf(1, 'process %s\n', obj.name); @@ -100,6 +105,7 @@ function process(obj, timeline, event) end function stop(obj,~) + % called when timeline is stopped (see hw.Timeline/stop) if obj.enable if obj.verbose fprintf(1, 'stop %s\n', obj.name); diff --git a/+hw/tlOutputClock.m b/+hw/tlOutputClock.m index f5e206fb..6c26685b 100644 --- a/+hw/tlOutputClock.m +++ b/+hw/tlOutputClock.m @@ -10,9 +10,9 @@ daqDeviceID daqChannelID daqVendor = 'ni' - initialDelay = 0 - frequency = 60; - dutyCycle = 0.2; + initialDelay = 0 % delay from session start to clock output + frequency = 60; % Hz, of the clocking pulse + dutyCycle = 0.2; % proportion of each cycle that the pulse is "true" end properties (Transient) @@ -27,6 +27,7 @@ end function init(obj, ~) + % called when timeline is initialized (see hw.Timeline/init) if obj.enable fprintf(1, 'initializing %s\n', obj.toStr); @@ -41,6 +42,7 @@ function init(obj, ~) end function start(obj, ~) + % called when timeline is started (see hw.Timeline/start) if obj.enable if obj.verbose fprintf(1, 'start %s\n', obj.name); @@ -50,11 +52,13 @@ function start(obj, ~) end function process(~, ~, ~) + % called every time Timeline processes a chunk of data %fprintf(1, 'process Clock\n'); % -- pass end function stop(obj,~) + % called when timeline is stopped (see hw.Timeline/stop) if obj.enable if obj.verbose fprintf(1, 'stop %s\n', obj.name); diff --git a/+hw/tlOutputStartStopSync.m b/+hw/tlOutputStartStopSync.m index 15b975ff..cd47c20a 100644 --- a/+hw/tlOutputStartStopSync.m +++ b/+hw/tlOutputStartStopSync.m @@ -10,8 +10,8 @@ daqDeviceID daqChannelID daqVendor = 'ni' - initialDelay = 0 - pulseDuration = 0.2; + initialDelay = 0 % sec, time between start of acquisition and onset of this pulse + pulseDuration = 0.2; % sec, time that the pulse is on at beginning and end end methods @@ -22,6 +22,7 @@ end function init(obj, ~) + % called when timeline is initialized (see hw.Timeline/init) if obj.enable fprintf(1, 'initializing %s\n', obj.toStr); obj.session = daq.createSession(obj.daqVendor); @@ -35,6 +36,7 @@ function init(obj, ~) end function start(obj, ~) + % called when timeline is started (see hw.Timeline/start) if obj.enable if obj.verbose fprintf(1, 'start %s\n', obj.name); @@ -47,11 +49,13 @@ function start(obj, ~) end function process(~, ~, ~) + % called every time Timeline processes a chunk of data %fprintf(1, 'process StartStopSync\n'); % -- pass end function stop(obj,~) + % called when timeline is stopped (see hw.Timeline/stop) if obj.enable if obj.verbose fprintf(1, 'stop %s\n', obj.name); From ee0b8b23263d6e9e844a269d1efa0bc9da5945a7 Mon Sep 17 00:00:00 2001 From: nsteinme Date: Mon, 29 Jan 2018 16:05:02 +0000 Subject: [PATCH 047/507] add saving of metadata for Output channels in Timeline into json --- +hw/Timeline.m | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index d259c25c..1ec8a36d 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -426,9 +426,13 @@ function stop(obj) obj.Data.currSysTimeTimelineOffset = obj.CurrSysTimeTimelineOffset; % saving hardware metadata for each output -% for outIdx = 1:numel(obj.Outputs) -% obj.Data.hw.Outputs{outIdx} = struct(obj.Outputs(outIdx)); -% end + warning('off', 'MATLAB:structOnObject'); % sorry, don't care + for outIdx = 1:numel(obj.Outputs) + s = struct(obj.Outputs(outIdx)); + s.class = class(obj.Outputs(outIdx)); + obj.Data.hw.Outputs{outIdx} = s; + end + warning('on', 'MATLAB:structOnObject'); % save tl to all paths superSave(obj.Data.savePaths, struct('Timeline', obj.Data)); @@ -438,7 +442,7 @@ function stop(obj) % save local copy savejson('hw', obj.Data.hw, fullfile(fileparts(obj.Data.savePaths{1}), 'TimelineHW.json')); % save server copy - savejson('hw', obj.Data.hw, fullfile(fileparts(obj.Data.savePaths{1}), 'TimelineHW.json')); + savejson('hw', obj.Data.hw, fullfile(fileparts(obj.Data.savePaths{2}), 'TimelineHW.json')); else warning('JSONlab not found - hardware information not saved to ALF') end From 7d074d531a366868d560f42d26a7540d04ef546c Mon Sep 17 00:00:00 2001 From: nsteinme Date: Mon, 29 Jan 2018 16:16:27 +0000 Subject: [PATCH 048/507] update TODO re: timeline alyx --- +hw/Timeline.m | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 1ec8a36d..a836df0c 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -465,8 +465,7 @@ function stop(obj) warning('couldnt register files to alyx'); end end - %TODO: Register ALF components to alyx, instead of the main - %Timeline.mat file + %TODO: Register ALF components to alyx, incl TimelineHW.json % delete data from memory, tl is now officially no longer running obj.Data = []; From 783247ca56ce44abe44cafcebf57cc3a4729a9be Mon Sep 17 00:00:00 2001 From: nsteinme Date: Mon, 29 Jan 2018 16:27:52 +0000 Subject: [PATCH 049/507] update file format when registering timeline file to alyx --- +hw/Timeline.m | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 1ec8a36d..a2414421 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -453,16 +453,18 @@ function stop(obj) if ~isempty(which('alf.timelineToALF'))&&~isempty(which('writeNPY')) alf.timelineToALF(obj.Data, [],... fileparts(dat.expFilePath(obj.Data.expRef, 'timeline', 'master'))) + else + warning('did not write files into alf format. Check that alyx-matlab and npy-matlab repositories are in path'); end % register Timeline.mat file to Alyx database [subject,~,~] = dat.parseExpRef(obj.Data.expRef); if ~isempty(obj.AlyxInstance) && ~strcmp(subject,'default') try - alyx.registerFile(obj.Data.savePaths{end}, 'alf',... + alyx.registerFile(obj.Data.savePaths{end}, 'mat',... obj.AlyxInstance.subsessionURL, 'Timeline', [], obj.AlyxInstance); catch - warning('couldnt register files to alyx'); + warning('couldn''t register files to alyx'); end end %TODO: Register ALF components to alyx, instead of the main From dad17bffb0d15553aa1fbf4b674bed0a107b2ac7 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 29 Jan 2018 17:23:43 +0000 Subject: [PATCH 050/507] Fix for water calibrations reverted change Nick had previously made in hw.PulseSwitcher where only the first index of input array was used. --- +hw/+ptb/Window.m | 7 ++-- +hw/PulseSwitcher.m | 2 +- cortexlab/+tl/bindMpepServer.m | 60 +++++++++++++++++++++++++++++----- 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/+hw/+ptb/Window.m b/+hw/+ptb/Window.m index 294d2628..8cab6568 100644 --- a/+hw/+ptb/Window.m +++ b/+hw/+ptb/Window.m @@ -571,8 +571,7 @@ function applyCalibration(obj, cal) list = find(diff(thisTable) <= 0, 1); if ~isempty(list) - announce = sprintf('Gamma table %d NOT MONOTONIC. We are adjusting.',igun); - disp(announce) + fprintf('Gamma table %d NOT MONOTONIC. We are adjusting.',igun); % We assume that the non-monotonic points only differ due to noise % and so we can resort them without any consequences @@ -606,12 +605,12 @@ function applyCalibration(obj, cal) interp1(monTable,posLocs-1,(0:(numEntries-1))/(numEntries-1))'; end - if any(isnan(c.monitorGamInv)), + if any(isnan(c.monitorGamInv)) msgbox('Warning: NaNs in inverse gamma table -- may need to recalibrate.'); end end - function storeDaqData(obj, src, event) + function storeDaqData(obj, ~, event) n = length(event.TimeStamps); ii = obj.DaqData.nSamples+(1:n); obj.DaqData.timeStamps(ii) = event.TimeStamps; diff --git a/+hw/PulseSwitcher.m b/+hw/PulseSwitcher.m index 4321bf60..e5b0e5f3 100644 --- a/+hw/PulseSwitcher.m +++ b/+hw/PulseSwitcher.m @@ -21,7 +21,7 @@ end function samples = waveform(obj, sampleRate, command) - [dt, npulses, f] = obj.ParamsFun(command(1)); + [dt, npulses, f] = obj.ParamsFun(command); wavelength = 1/f; duty = dt/wavelength; assert(duty <= (1 + 1e-3), 'Pulse width larger than wavelength (duty=%.2f)', duty); diff --git a/cortexlab/+tl/bindMpepServer.m b/cortexlab/+tl/bindMpepServer.m index 3ddd033e..37e67188 100644 --- a/cortexlab/+tl/bindMpepServer.m +++ b/cortexlab/+tl/bindMpepServer.m @@ -19,8 +19,8 @@ end mpepSendPort = 1103; % send responses back to this remote port - quitKey = KbName('esc'); +manualStartKey = KbName('m'); %% Start UDP communication listeners = struct(... @@ -36,6 +36,13 @@ tls.close = @closeConns; tls.process = @process; tls.listen = @listen; +tls.AlyxInstance = []; + + +%% Initialize timeline +rig = hw.devices([], false); +tlObj = rig.timeline; +tls.tlObj = tlObj; %% Helper functions @@ -52,10 +59,10 @@ function process() function processListener(listener) sz = pnet(listener.socket, 'readpacket', 1000, 'noblock'); if sz > 0 - t = tl.time(false); % save the time we got the UDP packet + t = tlObj.time(false); % save the time we got the UDP packet msg = pnet(listener.socket, 'read'); - if tl.running - tl.record([listener.name 'UDP'], msg, t); % record the UDP event in Timeline + if tlObj.IsRunning + tlObj.record([listener.name 'UDP'], msg, t); % record the UDP event in Timeline end listener.callback(listener, msg); % call special handling function end @@ -71,23 +78,28 @@ function processMpep(listener, msg) failed = false; % flag for preventing UDP echo %% Experiment-level events start/stop timeline switch lower(info.instruction) + case 'alyx' + fprintf(1, 'received alyx token message\n'); + idx = find(msg==' ', 1, 'last'); + [~, ai] = dat.parseAlyxInstance(msg(idx+1:end)); + tls.AlyxInstance = ai; case 'expstart' % create a file path & experiment ref based on experiment info try % start Timeline - tl.start(info.expRef); + tlObj.start(info.expRef, tls.AlyxInstance); % re-record the UDP event in Timeline since it wasn't started % when we tried earlier. Treat it as having arrived at time zero. - tl.record('mpepUDP', msg, 0); + tlObj.record('mpepUDP', msg, 0); catch ex % flag up failure so we do not echo the UDP message back below failed = true; disp(getReport(ex)); end case 'expend' - tl.stop(); % stop Timeline + tlObj.stop(); % stop Timeline case 'expinterrupt' - tl.stop(); % stop Timeline + tlObj.stop(); % stop Timeline end if ~failed %% echo the UDP message back to the sender @@ -106,6 +118,7 @@ function listen() % listen to keyboard events KbQueueCreate(); KbQueueStart(); + newExpRef = []; cleanup1 = onCleanup(@KbQueueRelease); log('Polling for UDP messages. PRESS <%s> TO QUIT', KbName(quitKey)); running = true; @@ -116,6 +129,37 @@ function listen() if firstPress(quitKey) running = false; end + if firstPress(manualStartKey) && ~tlObj.IsRunning + + if isempty(tls.AlyxInstance) + % first get an alyx instance + ai = alyx.loginWindow(); + else + ai = tls.AlyxInstance; + end + + [mouseName, ~] = dat.subjectSelector([],ai); + + if ~isempty(mouseName) + clear expParams; + expParams.experimentType = 'timelineManualStart'; + [newExpRef, ~, subsessionURL] = dat.newExp(mouseName, now, expParams, ai); + ai.subsessionURL = subsessionURL; + tls.AlyxInstance = ai; + + %[subjectRef, expDate, expSequence] = dat.parseExpRef(newExpRef); + %newExpRef = dat.constructExpRef(mouseName, now, expNum); + communicator.send('AlyxSend', {tls.AlyxInstance}); + communicator.send('status', { 'starting', newExpRef}); + tlObj.start(newExpRef, ai); + end + KbQueueFlush; + elseif firstPress(manualStartKey) && tlObj.IsRunning && ~isempty(newExpRef) + fprintf(1, 'stopping timeline\n'); + tlObj.stop(); + communicator.send('status', { 'completed', newExpRef}); + newExpRef = []; + end if toc(tid) > 0.2 pause(1e-3); % allow timeline aquisition every so often tid = tic; From b63ec9fa6bb3ca22dad8c636a779a85672d5ae38 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 30 Jan 2018 18:48:32 +0000 Subject: [PATCH 051/507] bindMpepServer echo port bindMpepServer now echos messages to port of origin --- cortexlab/+tl/bindMpepServer.m | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/cortexlab/+tl/bindMpepServer.m b/cortexlab/+tl/bindMpepServer.m index 37e67188..ccbc953f 100644 --- a/cortexlab/+tl/bindMpepServer.m +++ b/cortexlab/+tl/bindMpepServer.m @@ -18,9 +18,9 @@ mpepListenPort = 1001; % listen for commands on this port end -mpepSendPort = 1103; % send responses back to this remote port +% mpepSendPort = 1103; % send responses back to this remote port quitKey = KbName('esc'); -manualStartKey = KbName('m'); +manualStartKey = KbName('t'); %% Start UDP communication listeners = struct(... @@ -109,7 +109,7 @@ function processMpep(listener, msg) % connected = true; % end pnet(listener.socket, 'write', msg); - pnet(listener.socket, 'writepacket', ipstr, mpepSendPort); + pnet(listener.socket, 'writepacket', ipstr, port); end end @@ -118,7 +118,6 @@ function listen() % listen to keyboard events KbQueueCreate(); KbQueueStart(); - newExpRef = []; cleanup1 = onCleanup(@KbQueueRelease); log('Polling for UDP messages. PRESS <%s> TO QUIT', KbName(quitKey)); running = true; @@ -154,11 +153,10 @@ function listen() tlObj.start(newExpRef, ai); end KbQueueFlush; - elseif firstPress(manualStartKey) && tlObj.IsRunning && ~isempty(newExpRef) + elseif firstPress(manualStartKey) && tlObj.IsRunning fprintf(1, 'stopping timeline\n'); tlObj.stop(); communicator.send('status', { 'completed', newExpRef}); - newExpRef = []; end if toc(tid) > 0.2 pause(1e-3); % allow timeline aquisition every so often From 6317ccbeed0276edef749b8d6f3f9180c2922cc0 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 31 Jan 2018 10:22:24 +0000 Subject: [PATCH 052/507] Removed irrelevent code Code was copied from bindMpepServerWithWS and isn't relevent here --- cortexlab/+tl/bindMpepServer.m | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cortexlab/+tl/bindMpepServer.m b/cortexlab/+tl/bindMpepServer.m index ccbc953f..533e375b 100644 --- a/cortexlab/+tl/bindMpepServer.m +++ b/cortexlab/+tl/bindMpepServer.m @@ -145,18 +145,12 @@ function listen() [newExpRef, ~, subsessionURL] = dat.newExp(mouseName, now, expParams, ai); ai.subsessionURL = subsessionURL; tls.AlyxInstance = ai; - - %[subjectRef, expDate, expSequence] = dat.parseExpRef(newExpRef); - %newExpRef = dat.constructExpRef(mouseName, now, expNum); - communicator.send('AlyxSend', {tls.AlyxInstance}); - communicator.send('status', { 'starting', newExpRef}); tlObj.start(newExpRef, ai); end KbQueueFlush; elseif firstPress(manualStartKey) && tlObj.IsRunning fprintf(1, 'stopping timeline\n'); tlObj.stop(); - communicator.send('status', { 'completed', newExpRef}); end if toc(tid) > 0.2 pause(1e-3); % allow timeline aquisition every so often From 64dad61724319ae06fbed35e0920e2641a028894 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 31 Jan 2018 18:02:26 +0000 Subject: [PATCH 053/507] TL classes now have the correct case Also More documentation and TLOutputAcqLive merged with StartStopSync --- +hw/Timeline.m | 42 +++++-------- +hw/tlOutput.m | 35 ++++++----- +hw/tlOutputAcqLive.m | 97 ++++++++++++++++++------------ +hw/tlOutputChrono.m | 137 +++++++++++++++++++++++------------------- +hw/tlOutputClock.m | 84 ++++++++++++++------------ 5 files changed, 214 insertions(+), 181 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 2168306e..e465c212 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -69,18 +69,13 @@ DaqIds = 'Dev1' % Device ID can be found with daq.getDevices() DaqSampleRate = 1000 % rate at which daq aquires data in Hz, see Rate DaqSamplesPerNotify % determines the number of data samples to be processed each time, see Timeline.process(), constructor and NotifyWhenDataAvailableExceeds - - Outputs = hw.tlOutputChrono('chrono', 'Dev1', 'port0/line1') - % array of output classes, defining any signals you desire to be sent from the daq. - % see hw.tlOutput, and, e.g. hw.tlOutputClock. - + Outputs = hw.tlOutputChrono('chrono', 'Dev1', 'port1/line0') % array of output classes, defining any signals you desire to be sent from the daq. See Also HW.TLOUTPUT, HW.TLOUTPUTCLOCK Inputs = struct('name', 'chrono',... 'arrayColumn', -1,... % -1 is default indicating unused, this is update when the channels are added during tl.start() 'daqChannelID', 'ai0',... 'measurement', 'Voltage',... 'terminalConfig', 'SingleEnded') UseInputs = {'chrono'} % array of inputs to record while tl is running - StopDelay = 2 % currently pauses for at least 2 secs as 'hack' before stopping main DAQ session MaxExpectedDuration = 2*60*60 % expected experiment time so data structure is initialised to sensible size (in secs) AquiredDataType = 'double' % default data type for the acquired data array (i.e. Data.rawDAQData) @@ -88,7 +83,6 @@ LivePlot = false % if true the data are plotted as the data are aquired LivePlotParams = []; WriteBufferToDisk = false % if true the data buffer is written to disk as they're aquired NB: in the future this will happen by default - end properties (Transient) @@ -115,9 +109,12 @@ methods function obj = Timeline(hw) - % Constructor method + % TIMELINE Constructor method + % HW.TIMELINE(hw) constructor can take a timeline hardware + % structure as an input, replicating a previous instance. % Adds chrono, aquireLive and clock to the outputs list, % along with default ports and delays + obj.DaqSamplesPerNotify = 1/obj.SamplingInterval; % calculate DaqSamplesPerNotify if nargin % if old tl hardware struct provided, use these to populate properties obj.Inputs = hw.inputs; @@ -126,12 +123,11 @@ obj.DaqSampleRate = hw.daqSampleRate; obj.DaqSamplesPerNotify = hw.daqSamplesPerNotify; end - end function start(obj, expRef, Alyx) - % Starts tl data acquisition - % TL.START(obj, ref, Alyx) starts all DAQ sessions and adds + % START Starts timeline data acquisition + % START(obj, ref, Alyx) starts all DAQ sessions and adds % the relevent output and input channels. if obj.IsRunning % check if it's already running, and if so, stop it @@ -178,14 +174,10 @@ function start(obj, expRef, Alyx) startBackground(obj.Sessions('main')); % start aquisition % wait for first acquisition processing to begin - while ~obj.IsRunning - pause(5e-3); - end + while ~obj.IsRunning; pause(5e-3); end % Start each output - for outidx = 1:numel(obj.Outputs) - obj.Outputs(outidx).start(obj); - end + arrayfun(@start, obj.Outputs) % Report success fprintf('Timeline started successfully for ''%s''.\n', expRef); @@ -233,7 +225,7 @@ function record(obj, name, event, t) end function secs = time(obj, strict) - % Time relative to Timeline acquisition + % TIME Time relative to Timeline acquisition % secs = TL.TIME([strict]) Returns the time in seconds relative to % Timeline data acquistion. 'strict' is optional (defaults to true), and % if true, this function will fail if Timeline is not running. If false, @@ -252,7 +244,7 @@ function record(obj, name, event, t) end function secs = ptbSecsToTimeline(obj, secs) - % Convert from Pyschtoolbox to Timeline time + % PTBSECSTOTIMELINE Convert from Pyschtoolbox to Timeline time % secs = TL.PTBSECSTOTIMELINE(secs) takes a timestamp 'secs' obtained % from Pyschtoolbox's functions and converts to Timeline-relative time. % See also TL.TIME(). @@ -262,7 +254,7 @@ function record(obj, name, event, t) function addInput(obj, name, channelID, measurement, terminalConfig, use) % Add a new input to the object's Input property - % TL.ADDINPUT(name, channelID, measurement, terminalConfig, use) + % ADDINPUT(name, channelID, measurement, terminalConfig, use) % adds a new input 'name' to the Inputs list. If use is % true, the input is also added to the UseInputs array. @@ -307,7 +299,7 @@ function addInput(obj, name, channelID, measurement, terminalConfig, use) end function wiringInfo(obj, name) - % TL.WIRINGINFO Return information about how the input/output + % WIRINGINFO Return information about how the input/output % 'name' is wired. If no name is provided, the different port % naming conventions of the NI DAQ are returned. if nargin < 2 @@ -364,9 +356,7 @@ function stop(obj) end % kill acquisition output signals - for outidx = 1:numel(obj.Outputs) - obj.Outputs(outidx).stop(obj); - end + arrayfun(@stop, obj.Outputs) pause(obj.StopDelay) % stop actual DAQ aquisition @@ -530,9 +520,7 @@ function init(obj) end % Initialize outputs - for outidx = 1:numel(obj.Outputs) - obj.Outputs(outidx).init(obj); - end + arrayfun(@(o)o.init(obj), obj.Outputs) end function process(obj, ~, event) diff --git a/+hw/tlOutput.m b/+hw/tlOutput.m index 697b2041..322a1e7f 100644 --- a/+hw/tlOutput.m +++ b/+hw/tlOutput.m @@ -1,14 +1,13 @@ -classdef tlOutput < matlab.mixin.Heterogeneous & handle - %hw.tlOutput Code to specify an output channel for timeline +classdef TLOutput < matlab.mixin.Heterogeneous & handle + % HW.TLOUTPUT Code to specify an output channel for timeline % This is an abstract class. % % Below is a list of some subclasses and their functions: - % hw.tlOutputClock - clocked output on a counter channel - % hw.tlOutputChrono - the default, flip/flop status check output - % hw.tlOutputAcqLive - a digital channel that is on for the duration - % of the recording - % hw.tlOutputStartStopSync - a digital channel that turns on only at - % the beginning and end of the recording + % hw.TLOutputClock - clocked output on a counter channel + % hw.TLOutputChrono - the default, flip/flop status check output + % hw.TLOutputAcqLive - a digital channel that signals that + % aquisition has begun or ended with either a constant on signal or a + % brief pulse. % % The timeline object will call the init, start, process, and stop % methods. @@ -18,21 +17,21 @@ % 2018-01 NS created properties - name % user choice, text - enable = true % will not do anything with it unless this is true - verbose = false % output status updates. Initialization message outputs regardless of verbose. + Name % The name of the timeline output, for easy identification + Enable = true % Will not do anything with it unless this is true + Verbose = false % Flag to output status updates. Initialization message outputs regardless of verbose. end - properties (Transient) - session + properties (Transient, Hidden) + Session % Holds an NI DAQ session object end methods (Abstract) - init(obj, timeline) % called when timeline is initialized (see hw.Timeline/init), e.g. to open daq session and set parameters - start(obj, timeline) % called when timeline is started (see hw.Timeline/start), e.g. to start outputs - process(obj, timeline, event) % called every time Timeline processes a chunk of data, in case output needs to react to it - stop(obj, timeline) % called when timeline is stopped (see hw.Timeline/stop), to close and clean up - s = toStr(obj) % a string that describes the object succintly + init(obj, timeline) % Called when timeline is initialized (see HW.TIMELINE/INIT), e.g. to open daq session and set parameters + start(obj, timeline) % Called when timeline is started (see HW.TIMELINE/START), e.g. to start outputs + process(obj, timeline, event) % Called every time Timeline processes a chunk of data, in case output needs to react to it + stop(obj, timeline) % Called when timeline is stopped (see HW.TIMELINE/STOP), to close and clean up + s = toStr(obj) % A string that describes the object succintly end end diff --git a/+hw/tlOutputAcqLive.m b/+hw/tlOutputAcqLive.m index 089e1710..a7610a3e 100644 --- a/+hw/tlOutputAcqLive.m +++ b/+hw/tlOutputAcqLive.m @@ -1,45 +1,59 @@ -classdef tlOutputAcqLive < hw.tlOutput - %hw.tlOutputAcqLive A digital signal that goes up when the recording starts, - % down when it ends. - % See also hw.tlOutput and hw.Timeline +classdef TLOutputAcqLive < hw.TlOutput + % HW.TLOUTPUTACQLIVE A digital signal that goes up when the recording starts, + % down when it ends. + % Used for triggaring external instruments during data aquisition. + % + % See also HW.TLOUTPUT and HW.TIMELINE % % Part of Rigbox % 2018-01 NS properties - daqDeviceID - daqChannelID - daqVendor = 'ni' - initialDelay = 0 % sec, time to wait before starting + DaqDeviceID + DaqChannelID + DaqVendor = 'ni' + InitialDelay = 0 % sec, time to wait before starting + PulseDuration = Inf; % sec, time that the pulse is on at beginning and end end methods - function obj = tlOutputAcqLive(name, daqDeviceID, daqChannelID) - obj.name = name; - obj.daqDeviceID = daqDeviceID; - obj.daqChannelID = daqChannelID; + function obj = TLOutputAcqLive(name, daqDeviceID, daqChannelID) + % TLOUTPUTCHRONO Constructor method + obj.Name = name; + obj.DaqDeviceID = daqDeviceID; + obj.DaqChannelID = daqChannelID; end function init(obj, ~) - % called when timeline is initialized (see hw.Timeline/init) - if obj.enable - fprintf(1, 'initializing %s\n', obj.toStr); - obj.session = daq.createSession(obj.daqVendor); - obj.session.addDigitalChannel(obj.daqDeviceID, obj.daqChannelID, 'OutputOnly'); - outputSingleScan(obj.session, false); % start in the off/false state - end + % INIT Initialize the output session + % INIT(obj, timeline) is called when timeline is initialized. + % Creates the DAQ session and ensures it is outputting a low + % (digital off) signal. + % + % See Also HW.TIMELINE/INIT + if obj.Enable + fprintf(1, 'initializing %s\n', obj.toStr); + obj.Session = daq.createSession(obj.DaqVendor); + obj.Session.addDigitalChannel(obj.DaqDeviceID, obj.DaqChannelID, 'OutputOnly'); + outputSingleScan(obj.Session, false); % start in the off/false state + end end function start(obj, ~) - % called when timeline is started (see hw.Timeline/start) - if obj.enable - if obj.verbose - fprintf(1, 'start %s\n', obj.name); - end - - pause(obj.initialDelay); % wait for some duration before starting - outputSingleScan(obj.session, true); % set digital output true: acquisition is "live" + % START Output a high voltage signal + % Called when timeline is started, this outputs the first high + % voltage signal to triggar external instrument aquisition + % + % See Also HW.TIMELINE/START + if obj.Enable + if obj.Verbose; fprintf(1, 'start %s\n', obj.Name); end + pause(obj.InitialDelay); % wait for some duration before starting + outputSingleScan(obj.Session, true); % set digital output true: acquisition is "live" + if obj.PulseDuration ~= Inf + pause(obj.PulseDuration); + outputSingleScan(obj.Session, false); end + end end function process(~, ~, ~) @@ -49,20 +63,29 @@ function process(~, ~, ~) end function stop(obj,~) - % called when timeline is stopped (see hw.Timeline/stop) - if obj.enable - if obj.verbose - fprintf(1, 'stop %s\n', obj.name); - end - stop(obj.session); - release(obj.session); - obj.session = []; + % STOP Stops the DAQ session object. + % Called when timeline is stopped. Outputs a low voltage signal, + % the stops and releases the session object. + % + % See Also HW.TIMELINE/STOP + if obj.Enable + % set digital output false: acquisition is no longer "live" + if obj.PulseDuration ~= Inf + outputSingleScan(obj.Session, true); + pause(obj.PulseDuration); + end + outputSingleScan(obj.Session, false); + + if obj.Verbose; fprintf(1, 'stop %s\n', obj.Name); end + stop(obj.Session); + release(obj.Session); + obj.Session = []; end end function s = toStr(obj) - s = sprintf('"%s" on %s/%s (acqLive, initial delay %.2f)', obj.name, ... - obj.daqDeviceID, obj.daqChannelID, obj.initialDelay); + s = sprintf('"%s" on %s/%s (acqLive, initial delay %.2f)', obj.Name, ... + obj.DaqDeviceID, obj.DaqChannelID, obj.InitialDelay); end end diff --git a/+hw/tlOutputChrono.m b/+hw/tlOutputChrono.m index f0c9f230..bad6aed1 100644 --- a/+hw/tlOutputChrono.m +++ b/+hw/tlOutputChrono.m @@ -1,89 +1,101 @@ -classdef tlOutputChrono < hw.tlOutput - %hw.tlOutputChrono Timeline uses this to monitor that - % acquisition is proceeding normally during a recording and to update - % the synchronization between the system time and the timeline time (to - % prevent drift between daq and computer clock). - % See also hw.tlOutput and hw.Timeline +classdef TLOutputChrono < hw.TlOutput + % HW.TLOUTPUTCHRONO Principle output channel class which sets timeline clock offset + % Timeline uses this to monitor that acquisition is proceeding normally + % during a recording and to update the synchronization between the + % system time and the timeline time (to prevent drift between daq and + % computer clock). + % + % See also HW.TLOUTPUT and HW.TIMELINE % % Part of Rigbox % 2018-01 NS properties - daqDeviceID - daqChannelID - daqVendor = 'ni' - NextChronoSign = 1 % the value to output on the chrono channel, the sign is changed each 'Process' event + DaqDeviceID + DaqChannelID + DaqVendor = 'ni' % Name of the DAQ vendor + NextChronoSign = 1 % The value to output on the chrono channel, the sign is changed each 'Process' event end methods - function obj = tlOutputChrono(name, daqDeviceID, daqChannelID) - obj.name = name; - obj.daqDeviceID = daqDeviceID; - obj.daqChannelID = daqChannelID; + function obj = TLOutputChrono(name, daqDeviceID, daqChannelID) + % TLOUTPUTCHRONO Constructor method + obj.Name = name; + obj.DaqDeviceID = daqDeviceID; + obj.DaqChannelID = daqChannelID; end function init(obj, timeline) - % called when timeline is initialized (see hw.Timeline/init) - if obj.enable + % INIT Initialize the output session + % INIT(obj, timeline) is called when timeline is initialized. + % Creates the DAQ session and ensures that the clocking pulse test + % can not be read back + % + % See Also HW.TIMELINE/INIT + if obj.Enable fprintf(1, 'initializing %s\n', obj.toStr); - obj.session = daq.createSession(obj.daqVendor); - obj.session.addDigitalChannel(obj.daqDeviceID, obj.daqChannelID, 'OutputOnly'); + obj.Session = daq.createSession(obj.DaqVendor); + obj.Session.addDigitalChannel(obj.DaqDeviceID, obj.DaqChannelID, 'OutputOnly'); tls = timeline.getSessions('main'); %%Send a test pulse low, then high to clocking channel & check we read it back idx = cellfun(@(s2)strcmp('chrono',s2), {timeline.Inputs.name}); - outputSingleScan(obj.session, false) + outputSingleScan(obj.Session, false) x1 = tls.inputSingleScan; - outputSingleScan(obj.session, true) + outputSingleScan(obj.Session, true) x2 = tls.inputSingleScan; assert(x1(timeline.Inputs(idx).arrayColumn) < 2.5 && x2(timeline.Inputs(idx).arrayColumn) > 2.5,... 'The clocking pulse test could not be read back'); - timeline.CurrSysTimeTimelineOffset = GetSecs; % to initialize this, will be a bit off but fixed after the first pulse end end function start(obj, timeline) - % called when timeline is started (see hw.Timeline/start) - if obj.enable - if obj.verbose - fprintf(1, 'start %s\n', obj.name); - end - t = GetSecs; % system time before output - outputSingleScan(obj.session, false) % this will be the clocking pulse detected the first time process is called - timeline.LastClockSentSysTime = (t + GetSecs)/2; - end + % START Starts the first chrono flip + % Called when timeline is started, this outputs the first low + % voltage output on the chrono output channel + % + % See Also HW.TIMELINE/START + if obj.Enable % If the object is to be used + if obj.Verbose; fprintf(1, 'start %s\n', obj.name); end + t = GetSecs; % system time before output + outputSingleScan(obj.session, false) % this will be the clocking pulse detected the first time process is called + timeline.LastClockSentSysTime = (t + GetSecs)/2; + end end function process(obj, timeline, event) - % called every time Timeline processes a chunk of data - if obj.enable && timeline.IsRunning && ~isempty(obj.session) - if obj.verbose - fprintf(1, 'process %s\n', obj.name); + % PROCESS Record the timestamp of last chrono flip, and output again + % OBJ.PROCESS(TIMELINE, EVENT) is called every time Timeline + % processes a chunk of data. The sign of the chrono signal is + % flipped on each call (at LastClockSentSysTime), and the time of + % the previous flip is found in the data and its timestamp noted. + % This is used by TL.TIME() to convert between system time and + % acquisition time. + % + % LastTimestamp is the time of the last scan in the previous data + % chunk, and is used to ensure no data samples have been lost. + % + % See Also TL.TIME() + + if obj.Enable && timeline.IsRunning && ~isempty(obj.Session) + if obj.Verbose + fprintf(1, 'process %s\n', obj.Name); end - % sign of the chrono signal is - % flipped on each call (at LastClockSentSysTime), and the - % time of the previous flip is found in the data and its - % timestamp noted. This is used by tl.time() to convert - % between system time and acquisition time. - % - % LastTimestamp is the time of the last scan in the previous - % data chunk, and is used to ensure no data samples have been - % lost. - %%% The chrono "out" value is flipped at a recorded time, and - %%% the sample index that this flip is measured is noted - % First, find the index of the flip in the latest chunk of data + % The chrono "out" value is flipped at a recorded time, and the + % sample index that this flip is measured is noted First, find + % the index of the flip in the latest chunk of data idx = elementByName(timeline.Inputs, 'chrono'); clockChangeIdx = find(sign(event.Data(:,timeline.Inputs(idx).arrayColumn) - 2.5) == obj.NextChronoSign, 1); - if obj.verbose + if obj.Verbose fprintf(1, ' CurrOffset=%.2f, LastClock=%.2f\n', ... timeline.CurrSysTimeTimelineOffset, timeline.LastClockSentSysTime); end - %Ensure the clocking pulse was detected + % Ensure the clocking pulse was detected if ~isempty(clockChangeIdx) clockChangeTimestamp = event.TimeStamps(clockChangeIdx); timeline.CurrSysTimeTimelineOffset = timeline.LastClockSentSysTime - clockChangeTimestamp; @@ -91,34 +103,35 @@ function process(obj, timeline, event) warning('Rigging:Timeline:timing', 'clocking pulse not detected - probably lagging more than one data chunk'); end - %Now send the next clock pulse + % Now send the next clock pulse obj.NextChronoSign = -obj.NextChronoSign; % flip next chrono t = GetSecs; % system time before output - outputSingleScan(obj.session, obj.NextChronoSign > 0); % send next chrono flip + outputSingleScan(obj.Session, obj.NextChronoSign > 0); % send next chrono flip timeline.LastClockSentSysTime = (t + GetSecs)/2; % record mean before/after system time - if obj.verbose + if obj.Verbose fprintf(1, ' CurrOffset=%.2f, LastClock=%.2f\n', ... timeline.CurrSysTimeTimelineOffset, timeline.LastClockSentSysTime); end - end end function stop(obj,~) - % called when timeline is stopped (see hw.Timeline/stop) - if obj.enable - if obj.verbose - fprintf(1, 'stop %s\n', obj.name); - end - stop(obj.session); - release(obj.session); - obj.session = []; + % STOP Stops the DAQ session object. + % Called when timeline is stopped. Stops and releases the + % session object. + % + % See Also HW.TIMELINE/STOP + if obj.Enable + if obj.Verbose; fprintf(1, 'stop %s\n', obj.Name); end + stop(obj.Session); + release(obj.Session); + obj.Session = []; end end function s = toStr(obj) - s = sprintf('"%s" on %s/%s (chrono)', obj.name, ... - obj.daqDeviceID, obj.daqChannelID); + s = sprintf('"%s" on %s/%s (chrono)', obj.Name, ... + obj.DaqDeviceID, obj.DaqChannelID); end end diff --git a/+hw/tlOutputClock.m b/+hw/tlOutputClock.m index 6c26685b..f7a3bfaa 100644 --- a/+hw/tlOutputClock.m +++ b/+hw/tlOutputClock.m @@ -1,52 +1,61 @@ -classdef tlOutputClock < hw.tlOutput - %hw.tlOutputClock A a regular pulse at a specified frequency and duty - % cycle. Can be used to trigger camera frames, e.g. - % See also hw.tlOutput and hw.Timeline +classdef TLOutputClock < hw.TlOutput + % HW.TLOUTPUTCLOCK A regular pulse at a specified frequency and duty + % cycle. Can be used to trigger camera frames. + % + % See also HW.TLOUTPUT and HW.TIMELINE % % Part of Rigbox % 2018-01 NS properties - daqDeviceID - daqChannelID - daqVendor = 'ni' - initialDelay = 0 % delay from session start to clock output - frequency = 60; % Hz, of the clocking pulse - dutyCycle = 0.2; % proportion of each cycle that the pulse is "true" + DaqDeviceID + DaqChannelID + DaqVendor = 'ni' + InitialDelay = 0 % delay from session start to clock output + Frequency = 60; % Hz, of the clocking pulse + DutyCycle = 0.2; % proportion of each cycle that the pulse is "true" end - properties (Transient) - clockChan + properties (Transient, Hidden) + ClockChan % Holds an instance of the PulseGeneration channel end methods - function obj = tlOutputClock(name, daqDeviceID, daqChannelID) - obj.name = name; - obj.daqDeviceID = daqDeviceID; - obj.daqChannelID = daqChannelID; + function obj = TLOutputClock(name, daqDeviceID, daqChannelID) + % TLOUTPUTCHRONO Constructor method + obj.Name = name; + obj.DaqDeviceID = daqDeviceID; + obj.DaqChannelID = daqChannelID; end function init(obj, ~) - % called when timeline is initialized (see hw.Timeline/init) - if obj.enable + % INIT Initialize the output session + % INIT(obj, timeline) is called when timeline is initialized. + % Creates the DAQ session and adds a PulseGeneration channel with + % the specified frequency, duty cycle and delay. + % + % See Also HW.TIMELINE/INIT + if obj.Enable fprintf(1, 'initializing %s\n', obj.toStr); - obj.session = daq.createSession(obj.daqVendor); + obj.session = daq.createSession(obj.DaqVendor); obj.session.IsContinuous = true; - clocked = obj.session.addCounterOutputChannel(obj.daqDeviceID, obj.daqChannelID, 'PulseGeneration'); - clocked.Frequency = obj.frequency; - clocked.DutyCycle = obj.dutyCycle; - clocked.InitialDelay = obj.initialDelay; - obj.clockChan = clocked; + clocked = obj.Session.addCounterOutputChannel(obj.DaqDeviceID, obj.DaqChannelID, 'PulseGeneration'); + clocked.Frequency = obj.Frequency; + clocked.DutyCycle = obj.DutyCycle; + clocked.InitialDelay = obj.InitialDelay; + obj.ClockChan = clocked; end end function start(obj, ~) - % called when timeline is started (see hw.Timeline/start) - if obj.enable - if obj.verbose - fprintf(1, 'start %s\n', obj.name); - end + % START Starts the clocking pulse + % Called when timeline is started, this uses STARTBACKGROUND to + % start the clocking pulse + % + % See Also HW.TIMELINE/START + if obj.Enable + if obj.Verbose; fprintf(1, 'start %s\n', obj.Name); end startBackground(obj.session); end end @@ -58,12 +67,13 @@ function process(~, ~, ~) end function stop(obj,~) - % called when timeline is stopped (see hw.Timeline/stop) - if obj.enable - if obj.verbose - fprintf(1, 'stop %s\n', obj.name); - end - + % STOP Stops the DAQ session object. + % Called when timeline is stopped. Stops and releases the + % session object. + % + % See Also HW.TIMELINE/STOP + if obj.Enable + if obj.Verbose; fprintf(1, 'stop %s\n', obj.Name); end stop(obj.session); release(obj.session); obj.session = []; @@ -71,8 +81,8 @@ function stop(obj,~) end function s = toStr(obj) - s = sprintf('"%s" on %s/%s (clock, %dHz, %.2f duty cycle)', obj.name, ... - obj.daqDeviceID, obj.daqChannelID, obj.frequency, obj.dutyCycle); + s = sprintf('"%s" on %s/%s (clock, %dHz, %.2f duty cycle)', obj.Name, ... + obj.DaqDeviceID, obj.DaqChannelID, obj.Frequency, obj.DutyCycle); end end From 17c2570b394155baa4d1946a9ed191ef245006aa Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 31 Jan 2018 18:02:58 +0000 Subject: [PATCH 054/507] Removed classes --- +hw/tlOutput.m | 38 ---------- +hw/tlOutputAcqLive.m | 94 ------------------------ +hw/tlOutputChrono.m | 140 ------------------------------------ +hw/tlOutputClock.m | 90 ----------------------- +hw/tlOutputStartStopSync.m | 82 --------------------- 5 files changed, 444 deletions(-) delete mode 100644 +hw/tlOutput.m delete mode 100644 +hw/tlOutputAcqLive.m delete mode 100644 +hw/tlOutputChrono.m delete mode 100644 +hw/tlOutputClock.m delete mode 100644 +hw/tlOutputStartStopSync.m diff --git a/+hw/tlOutput.m b/+hw/tlOutput.m deleted file mode 100644 index 322a1e7f..00000000 --- a/+hw/tlOutput.m +++ /dev/null @@ -1,38 +0,0 @@ -classdef TLOutput < matlab.mixin.Heterogeneous & handle - % HW.TLOUTPUT Code to specify an output channel for timeline - % This is an abstract class. - % - % Below is a list of some subclasses and their functions: - % hw.TLOutputClock - clocked output on a counter channel - % hw.TLOutputChrono - the default, flip/flop status check output - % hw.TLOutputAcqLive - a digital channel that signals that - % aquisition has begun or ended with either a constant on signal or a - % brief pulse. - % - % The timeline object will call the init, start, process, and stop - % methods. - % - % Part of Rigbox - - % 2018-01 NS created - - properties - Name % The name of the timeline output, for easy identification - Enable = true % Will not do anything with it unless this is true - Verbose = false % Flag to output status updates. Initialization message outputs regardless of verbose. - end - - properties (Transient, Hidden) - Session % Holds an NI DAQ session object - end - - methods (Abstract) - init(obj, timeline) % Called when timeline is initialized (see HW.TIMELINE/INIT), e.g. to open daq session and set parameters - start(obj, timeline) % Called when timeline is started (see HW.TIMELINE/START), e.g. to start outputs - process(obj, timeline, event) % Called every time Timeline processes a chunk of data, in case output needs to react to it - stop(obj, timeline) % Called when timeline is stopped (see HW.TIMELINE/STOP), to close and clean up - s = toStr(obj) % A string that describes the object succintly - end - -end - diff --git a/+hw/tlOutputAcqLive.m b/+hw/tlOutputAcqLive.m deleted file mode 100644 index a7610a3e..00000000 --- a/+hw/tlOutputAcqLive.m +++ /dev/null @@ -1,94 +0,0 @@ -classdef TLOutputAcqLive < hw.TlOutput - % HW.TLOUTPUTACQLIVE A digital signal that goes up when the recording starts, - % down when it ends. - % Used for triggaring external instruments during data aquisition. - % - % See also HW.TLOUTPUT and HW.TIMELINE - % - % Part of Rigbox - % 2018-01 NS - - properties - DaqDeviceID - DaqChannelID - DaqVendor = 'ni' - InitialDelay = 0 % sec, time to wait before starting - PulseDuration = Inf; % sec, time that the pulse is on at beginning and end - end - - methods - function obj = TLOutputAcqLive(name, daqDeviceID, daqChannelID) - % TLOUTPUTCHRONO Constructor method - obj.Name = name; - obj.DaqDeviceID = daqDeviceID; - obj.DaqChannelID = daqChannelID; - end - - function init(obj, ~) - % INIT Initialize the output session - % INIT(obj, timeline) is called when timeline is initialized. - % Creates the DAQ session and ensures it is outputting a low - % (digital off) signal. - % - % See Also HW.TIMELINE/INIT - if obj.Enable - fprintf(1, 'initializing %s\n', obj.toStr); - obj.Session = daq.createSession(obj.DaqVendor); - obj.Session.addDigitalChannel(obj.DaqDeviceID, obj.DaqChannelID, 'OutputOnly'); - outputSingleScan(obj.Session, false); % start in the off/false state - end - end - - function start(obj, ~) - % START Output a high voltage signal - % Called when timeline is started, this outputs the first high - % voltage signal to triggar external instrument aquisition - % - % See Also HW.TIMELINE/START - if obj.Enable - if obj.Verbose; fprintf(1, 'start %s\n', obj.Name); end - pause(obj.InitialDelay); % wait for some duration before starting - outputSingleScan(obj.Session, true); % set digital output true: acquisition is "live" - if obj.PulseDuration ~= Inf - pause(obj.PulseDuration); - outputSingleScan(obj.Session, false); - end - end - end - - function process(~, ~, ~) - % called every time Timeline processes a chunk of data - %fprintf(1, 'process acqLive\n'); - % -- pass - end - - function stop(obj,~) - % STOP Stops the DAQ session object. - % Called when timeline is stopped. Outputs a low voltage signal, - % the stops and releases the session object. - % - % See Also HW.TIMELINE/STOP - if obj.Enable - % set digital output false: acquisition is no longer "live" - if obj.PulseDuration ~= Inf - outputSingleScan(obj.Session, true); - pause(obj.PulseDuration); - end - outputSingleScan(obj.Session, false); - - if obj.Verbose; fprintf(1, 'stop %s\n', obj.Name); end - stop(obj.Session); - release(obj.Session); - obj.Session = []; - end - end - - function s = toStr(obj) - s = sprintf('"%s" on %s/%s (acqLive, initial delay %.2f)', obj.Name, ... - obj.DaqDeviceID, obj.DaqChannelID, obj.InitialDelay); - end - - end - -end - diff --git a/+hw/tlOutputChrono.m b/+hw/tlOutputChrono.m deleted file mode 100644 index bad6aed1..00000000 --- a/+hw/tlOutputChrono.m +++ /dev/null @@ -1,140 +0,0 @@ -classdef TLOutputChrono < hw.TlOutput - % HW.TLOUTPUTCHRONO Principle output channel class which sets timeline clock offset - % Timeline uses this to monitor that acquisition is proceeding normally - % during a recording and to update the synchronization between the - % system time and the timeline time (to prevent drift between daq and - % computer clock). - % - % See also HW.TLOUTPUT and HW.TIMELINE - % - % Part of Rigbox - % 2018-01 NS - - properties - DaqDeviceID - DaqChannelID - DaqVendor = 'ni' % Name of the DAQ vendor - NextChronoSign = 1 % The value to output on the chrono channel, the sign is changed each 'Process' event - end - - methods - function obj = TLOutputChrono(name, daqDeviceID, daqChannelID) - % TLOUTPUTCHRONO Constructor method - obj.Name = name; - obj.DaqDeviceID = daqDeviceID; - obj.DaqChannelID = daqChannelID; - end - - function init(obj, timeline) - % INIT Initialize the output session - % INIT(obj, timeline) is called when timeline is initialized. - % Creates the DAQ session and ensures that the clocking pulse test - % can not be read back - % - % See Also HW.TIMELINE/INIT - if obj.Enable - fprintf(1, 'initializing %s\n', obj.toStr); - obj.Session = daq.createSession(obj.DaqVendor); - obj.Session.addDigitalChannel(obj.DaqDeviceID, obj.DaqChannelID, 'OutputOnly'); - - tls = timeline.getSessions('main'); - - %%Send a test pulse low, then high to clocking channel & check we read it back - idx = cellfun(@(s2)strcmp('chrono',s2), {timeline.Inputs.name}); - outputSingleScan(obj.Session, false) - x1 = tls.inputSingleScan; - outputSingleScan(obj.Session, true) - x2 = tls.inputSingleScan; - assert(x1(timeline.Inputs(idx).arrayColumn) < 2.5 && x2(timeline.Inputs(idx).arrayColumn) > 2.5,... - 'The clocking pulse test could not be read back'); - timeline.CurrSysTimeTimelineOffset = GetSecs; % to initialize this, will be a bit off but fixed after the first pulse - end - end - - function start(obj, timeline) - % START Starts the first chrono flip - % Called when timeline is started, this outputs the first low - % voltage output on the chrono output channel - % - % See Also HW.TIMELINE/START - if obj.Enable % If the object is to be used - if obj.Verbose; fprintf(1, 'start %s\n', obj.name); end - t = GetSecs; % system time before output - outputSingleScan(obj.session, false) % this will be the clocking pulse detected the first time process is called - timeline.LastClockSentSysTime = (t + GetSecs)/2; - end - end - - function process(obj, timeline, event) - % PROCESS Record the timestamp of last chrono flip, and output again - % OBJ.PROCESS(TIMELINE, EVENT) is called every time Timeline - % processes a chunk of data. The sign of the chrono signal is - % flipped on each call (at LastClockSentSysTime), and the time of - % the previous flip is found in the data and its timestamp noted. - % This is used by TL.TIME() to convert between system time and - % acquisition time. - % - % LastTimestamp is the time of the last scan in the previous data - % chunk, and is used to ensure no data samples have been lost. - % - % See Also TL.TIME() - - if obj.Enable && timeline.IsRunning && ~isempty(obj.Session) - if obj.Verbose - fprintf(1, 'process %s\n', obj.Name); - end - - % The chrono "out" value is flipped at a recorded time, and the - % sample index that this flip is measured is noted First, find - % the index of the flip in the latest chunk of data - idx = elementByName(timeline.Inputs, 'chrono'); - clockChangeIdx = find(sign(event.Data(:,timeline.Inputs(idx).arrayColumn) - 2.5) == obj.NextChronoSign, 1); - - if obj.Verbose - fprintf(1, ' CurrOffset=%.2f, LastClock=%.2f\n', ... - timeline.CurrSysTimeTimelineOffset, timeline.LastClockSentSysTime); - end - - % Ensure the clocking pulse was detected - if ~isempty(clockChangeIdx) - clockChangeTimestamp = event.TimeStamps(clockChangeIdx); - timeline.CurrSysTimeTimelineOffset = timeline.LastClockSentSysTime - clockChangeTimestamp; - else - warning('Rigging:Timeline:timing', 'clocking pulse not detected - probably lagging more than one data chunk'); - end - - % Now send the next clock pulse - obj.NextChronoSign = -obj.NextChronoSign; % flip next chrono - t = GetSecs; % system time before output - outputSingleScan(obj.Session, obj.NextChronoSign > 0); % send next chrono flip - timeline.LastClockSentSysTime = (t + GetSecs)/2; % record mean before/after system time - if obj.Verbose - fprintf(1, ' CurrOffset=%.2f, LastClock=%.2f\n', ... - timeline.CurrSysTimeTimelineOffset, timeline.LastClockSentSysTime); - end - end - end - - function stop(obj,~) - % STOP Stops the DAQ session object. - % Called when timeline is stopped. Stops and releases the - % session object. - % - % See Also HW.TIMELINE/STOP - if obj.Enable - if obj.Verbose; fprintf(1, 'stop %s\n', obj.Name); end - stop(obj.Session); - release(obj.Session); - obj.Session = []; - end - end - - function s = toStr(obj) - s = sprintf('"%s" on %s/%s (chrono)', obj.Name, ... - obj.DaqDeviceID, obj.DaqChannelID); - end - - end - -end - diff --git a/+hw/tlOutputClock.m b/+hw/tlOutputClock.m deleted file mode 100644 index f7a3bfaa..00000000 --- a/+hw/tlOutputClock.m +++ /dev/null @@ -1,90 +0,0 @@ -classdef TLOutputClock < hw.TlOutput - % HW.TLOUTPUTCLOCK A regular pulse at a specified frequency and duty - % cycle. Can be used to trigger camera frames. - % - % See also HW.TLOUTPUT and HW.TIMELINE - % - % Part of Rigbox - % 2018-01 NS - - properties - DaqDeviceID - DaqChannelID - DaqVendor = 'ni' - InitialDelay = 0 % delay from session start to clock output - Frequency = 60; % Hz, of the clocking pulse - DutyCycle = 0.2; % proportion of each cycle that the pulse is "true" - end - - properties (Transient, Hidden) - ClockChan % Holds an instance of the PulseGeneration channel - end - - methods - function obj = TLOutputClock(name, daqDeviceID, daqChannelID) - % TLOUTPUTCHRONO Constructor method - obj.Name = name; - obj.DaqDeviceID = daqDeviceID; - obj.DaqChannelID = daqChannelID; - end - - function init(obj, ~) - % INIT Initialize the output session - % INIT(obj, timeline) is called when timeline is initialized. - % Creates the DAQ session and adds a PulseGeneration channel with - % the specified frequency, duty cycle and delay. - % - % See Also HW.TIMELINE/INIT - if obj.Enable - fprintf(1, 'initializing %s\n', obj.toStr); - - obj.session = daq.createSession(obj.DaqVendor); - obj.session.IsContinuous = true; - clocked = obj.Session.addCounterOutputChannel(obj.DaqDeviceID, obj.DaqChannelID, 'PulseGeneration'); - clocked.Frequency = obj.Frequency; - clocked.DutyCycle = obj.DutyCycle; - clocked.InitialDelay = obj.InitialDelay; - obj.ClockChan = clocked; - end - end - - function start(obj, ~) - % START Starts the clocking pulse - % Called when timeline is started, this uses STARTBACKGROUND to - % start the clocking pulse - % - % See Also HW.TIMELINE/START - if obj.Enable - if obj.Verbose; fprintf(1, 'start %s\n', obj.Name); end - startBackground(obj.session); - end - end - - function process(~, ~, ~) - % called every time Timeline processes a chunk of data - %fprintf(1, 'process Clock\n'); - % -- pass - end - - function stop(obj,~) - % STOP Stops the DAQ session object. - % Called when timeline is stopped. Stops and releases the - % session object. - % - % See Also HW.TIMELINE/STOP - if obj.Enable - if obj.Verbose; fprintf(1, 'stop %s\n', obj.Name); end - stop(obj.session); - release(obj.session); - obj.session = []; - end - end - - function s = toStr(obj) - s = sprintf('"%s" on %s/%s (clock, %dHz, %.2f duty cycle)', obj.Name, ... - obj.DaqDeviceID, obj.DaqChannelID, obj.Frequency, obj.DutyCycle); - end - end - -end - diff --git a/+hw/tlOutputStartStopSync.m b/+hw/tlOutputStartStopSync.m deleted file mode 100644 index cd47c20a..00000000 --- a/+hw/tlOutputStartStopSync.m +++ /dev/null @@ -1,82 +0,0 @@ -classdef tlOutputStartStopSync < hw.tlOutput - %hw.tlOutputStartStopSync A digital signal that goes up when the recording starts, - % but just briefly, then down again at the end. - % See also hw.tlOutput and hw.Timeline - % - % Part of Rigbox - % 2018-01 NS - - properties - daqDeviceID - daqChannelID - daqVendor = 'ni' - initialDelay = 0 % sec, time between start of acquisition and onset of this pulse - pulseDuration = 0.2; % sec, time that the pulse is on at beginning and end - end - - methods - function obj = tlOutputStartStopSync(name, daqDeviceID, daqChannelID) - obj.name = name; - obj.daqDeviceID = daqDeviceID; - obj.daqChannelID = daqChannelID; - end - - function init(obj, ~) - % called when timeline is initialized (see hw.Timeline/init) - if obj.enable - fprintf(1, 'initializing %s\n', obj.toStr); - obj.session = daq.createSession(obj.daqVendor); - obj.session.addDigitalChannel(obj.daqDeviceID, obj.daqChannelID, 'OutputOnly'); - outputSingleScan(obj.session, false); % ensure that it starts down - % by the way, if you use this to control a light for - % synchronization, note that you can configure in nidaqMX a - % "default" value for the channel, so for example it will stay - % "false" at all times even if the computer reboots. - end - end - - function start(obj, ~) - % called when timeline is started (see hw.Timeline/start) - if obj.enable - if obj.verbose - fprintf(1, 'start %s\n', obj.name); - end - pause(obj.initialDelay); - outputSingleScan(obj.session, true); - pause(obj.pulseDuration); - outputSingleScan(obj.session, false); - end - end - - function process(~, ~, ~) - % called every time Timeline processes a chunk of data - %fprintf(1, 'process StartStopSync\n'); - % -- pass - end - - function stop(obj,~) - % called when timeline is stopped (see hw.Timeline/stop) - if obj.enable - if obj.verbose - fprintf(1, 'stop %s\n', obj.name); - end - - outputSingleScan(obj.session, true); - pause(obj.pulseDuration); - outputSingleScan(obj.session, false); - - stop(obj.session); - release(obj.session); - obj.session = []; - end - end - - function s = toStr(obj) - s = sprintf('"%s" on %s/%s (StartStopSync, pulse duration %.2f)', obj.name, ... - obj.daqDeviceID, obj.daqChannelID, obj.pulseDuration); - end - - end - -end - From 5262d19425b5ce71a125346fdb0f171eb810dfda Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 31 Jan 2018 18:03:25 +0000 Subject: [PATCH 055/507] Re-added classes with correct case --- +hw/TLOutput.m | 38 ++++++++++++ +hw/TLOutputAcqLive.m | 94 ++++++++++++++++++++++++++++ +hw/TLOutputChrono.m | 140 ++++++++++++++++++++++++++++++++++++++++++ +hw/TLOutputClock.m | 90 +++++++++++++++++++++++++++ 4 files changed, 362 insertions(+) create mode 100644 +hw/TLOutput.m create mode 100644 +hw/TLOutputAcqLive.m create mode 100644 +hw/TLOutputChrono.m create mode 100644 +hw/TLOutputClock.m diff --git a/+hw/TLOutput.m b/+hw/TLOutput.m new file mode 100644 index 00000000..322a1e7f --- /dev/null +++ b/+hw/TLOutput.m @@ -0,0 +1,38 @@ +classdef TLOutput < matlab.mixin.Heterogeneous & handle + % HW.TLOUTPUT Code to specify an output channel for timeline + % This is an abstract class. + % + % Below is a list of some subclasses and their functions: + % hw.TLOutputClock - clocked output on a counter channel + % hw.TLOutputChrono - the default, flip/flop status check output + % hw.TLOutputAcqLive - a digital channel that signals that + % aquisition has begun or ended with either a constant on signal or a + % brief pulse. + % + % The timeline object will call the init, start, process, and stop + % methods. + % + % Part of Rigbox + + % 2018-01 NS created + + properties + Name % The name of the timeline output, for easy identification + Enable = true % Will not do anything with it unless this is true + Verbose = false % Flag to output status updates. Initialization message outputs regardless of verbose. + end + + properties (Transient, Hidden) + Session % Holds an NI DAQ session object + end + + methods (Abstract) + init(obj, timeline) % Called when timeline is initialized (see HW.TIMELINE/INIT), e.g. to open daq session and set parameters + start(obj, timeline) % Called when timeline is started (see HW.TIMELINE/START), e.g. to start outputs + process(obj, timeline, event) % Called every time Timeline processes a chunk of data, in case output needs to react to it + stop(obj, timeline) % Called when timeline is stopped (see HW.TIMELINE/STOP), to close and clean up + s = toStr(obj) % A string that describes the object succintly + end + +end + diff --git a/+hw/TLOutputAcqLive.m b/+hw/TLOutputAcqLive.m new file mode 100644 index 00000000..a7610a3e --- /dev/null +++ b/+hw/TLOutputAcqLive.m @@ -0,0 +1,94 @@ +classdef TLOutputAcqLive < hw.TlOutput + % HW.TLOUTPUTACQLIVE A digital signal that goes up when the recording starts, + % down when it ends. + % Used for triggaring external instruments during data aquisition. + % + % See also HW.TLOUTPUT and HW.TIMELINE + % + % Part of Rigbox + % 2018-01 NS + + properties + DaqDeviceID + DaqChannelID + DaqVendor = 'ni' + InitialDelay = 0 % sec, time to wait before starting + PulseDuration = Inf; % sec, time that the pulse is on at beginning and end + end + + methods + function obj = TLOutputAcqLive(name, daqDeviceID, daqChannelID) + % TLOUTPUTCHRONO Constructor method + obj.Name = name; + obj.DaqDeviceID = daqDeviceID; + obj.DaqChannelID = daqChannelID; + end + + function init(obj, ~) + % INIT Initialize the output session + % INIT(obj, timeline) is called when timeline is initialized. + % Creates the DAQ session and ensures it is outputting a low + % (digital off) signal. + % + % See Also HW.TIMELINE/INIT + if obj.Enable + fprintf(1, 'initializing %s\n', obj.toStr); + obj.Session = daq.createSession(obj.DaqVendor); + obj.Session.addDigitalChannel(obj.DaqDeviceID, obj.DaqChannelID, 'OutputOnly'); + outputSingleScan(obj.Session, false); % start in the off/false state + end + end + + function start(obj, ~) + % START Output a high voltage signal + % Called when timeline is started, this outputs the first high + % voltage signal to triggar external instrument aquisition + % + % See Also HW.TIMELINE/START + if obj.Enable + if obj.Verbose; fprintf(1, 'start %s\n', obj.Name); end + pause(obj.InitialDelay); % wait for some duration before starting + outputSingleScan(obj.Session, true); % set digital output true: acquisition is "live" + if obj.PulseDuration ~= Inf + pause(obj.PulseDuration); + outputSingleScan(obj.Session, false); + end + end + end + + function process(~, ~, ~) + % called every time Timeline processes a chunk of data + %fprintf(1, 'process acqLive\n'); + % -- pass + end + + function stop(obj,~) + % STOP Stops the DAQ session object. + % Called when timeline is stopped. Outputs a low voltage signal, + % the stops and releases the session object. + % + % See Also HW.TIMELINE/STOP + if obj.Enable + % set digital output false: acquisition is no longer "live" + if obj.PulseDuration ~= Inf + outputSingleScan(obj.Session, true); + pause(obj.PulseDuration); + end + outputSingleScan(obj.Session, false); + + if obj.Verbose; fprintf(1, 'stop %s\n', obj.Name); end + stop(obj.Session); + release(obj.Session); + obj.Session = []; + end + end + + function s = toStr(obj) + s = sprintf('"%s" on %s/%s (acqLive, initial delay %.2f)', obj.Name, ... + obj.DaqDeviceID, obj.DaqChannelID, obj.InitialDelay); + end + + end + +end + diff --git a/+hw/TLOutputChrono.m b/+hw/TLOutputChrono.m new file mode 100644 index 00000000..bad6aed1 --- /dev/null +++ b/+hw/TLOutputChrono.m @@ -0,0 +1,140 @@ +classdef TLOutputChrono < hw.TlOutput + % HW.TLOUTPUTCHRONO Principle output channel class which sets timeline clock offset + % Timeline uses this to monitor that acquisition is proceeding normally + % during a recording and to update the synchronization between the + % system time and the timeline time (to prevent drift between daq and + % computer clock). + % + % See also HW.TLOUTPUT and HW.TIMELINE + % + % Part of Rigbox + % 2018-01 NS + + properties + DaqDeviceID + DaqChannelID + DaqVendor = 'ni' % Name of the DAQ vendor + NextChronoSign = 1 % The value to output on the chrono channel, the sign is changed each 'Process' event + end + + methods + function obj = TLOutputChrono(name, daqDeviceID, daqChannelID) + % TLOUTPUTCHRONO Constructor method + obj.Name = name; + obj.DaqDeviceID = daqDeviceID; + obj.DaqChannelID = daqChannelID; + end + + function init(obj, timeline) + % INIT Initialize the output session + % INIT(obj, timeline) is called when timeline is initialized. + % Creates the DAQ session and ensures that the clocking pulse test + % can not be read back + % + % See Also HW.TIMELINE/INIT + if obj.Enable + fprintf(1, 'initializing %s\n', obj.toStr); + obj.Session = daq.createSession(obj.DaqVendor); + obj.Session.addDigitalChannel(obj.DaqDeviceID, obj.DaqChannelID, 'OutputOnly'); + + tls = timeline.getSessions('main'); + + %%Send a test pulse low, then high to clocking channel & check we read it back + idx = cellfun(@(s2)strcmp('chrono',s2), {timeline.Inputs.name}); + outputSingleScan(obj.Session, false) + x1 = tls.inputSingleScan; + outputSingleScan(obj.Session, true) + x2 = tls.inputSingleScan; + assert(x1(timeline.Inputs(idx).arrayColumn) < 2.5 && x2(timeline.Inputs(idx).arrayColumn) > 2.5,... + 'The clocking pulse test could not be read back'); + timeline.CurrSysTimeTimelineOffset = GetSecs; % to initialize this, will be a bit off but fixed after the first pulse + end + end + + function start(obj, timeline) + % START Starts the first chrono flip + % Called when timeline is started, this outputs the first low + % voltage output on the chrono output channel + % + % See Also HW.TIMELINE/START + if obj.Enable % If the object is to be used + if obj.Verbose; fprintf(1, 'start %s\n', obj.name); end + t = GetSecs; % system time before output + outputSingleScan(obj.session, false) % this will be the clocking pulse detected the first time process is called + timeline.LastClockSentSysTime = (t + GetSecs)/2; + end + end + + function process(obj, timeline, event) + % PROCESS Record the timestamp of last chrono flip, and output again + % OBJ.PROCESS(TIMELINE, EVENT) is called every time Timeline + % processes a chunk of data. The sign of the chrono signal is + % flipped on each call (at LastClockSentSysTime), and the time of + % the previous flip is found in the data and its timestamp noted. + % This is used by TL.TIME() to convert between system time and + % acquisition time. + % + % LastTimestamp is the time of the last scan in the previous data + % chunk, and is used to ensure no data samples have been lost. + % + % See Also TL.TIME() + + if obj.Enable && timeline.IsRunning && ~isempty(obj.Session) + if obj.Verbose + fprintf(1, 'process %s\n', obj.Name); + end + + % The chrono "out" value is flipped at a recorded time, and the + % sample index that this flip is measured is noted First, find + % the index of the flip in the latest chunk of data + idx = elementByName(timeline.Inputs, 'chrono'); + clockChangeIdx = find(sign(event.Data(:,timeline.Inputs(idx).arrayColumn) - 2.5) == obj.NextChronoSign, 1); + + if obj.Verbose + fprintf(1, ' CurrOffset=%.2f, LastClock=%.2f\n', ... + timeline.CurrSysTimeTimelineOffset, timeline.LastClockSentSysTime); + end + + % Ensure the clocking pulse was detected + if ~isempty(clockChangeIdx) + clockChangeTimestamp = event.TimeStamps(clockChangeIdx); + timeline.CurrSysTimeTimelineOffset = timeline.LastClockSentSysTime - clockChangeTimestamp; + else + warning('Rigging:Timeline:timing', 'clocking pulse not detected - probably lagging more than one data chunk'); + end + + % Now send the next clock pulse + obj.NextChronoSign = -obj.NextChronoSign; % flip next chrono + t = GetSecs; % system time before output + outputSingleScan(obj.Session, obj.NextChronoSign > 0); % send next chrono flip + timeline.LastClockSentSysTime = (t + GetSecs)/2; % record mean before/after system time + if obj.Verbose + fprintf(1, ' CurrOffset=%.2f, LastClock=%.2f\n', ... + timeline.CurrSysTimeTimelineOffset, timeline.LastClockSentSysTime); + end + end + end + + function stop(obj,~) + % STOP Stops the DAQ session object. + % Called when timeline is stopped. Stops and releases the + % session object. + % + % See Also HW.TIMELINE/STOP + if obj.Enable + if obj.Verbose; fprintf(1, 'stop %s\n', obj.Name); end + stop(obj.Session); + release(obj.Session); + obj.Session = []; + end + end + + function s = toStr(obj) + s = sprintf('"%s" on %s/%s (chrono)', obj.Name, ... + obj.DaqDeviceID, obj.DaqChannelID); + end + + end + +end + diff --git a/+hw/TLOutputClock.m b/+hw/TLOutputClock.m new file mode 100644 index 00000000..f7a3bfaa --- /dev/null +++ b/+hw/TLOutputClock.m @@ -0,0 +1,90 @@ +classdef TLOutputClock < hw.TlOutput + % HW.TLOUTPUTCLOCK A regular pulse at a specified frequency and duty + % cycle. Can be used to trigger camera frames. + % + % See also HW.TLOUTPUT and HW.TIMELINE + % + % Part of Rigbox + % 2018-01 NS + + properties + DaqDeviceID + DaqChannelID + DaqVendor = 'ni' + InitialDelay = 0 % delay from session start to clock output + Frequency = 60; % Hz, of the clocking pulse + DutyCycle = 0.2; % proportion of each cycle that the pulse is "true" + end + + properties (Transient, Hidden) + ClockChan % Holds an instance of the PulseGeneration channel + end + + methods + function obj = TLOutputClock(name, daqDeviceID, daqChannelID) + % TLOUTPUTCHRONO Constructor method + obj.Name = name; + obj.DaqDeviceID = daqDeviceID; + obj.DaqChannelID = daqChannelID; + end + + function init(obj, ~) + % INIT Initialize the output session + % INIT(obj, timeline) is called when timeline is initialized. + % Creates the DAQ session and adds a PulseGeneration channel with + % the specified frequency, duty cycle and delay. + % + % See Also HW.TIMELINE/INIT + if obj.Enable + fprintf(1, 'initializing %s\n', obj.toStr); + + obj.session = daq.createSession(obj.DaqVendor); + obj.session.IsContinuous = true; + clocked = obj.Session.addCounterOutputChannel(obj.DaqDeviceID, obj.DaqChannelID, 'PulseGeneration'); + clocked.Frequency = obj.Frequency; + clocked.DutyCycle = obj.DutyCycle; + clocked.InitialDelay = obj.InitialDelay; + obj.ClockChan = clocked; + end + end + + function start(obj, ~) + % START Starts the clocking pulse + % Called when timeline is started, this uses STARTBACKGROUND to + % start the clocking pulse + % + % See Also HW.TIMELINE/START + if obj.Enable + if obj.Verbose; fprintf(1, 'start %s\n', obj.Name); end + startBackground(obj.session); + end + end + + function process(~, ~, ~) + % called every time Timeline processes a chunk of data + %fprintf(1, 'process Clock\n'); + % -- pass + end + + function stop(obj,~) + % STOP Stops the DAQ session object. + % Called when timeline is stopped. Stops and releases the + % session object. + % + % See Also HW.TIMELINE/STOP + if obj.Enable + if obj.Verbose; fprintf(1, 'stop %s\n', obj.Name); end + stop(obj.session); + release(obj.session); + obj.session = []; + end + end + + function s = toStr(obj) + s = sprintf('"%s" on %s/%s (clock, %dHz, %.2f duty cycle)', obj.Name, ... + obj.DaqDeviceID, obj.DaqChannelID, obj.Frequency, obj.DutyCycle); + end + end + +end + From f93e62318847c84b068cc33396d0d77d5d9c3e45 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 31 Jan 2018 19:02:07 +0000 Subject: [PATCH 056/507] Comment formatting --- +hw/DummyFeedback.m | 23 ---------------------- +hw/TLOutput.m | 32 ++++++++++++++++++++++-------- +hw/TLOutputAcqLive.m | 40 ++++++++++++++++++++++++++++---------- +hw/TLOutputChrono.m | 22 ++++++++++++++++----- +hw/TLOutputClock.m | 37 ++++++++++++++++++++++++++--------- +hw/Timeline.m | 45 ++++++++++++++++++++++++++----------------- 6 files changed, 126 insertions(+), 73 deletions(-) delete mode 100644 +hw/DummyFeedback.m diff --git a/+hw/DummyFeedback.m b/+hw/DummyFeedback.m deleted file mode 100644 index ca3da58a..00000000 --- a/+hw/DummyFeedback.m +++ /dev/null @@ -1,23 +0,0 @@ -classdef DummyFeedback < hw.RewardController - %HW.DUMMYFEEDBACK hw.RewardController implementation that does nothing - % Detailed explanation goes here - % - % Part of Rigbox - - % 2012-10 CB created - - properties - end - - methods - function deliverBackground(obj, size) - % do nothing for now - fprintf('reward %f\n', size); - logSample(obj, size, obj.Clock.now); - end - function deliverMultiple(obj, size, interval, n) - % do nothing for now - end - end -end - diff --git a/+hw/TLOutput.m b/+hw/TLOutput.m index 322a1e7f..0d7dff65 100644 --- a/+hw/TLOutput.m +++ b/+hw/TLOutput.m @@ -1,5 +1,5 @@ classdef TLOutput < matlab.mixin.Heterogeneous & handle - % HW.TLOUTPUT Code to specify an output channel for timeline + %HW.TLOUTPUT Code to specify an output channel for timeline % This is an abstract class. % % Below is a list of some subclasses and their functions: @@ -10,7 +10,18 @@ % brief pulse. % % The timeline object will call the init, start, process, and stop - % methods. + % methods. Example: + % + % tl = hw.Timeline; + % tl.Outputs(1) = hw.TLOutputAcqLive('Instra-Triggar', 'Dev1', + % 'PFI4'); + % tl.start('2018-01-01_1_mouse2', alyxInstance); + % >> initializing Instra-Triggar + % >> start Instra-Triggar + % >> Timeline started successfully + % tl.stop; + % + % See Also HW.TLOutputChrono, HW.TLOutputAcqLive, HW.TLOutputClock % % Part of Rigbox @@ -22,16 +33,21 @@ Verbose = false % Flag to output status updates. Initialization message outputs regardless of verbose. end - properties (Transient, Hidden) + properties (Transient, Hidden, Access = protected) Session % Holds an NI DAQ session object end methods (Abstract) - init(obj, timeline) % Called when timeline is initialized (see HW.TIMELINE/INIT), e.g. to open daq session and set parameters - start(obj, timeline) % Called when timeline is started (see HW.TIMELINE/START), e.g. to start outputs - process(obj, timeline, event) % Called every time Timeline processes a chunk of data, in case output needs to react to it - stop(obj, timeline) % Called when timeline is stopped (see HW.TIMELINE/STOP), to close and clean up - s = toStr(obj) % A string that describes the object succintly + % Called when timeline is initialized (see HW.TIMELINE/INIT), e.g. to open daq session and set parameters + init(obj, timeline) + % Called when timeline is started (see HW.TIMELINE/START), e.g. to start outputs + start(obj, timeline) + % Called every time Timeline processes a chunk of data, in case output needs to react to it + process(obj, timeline, event) + % Called when timeline is stopped (see HW.TIMELINE/STOP), to close and clean up + stop(obj, timeline) + % Returns a string that describes the object succintly + s = toStr(obj) end end diff --git a/+hw/TLOutputAcqLive.m b/+hw/TLOutputAcqLive.m index a7610a3e..314f73b0 100644 --- a/+hw/TLOutputAcqLive.m +++ b/+hw/TLOutputAcqLive.m @@ -1,17 +1,30 @@ classdef TLOutputAcqLive < hw.TlOutput - % HW.TLOUTPUTACQLIVE A digital signal that goes up when the recording starts, + %HW.TLOUTPUTACQLIVE A digital signal that goes up when the recording starts, % down when it ends. - % Used for triggaring external instruments during data aquisition. + % Used for triggaring external instruments during data aquisition. Will + % either output a constant high voltage signal while Timeline is + % running, or if obj.PulseDuration is set to a value > 0 and < Inf, the + % DAQ will output a pulse of that duration at the start and end of the + % aquisition. % - % See also HW.TLOUTPUT and HW.TIMELINE + % Example: + % tl = hw.Timeline; + % tl.Outputs(1) = hw.TLOutputAcqLive('Instra-Triggar', 'Dev1', 'PFI4'); + % tl.start('2018-01-01_1_mouse2', alyxInstance); + % >> initializing Instra-Triggar + % >> start Instra-Triggar + % >> Timeline started successfully + % tl.stop; + % + % See also HW.TLOUTPUT, HW.TIMELINE % % Part of Rigbox % 2018-01 NS properties - DaqDeviceID - DaqChannelID - DaqVendor = 'ni' + DaqDeviceID % The name of the DAQ device ID, e.g. 'Dev1', see DAQ.GETDEVICES + DaqChannelID % The name of the DAQ channel ID, e.g. 'port1/line0', see DAQ.GETDEVICES + DaqVendor = 'ni' % Name of the DAQ vendor InitialDelay = 0 % sec, time to wait before starting PulseDuration = Inf; % sec, time that the pulse is on at beginning and end end @@ -57,9 +70,14 @@ function start(obj, ~) end function process(~, ~, ~) - % called every time Timeline processes a chunk of data - %fprintf(1, 'process acqLive\n'); - % -- pass + % PROCESS() Listener for processing acquired Timeline data + % PROCESS(obj, source, event) is a listener callback + % function for handling tl data acquisition. Called by the + % 'main' DAQ session with latest chunk of data. + % + % See Also HW.TIMELINE/PROCESS + %fprintf(1, 'process acqLive\n'); + % -- pass end function stop(obj,~) @@ -84,10 +102,12 @@ function stop(obj,~) end function s = toStr(obj) + % TOSTR Returns a string that describes the object succintly + % + % See Also INIT s = sprintf('"%s" on %s/%s (acqLive, initial delay %.2f)', obj.Name, ... obj.DaqDeviceID, obj.DaqChannelID, obj.InitialDelay); end - end end diff --git a/+hw/TLOutputChrono.m b/+hw/TLOutputChrono.m index bad6aed1..609865ba 100644 --- a/+hw/TLOutputChrono.m +++ b/+hw/TLOutputChrono.m @@ -1,18 +1,27 @@ classdef TLOutputChrono < hw.TlOutput - % HW.TLOUTPUTCHRONO Principle output channel class which sets timeline clock offset + %HW.TLOUTPUTCHRONO Principle output channel class which sets timeline clock offset % Timeline uses this to monitor that acquisition is proceeding normally % during a recording and to update the synchronization between the % system time and the timeline time (to prevent drift between daq and % computer clock). % - % See also HW.TLOUTPUT and HW.TIMELINE + % Example: + % tl = hw.Timeline; + % tl.Outputs(1) = hw.TLOutputChrono('Chrono', 'Dev1', 'PFI4'); + % tl.start('2018-01-01_1_mouse2', alyxInstance); + % >> initializing Chrono + % >> start Chrono + % >> Timeline started successfully + % tl.stop; + % + % See also HW.TLOUTPUT, HW.TIMELINE % % Part of Rigbox % 2018-01 NS properties - DaqDeviceID - DaqChannelID + DaqDeviceID % The name of the DAQ device ID, e.g. 'Dev1', see DAQ.GETDEVICES + DaqChannelID % The name of the DAQ channel ID, e.g. 'port1/line0', see DAQ.GETDEVICES DaqVendor = 'ni' % Name of the DAQ vendor NextChronoSign = 1 % The value to output on the chrono channel, the sign is changed each 'Process' event end @@ -77,7 +86,7 @@ function process(obj, timeline, event) % LastTimestamp is the time of the last scan in the previous data % chunk, and is used to ensure no data samples have been lost. % - % See Also TL.TIME() + % See Also HW.TIMELINE/TIME() and HW.TIMELINE/PROCESS if obj.Enable && timeline.IsRunning && ~isempty(obj.Session) if obj.Verbose @@ -130,6 +139,9 @@ function stop(obj,~) end function s = toStr(obj) + % TOSTR Returns a string that describes the object succintly + % + % See Also INIT s = sprintf('"%s" on %s/%s (chrono)', obj.Name, ... obj.DaqDeviceID, obj.DaqChannelID); end diff --git a/+hw/TLOutputClock.m b/+hw/TLOutputClock.m index f7a3bfaa..1e0210ec 100644 --- a/+hw/TLOutputClock.m +++ b/+hw/TLOutputClock.m @@ -1,22 +1,32 @@ classdef TLOutputClock < hw.TlOutput - % HW.TLOUTPUTCLOCK A regular pulse at a specified frequency and duty + %HW.TLOUTPUTCLOCK A regular pulse at a specified frequency and duty % cycle. Can be used to trigger camera frames. % - % See also HW.TLOUTPUT and HW.TIMELINE + % Example: + % tl = hw.Timeline; + % tl.Outputs(end+1) = hw.TLOutputClock('Cam-Triggar', 'Dev1', 'PFI4'); + % tl.Outputs(end).InitialDelay = 5 % Add initial delay before start + % tl.start('2018-01-01_1_mouse2', alyxInstance); + % >> initializing Cam-Triggar + % >> start Cam-Triggar + % >> Timeline started successfully + % tl.stop; + % + % See also HW.TLOUTPUT, HW.TIMELINE % % Part of Rigbox % 2018-01 NS properties - DaqDeviceID - DaqChannelID - DaqVendor = 'ni' + DaqDeviceID % The name of the DAQ device ID, e.g. 'Dev1', see DAQ.GETDEVICES + DaqChannelID % The name of the DAQ channel ID, e.g. 'port1/line0', see DAQ.GETDEVICES + DaqVendor = 'ni' % Name of the DAQ vendor InitialDelay = 0 % delay from session start to clock output Frequency = 60; % Hz, of the clocking pulse DutyCycle = 0.2; % proportion of each cycle that the pulse is "true" end - properties (Transient, Hidden) + properties (Transient, Hidden, Access = protected) ClockChan % Holds an instance of the PulseGeneration channel end @@ -61,9 +71,15 @@ function start(obj, ~) end function process(~, ~, ~) - % called every time Timeline processes a chunk of data - %fprintf(1, 'process Clock\n'); - % -- pass + % PROCESS() Listener for processing acquired Timeline data + % PROCESS(obj, source, event) is a listener callback + % function for handling tl data acquisition. Called by the + % 'main' DAQ session with latest chunk of data. + % + % See Also HW.TIMELINE/PROCESS + + %fprintf(1, 'process Clock\n'); + % -- pass end function stop(obj,~) @@ -81,6 +97,9 @@ function stop(obj,~) end function s = toStr(obj) + % TOSTR Returns a string that describes the object succintly + % + % See Also INIT s = sprintf('"%s" on %s/%s (clock, %dHz, %.2f duty cycle)', obj.Name, ... obj.DaqDeviceID, obj.DaqChannelID, obj.Frequency, obj.DutyCycle); end diff --git a/+hw/Timeline.m b/+hw/Timeline.m index e465c212..d5862738 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -129,6 +129,8 @@ function start(obj, expRef, Alyx) % START Starts timeline data acquisition % START(obj, ref, Alyx) starts all DAQ sessions and adds % the relevent output and input channels. + % + % See Also HW.TLOUTPUT/START if obj.IsRunning % check if it's already running, and if so, stop it disp('Timeline already running, stopping first'); @@ -230,7 +232,8 @@ function record(obj, name, event, t) % Timeline data acquistion. 'strict' is optional (defaults to true), and % if true, this function will fail if Timeline is not running. If false, % it will just return the time using Psychtoolbox GetSecs if it's not - % running. See also TL.PTBSECSTOTIMELINE(). + % running. + % See also TL.PTBSECSTOTIMELINE(). if nargin < 2; strict = true; end if obj.IsRunning secs = GetSecs - obj.CurrSysTimeTimelineOffset; @@ -329,6 +332,7 @@ function wiringInfo(obj, name) end function v = get.SamplingInterval(obj) + %GET.SAMPLINGINTERVAL Defined as the reciprocal of obj.DaqSampleRate v = 1/obj.DaqSampleRate; end @@ -350,6 +354,7 @@ function stop(obj) % TL.STOP() Deletes the listener, saves the aquired data, % stops all running DAQ sessions % + % See Also HW.TLOUTPUT/STOP if ~obj.IsRunning warning('Nothing to do, Timeline is not running!') return @@ -476,8 +481,11 @@ function stop(obj) end function s = getSessions(obj, name) + % GETSESSIONS() Returns the Sessions property % returns the Sessions property. Some things (e.g. output - % classes) need this. + % classes) need this. + % + % See Also HW.TLOUTPUT s = obj.Sessions(name); end @@ -489,13 +497,15 @@ function init(obj) % TL.INIT() creates all the DAQ sessions % and stores them in the Sessions map by their Outputs name. % Also add a 'main' session to which all input channels are - % added. See daq.createSession + % added. + % + % See Also DAQ.CREATESESSION - %%Create channels for each input + %%reate channels for each input [use, idx] = intersect({obj.Inputs.name}, obj.UseInputs);% find which inputs to use assert(numel(idx) == numel(obj.UseInputs), 'Not all inputs were recognised'); - inputSession = daq.createSession(obj.DaqVendor); - inputSession.Rate = obj.DaqSampleRate; + inputSession = daq.createSession(obj.DaqVendor); %create DAQ session for input aquisition + inputSession.Rate = obj.DaqSampleRate; % set the aquisition sample rate inputSession.IsContinuous = true; % once started, continue acquiring until manually stopped inputSession.NotifyWhenDataAvailableExceeds = obj.DaqSamplesPerNotify; % when to process data obj.Sessions('main') = inputSession; @@ -519,12 +529,12 @@ function init(obj) obj.Inputs(strcmp({obj.Inputs.name}, obj.UseInputs(i))).arrayColumn = i; end - % Initialize outputs - arrayfun(@(o)o.init(obj), obj.Outputs) + %Initialize outputs + arrayfun(@(out)out.init(obj), obj.Outputs) end - function process(obj, ~, event) - % Listener for processing acquired Timeline data + function process(obj, source, event) + % PROCESS() Listener for processing acquired Timeline data % TL.PROCESS(source, event) is a listener callback % function for handling tl data acquisition. Called by the % 'main' DAQ session with latest chunk of data. This is @@ -538,18 +548,18 @@ function process(obj, ~, event) % LastTimestamp is the time of the last scan in the previous % data chunk, and is used to ensure no data samples have been % lost. + % + % See Also HW.TLOUTPUT/PROCESS - % assert continuity of this data from previous + %Assert continuity of this data from previous assert(abs(event.TimeStamps(1) - obj.LastTimestamp - obj.SamplingInterval) < 1e-8,... 'Discontinuity of DAQ acquistion detected: last timestamp was %f and this one is %f',... obj.LastTimestamp, event.TimeStamps(1)); - % process methods for outputs - for outidx = 1:numel(obj.Outputs) - obj.Outputs(outidx).process(obj, event); - end + %Process methods for outputs + arrayfun(@(out)out.process(source, event), obj.Outputs); - %%% Store new samples into the timeline array + %Store new samples into the timeline array prevSampleCount = obj.Data.rawDAQSampleCount; newSampleCount = prevSampleCount + size(event.Data, 1); @@ -570,10 +580,9 @@ function process(obj, ~, event) datToWrite = cast(event.Data, obj.AquiredDataType); % Ensure data are the correct type fwrite(obj.DataFID, datToWrite', obj.AquiredDataType); % Write to file end - % if plotting the channels live, plot the new data + %If plotting the channels live, plot the new data if obj.LivePlot; obj.livePlot(event.Data); end - end function livePlot(obj, data) From 963fd5522e9b7bb3fddd078c2a99f9d5e0d57dbd Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 1 Feb 2018 10:41:48 +0000 Subject: [PATCH 057/507] More documentation and minor fixes --- +hw/TLOutputAcqLive.m | 6 +++++- +hw/TLOutputChrono.m | 33 +++++++++++++++++++-------------- +hw/TLOutputClock.m | 15 +++++++-------- +hw/Timeline.m | 36 +++++++++++++++++------------------- 4 files changed, 48 insertions(+), 42 deletions(-) diff --git a/+hw/TLOutputAcqLive.m b/+hw/TLOutputAcqLive.m index 314f73b0..5dbfaf82 100644 --- a/+hw/TLOutputAcqLive.m +++ b/+hw/TLOutputAcqLive.m @@ -1,4 +1,4 @@ -classdef TLOutputAcqLive < hw.TlOutput +classdef TLOutputAcqLive < hw.TLOutput %HW.TLOUTPUTACQLIVE A digital signal that goes up when the recording starts, % down when it ends. % Used for triggaring external instruments during data aquisition. Will @@ -47,7 +47,11 @@ function init(obj, ~) if obj.Enable fprintf(1, 'initializing %s\n', obj.toStr); obj.Session = daq.createSession(obj.DaqVendor); + % Turn off warning about clocked sampling availability + warning('off', 'daq:Session:onDemandOnlyChannelsAdded'); + % Add on-demand digital channel obj.Session.addDigitalChannel(obj.DaqDeviceID, obj.DaqChannelID, 'OutputOnly'); + warning('on', 'daq:Session:onDemandOnlyChannelsAdded'); outputSingleScan(obj.Session, false); % start in the off/false state end end diff --git a/+hw/TLOutputChrono.m b/+hw/TLOutputChrono.m index 609865ba..059d3a85 100644 --- a/+hw/TLOutputChrono.m +++ b/+hw/TLOutputChrono.m @@ -1,4 +1,4 @@ -classdef TLOutputChrono < hw.TlOutput +classdef TLOutputChrono < hw.TLOutput %HW.TLOUTPUTCHRONO Principle output channel class which sets timeline clock offset % Timeline uses this to monitor that acquisition is proceeding normally % during a recording and to update the synchronization between the @@ -26,6 +26,11 @@ NextChronoSign = 1 % The value to output on the chrono channel, the sign is changed each 'Process' event end + properties (SetAccess = private) + CurrSysTimeTimelineOffset = 0 % difference between the system time when the last chrono flip occured and the timestamp recorded by the DAQ, see tl.process() + LastClockSentSysTime % the mean of the system time before and after the last chrono flip. Used to calculate CurrSysTimeTimelineOffset, see tl.process() + end + methods function obj = TLOutputChrono(name, daqDeviceID, daqChannelID) % TLOUTPUTCHRONO Constructor method @@ -44,8 +49,11 @@ function init(obj, timeline) if obj.Enable fprintf(1, 'initializing %s\n', obj.toStr); obj.Session = daq.createSession(obj.DaqVendor); + % Turn off warning about clocked sampling availability + warning('off', 'daq:Session:onDemandOnlyChannelsAdded'); + % Add on-demand digital channel obj.Session.addDigitalChannel(obj.DaqDeviceID, obj.DaqChannelID, 'OutputOnly'); - + warning('on', 'daq:Session:onDemandOnlyChannelsAdded'); tls = timeline.getSessions('main'); %%Send a test pulse low, then high to clocking channel & check we read it back @@ -56,11 +64,11 @@ function init(obj, timeline) x2 = tls.inputSingleScan; assert(x1(timeline.Inputs(idx).arrayColumn) < 2.5 && x2(timeline.Inputs(idx).arrayColumn) > 2.5,... 'The clocking pulse test could not be read back'); - timeline.CurrSysTimeTimelineOffset = GetSecs; % to initialize this, will be a bit off but fixed after the first pulse + obj.CurrSysTimeTimelineOffset = GetSecs; % to initialize this, will be a bit off but fixed after the first pulse end end - function start(obj, timeline) + function start(obj, ~) % START Starts the first chrono flip % Called when timeline is started, this outputs the first low % voltage output on the chrono output channel @@ -69,8 +77,8 @@ function start(obj, timeline) if obj.Enable % If the object is to be used if obj.Verbose; fprintf(1, 'start %s\n', obj.name); end t = GetSecs; % system time before output - outputSingleScan(obj.session, false) % this will be the clocking pulse detected the first time process is called - timeline.LastClockSentSysTime = (t + GetSecs)/2; + outputSingleScan(obj.Session, false) % this will be the clocking pulse detected the first time process is called + obj.LastClockSentSysTime = (t + GetSecs)/2; end end @@ -83,9 +91,6 @@ function process(obj, timeline, event) % This is used by TL.TIME() to convert between system time and % acquisition time. % - % LastTimestamp is the time of the last scan in the previous data - % chunk, and is used to ensure no data samples have been lost. - % % See Also HW.TIMELINE/TIME() and HW.TIMELINE/PROCESS if obj.Enable && timeline.IsRunning && ~isempty(obj.Session) @@ -101,13 +106,13 @@ function process(obj, timeline, event) if obj.Verbose fprintf(1, ' CurrOffset=%.2f, LastClock=%.2f\n', ... - timeline.CurrSysTimeTimelineOffset, timeline.LastClockSentSysTime); + obj.CurrSysTimeTimelineOffset, obj.LastClockSentSysTime); end % Ensure the clocking pulse was detected if ~isempty(clockChangeIdx) clockChangeTimestamp = event.TimeStamps(clockChangeIdx); - timeline.CurrSysTimeTimelineOffset = timeline.LastClockSentSysTime - clockChangeTimestamp; + obj.CurrSysTimeTimelineOffset = obj.LastClockSentSysTime - clockChangeTimestamp; else warning('Rigging:Timeline:timing', 'clocking pulse not detected - probably lagging more than one data chunk'); end @@ -116,10 +121,10 @@ function process(obj, timeline, event) obj.NextChronoSign = -obj.NextChronoSign; % flip next chrono t = GetSecs; % system time before output outputSingleScan(obj.Session, obj.NextChronoSign > 0); % send next chrono flip - timeline.LastClockSentSysTime = (t + GetSecs)/2; % record mean before/after system time + obj.LastClockSentSysTime = (t + GetSecs)/2; % record mean before/after system time if obj.Verbose fprintf(1, ' CurrOffset=%.2f, LastClock=%.2f\n', ... - timeline.CurrSysTimeTimelineOffset, timeline.LastClockSentSysTime); + obj.CurrSysTimeTimelineOffset, obj.LastClockSentSysTime); end end end @@ -141,7 +146,7 @@ function stop(obj,~) function s = toStr(obj) % TOSTR Returns a string that describes the object succintly % - % See Also INIT + % See Also HW.TIMELINE/INIT s = sprintf('"%s" on %s/%s (chrono)', obj.Name, ... obj.DaqDeviceID, obj.DaqChannelID); end diff --git a/+hw/TLOutputClock.m b/+hw/TLOutputClock.m index 1e0210ec..4784f531 100644 --- a/+hw/TLOutputClock.m +++ b/+hw/TLOutputClock.m @@ -1,4 +1,4 @@ -classdef TLOutputClock < hw.TlOutput +classdef TLOutputClock < hw.TLOutput %HW.TLOUTPUTCLOCK A regular pulse at a specified frequency and duty % cycle. Can be used to trigger camera frames. % @@ -47,9 +47,8 @@ function init(obj, ~) % See Also HW.TIMELINE/INIT if obj.Enable fprintf(1, 'initializing %s\n', obj.toStr); - - obj.session = daq.createSession(obj.DaqVendor); - obj.session.IsContinuous = true; + obj.Session = daq.createSession(obj.DaqVendor); + obj.Session.IsContinuous = true; clocked = obj.Session.addCounterOutputChannel(obj.DaqDeviceID, obj.DaqChannelID, 'PulseGeneration'); clocked.Frequency = obj.Frequency; clocked.DutyCycle = obj.DutyCycle; @@ -66,7 +65,7 @@ function start(obj, ~) % See Also HW.TIMELINE/START if obj.Enable if obj.Verbose; fprintf(1, 'start %s\n', obj.Name); end - startBackground(obj.session); + startBackground(obj.Session); end end @@ -90,9 +89,9 @@ function stop(obj,~) % See Also HW.TIMELINE/STOP if obj.Enable if obj.Verbose; fprintf(1, 'stop %s\n', obj.Name); end - stop(obj.session); - release(obj.session); - obj.session = []; + stop(obj.Session); + release(obj.Session); + obj.Session = []; end end diff --git a/+hw/Timeline.m b/+hw/Timeline.m index d5862738..7d650fa6 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -5,13 +5,14 @@ % is called 'chrono' and consists of a digital squarewave that flips each % time a new chunk of data is availible from the DAQ (see % NotifyWhenDataAvailableExceeds for more information). A callback -% function to this event (see tl.process()) collects the timestamp from the -% DAQ of the precise scan where the chrono signal flipped. The +% function to this event (see tl.process()) collects the timestamp from +% the DAQ of the precise scan where the chrono signal flipped. The % difference between this and the system time recorded when the flip % command was given is recorded as the CurrSysTimeTimelineOffset and can % be used to unify all timestamps across computers during an experiment -% (see tl.time() and tl.ptbSecsToTimeline()). In is assumed that the -% time between sending the chrono pulse and recieving it is negligible. +% (see tl.time(), tl.ptbSecsToTimeline() and hw.TLOutputChrono). In is +% assumed that the time between sending the chrono pulse and recieving it +% is negligible. % % There are other available clocking signals, for instance: 'acqLive' and 'clock'. % The former outputs a high (+5V) signal the entire time tl is aquiring @@ -57,7 +58,7 @@ % memory limitations when aquiring a lot of data % - Delete local binary files once timeline has successfully saved to zserver? % -% See also HW.TIMELINECLOCK +% See also HW.TIMELINECLOCK, HW.TLOUTPUT % % Part of Rigbox @@ -69,7 +70,7 @@ DaqIds = 'Dev1' % Device ID can be found with daq.getDevices() DaqSampleRate = 1000 % rate at which daq aquires data in Hz, see Rate DaqSamplesPerNotify % determines the number of data samples to be processed each time, see Timeline.process(), constructor and NotifyWhenDataAvailableExceeds - Outputs = hw.tlOutputChrono('chrono', 'Dev1', 'port1/line0') % array of output classes, defining any signals you desire to be sent from the daq. See Also HW.TLOUTPUT, HW.TLOUTPUTCLOCK + Outputs = hw.TLOutputChrono('chrono', 'Dev1', 'port1/line0') % array of output classes, defining any signals you desire to be sent from the daq. See Also HW.TLOUTPUT, HW.TLOUTPUTCLOCK Inputs = struct('name', 'chrono',... 'arrayColumn', -1,... % -1 is default indicating unused, this is update when the channels are added during tl.start() 'daqChannelID', 'ai0',... @@ -84,14 +85,7 @@ LivePlotParams = []; WriteBufferToDisk = false % if true the data buffer is written to disk as they're aquired NB: in the future this will happen by default end - - properties (Transient) - % moved these here (i.e. unprotected) so chrono class can access - NS - CurrSysTimeTimelineOffset = 0 % difference between the system time when the last chrono flip occured and the timestamp recorded by the DAQ, see tl.process() - LastTimestamp % the last timestamp returned from the daq during the DataAvailable event. Used to check sampling continuity, see tl.process() - LastClockSentSysTime % the mean of the system time before and after the last chrono flip. Used to calculate CurrSysTimeTimelineOffset, see tl.process() - end - + properties (Dependent) SamplingInterval % defined as 1/DaqSampleRate IsRunning = false % flag is set to true when the first chrono pulse is aquired and set to false when tl is stopped (and everything saved), see tl.process and tl.stop @@ -99,7 +93,8 @@ properties (Transient, Access = protected) Listener % holds the listener for 'DataAvailable', see DataAvailable and Timeline.process() - Sessions = containers.Map % map of daq sessions and their channels, created at tl.start() + Sessions = containers.Map % map of daq sessions and their channels, created at tl.start() + LastTimestamp % the last timestamp returned from the daq during the DataAvailable event. Used to check sampling continuity, see tl.process() Ref % the expRef string. See tl.start() AlyxInstance % a struct contraining the Alyx token, user and url for ile registration. See tl.start() Data % A structure containing timeline data @@ -389,10 +384,13 @@ function stop(obj) % metadata saving, see below outputClasses = arrayfun(@class, obj.Outputs, 'uni', false); chronoChan = []; nextChrono = []; acqLiveChan = []; useClock = false; clockF = []; clockD = []; + LastClockSentSysTime = []; CurrSysTimeTimelineOffset = []; chronoOutputIdx = find(strcmp(outputClasses, 'hw.tlOutputChrono'),1); if ~isempty(chronoOutputIdx) chronoChan = obj.Outputs(chronoOutputIdx).daqChannelID; nextChrono = obj.Outputs(chronoOutputIdx).NextChronoSign; + LastClockSentSysTime = obj.Outputs(chronoOutputIdx).LastClockSentSysTime; + CurrSysTimeTimelineOffset = obj.Outputs(chronoOutputIdx).CurrSysTimeTimelineOffset; end acqLiveOutputIdx = find(strcmp(outputClasses, 'hw.tlOutputAcqLive'),1); if ~isempty(acqLiveOutputIdx) @@ -417,8 +415,8 @@ function stop(obj) obj.Data.isRunning = obj.IsRunning; obj.Data.nextChronoSign = nextChrono; obj.Data.lastTimestamp = obj.LastTimestamp; - obj.Data.lastClockSentSysTime = obj.LastClockSentSysTime; - obj.Data.currSysTimeTimelineOffset = obj.CurrSysTimeTimelineOffset; + obj.Data.lastClockSentSysTime = LastClockSentSysTime; + obj.Data.currSysTimeTimelineOffset = CurrSysTimeTimelineOffset; % saving hardware metadata for each output warning('off', 'MATLAB:structOnObject'); % sorry, don't care @@ -533,7 +531,7 @@ function init(obj) arrayfun(@(out)out.init(obj), obj.Outputs) end - function process(obj, source, event) + function process(obj, ~, event) % PROCESS() Listener for processing acquired Timeline data % TL.PROCESS(source, event) is a listener callback % function for handling tl data acquisition. Called by the @@ -557,7 +555,7 @@ function process(obj, source, event) obj.LastTimestamp, event.TimeStamps(1)); %Process methods for outputs - arrayfun(@(out)out.process(source, event), obj.Outputs); + arrayfun(@(out)out.process(obj, event), obj.Outputs); %Store new samples into the timeline array prevSampleCount = obj.Data.rawDAQSampleCount; From 20903ef00a1e3906140a540d5ea23b26520afd4d Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 1 Feb 2018 13:46:08 +0000 Subject: [PATCH 058/507] Can now instantiate new timeline object with a previous hw struct --- +hw/TLOutputAcqLive.m | 28 ++++++++++++++++++++++------ +hw/TLOutputChrono.m | 22 ++++++++++++++++++---- +hw/TLOutputClock.m | 27 ++++++++++++++++++++++----- +hw/Timeline.m | 41 +++++++++++++++++++++++++---------------- 4 files changed, 87 insertions(+), 31 deletions(-) diff --git a/+hw/TLOutputAcqLive.m b/+hw/TLOutputAcqLive.m index 5dbfaf82..faf6a999 100644 --- a/+hw/TLOutputAcqLive.m +++ b/+hw/TLOutputAcqLive.m @@ -30,11 +30,27 @@ end methods - function obj = TLOutputAcqLive(name, daqDeviceID, daqChannelID) + function obj = TLOutputAcqLive(hw) % TLOUTPUTCHRONO Constructor method - obj.Name = name; - obj.DaqDeviceID = daqDeviceID; - obj.DaqChannelID = daqChannelID; + % Can take the struct form of a previous instance (as saved in the + % Timeline hw struct) to intantiate a new object with the same + % properties. + % + % See Also HW.TIMELINE + if nargin + obj.Name = hw.Name; + obj.DaqDeviceID = hw.DaqDeviceID; + obj.DaqVendor = hw.DaqVendor; + obj.DaqChannelID = hw.DaqChannelID; + obj.InitialDelay = hw.InitialDelay; + obj.PulseDuration = hw.PulseDuration; + obj.Enable = hw.Enable; + obj.Verbose = hw.Verbose; + else % Some safe defaults + obj.Name = 'Acquire Live'; + obj.DaqDeviceID = 'Dev1'; + obj.DaqChannelID = 'port1/line2'; + end end function init(obj, ~) @@ -109,8 +125,8 @@ function stop(obj,~) % TOSTR Returns a string that describes the object succintly % % See Also INIT - s = sprintf('"%s" on %s/%s (acqLive, initial delay %.2f)', obj.Name, ... - obj.DaqDeviceID, obj.DaqChannelID, obj.InitialDelay); + s = sprintf('"%s" on %s/%s (acqLive, initial delay %.2f, pulse duration %.2f)',... + obj.Name, obj.DaqDeviceID, obj.DaqChannelID, obj.InitialDelay, obj.PulseDuration); end end diff --git a/+hw/TLOutputChrono.m b/+hw/TLOutputChrono.m index 059d3a85..373cad10 100644 --- a/+hw/TLOutputChrono.m +++ b/+hw/TLOutputChrono.m @@ -32,11 +32,25 @@ end methods - function obj = TLOutputChrono(name, daqDeviceID, daqChannelID) + function obj = TLOutputChrono(hw) % TLOUTPUTCHRONO Constructor method - obj.Name = name; - obj.DaqDeviceID = daqDeviceID; - obj.DaqChannelID = daqChannelID; + % Can take the struct form of a previous instance (as saved in the + % Timeline hw struct) to intantiate a new object with the same + % properties. + % + % See Also HW.TIMELINE + if nargin + obj.Name = hw.Name; + obj.DaqDeviceID = hw.DaqDeviceID; + obj.DaqVendor = hw.DaqVendor; + obj.DaqChannelID = hw.DaqChannelID; + obj.Enable = hw.Enable; + obj.Verbose = hw.Verbose; + else % Some safe defaults + obj.Name = 'Chrono'; + obj.DaqDeviceID = 'Dev1'; + obj.DaqChannelID = 'port1/line0'; + end end function init(obj, timeline) diff --git a/+hw/TLOutputClock.m b/+hw/TLOutputClock.m index 4784f531..b258ae9e 100644 --- a/+hw/TLOutputClock.m +++ b/+hw/TLOutputClock.m @@ -19,7 +19,7 @@ properties DaqDeviceID % The name of the DAQ device ID, e.g. 'Dev1', see DAQ.GETDEVICES - DaqChannelID % The name of the DAQ channel ID, e.g. 'port1/line0', see DAQ.GETDEVICES + DaqChannelID % The name of the DAQ channel ID, e.g. 'ctr0', see DAQ.GETDEVICES DaqVendor = 'ni' % Name of the DAQ vendor InitialDelay = 0 % delay from session start to clock output Frequency = 60; % Hz, of the clocking pulse @@ -31,11 +31,28 @@ end methods - function obj = TLOutputClock(name, daqDeviceID, daqChannelID) + function obj = TLOutputClock(hw) % TLOUTPUTCHRONO Constructor method - obj.Name = name; - obj.DaqDeviceID = daqDeviceID; - obj.DaqChannelID = daqChannelID; + % Can take the struct form of a previous instance (as saved in the + % Timeline hw struct) to intantiate a new object with the same + % properties. + % + % See Also HW.TIMELINE + if nargin + obj.Name = hw.Name; + obj.DaqDeviceID = hw.DaqDeviceID; + obj.DaqVendor = hw.DaqVendor; + obj.DaqChannelID = hw.DaqChannelID; + obj.InitialDelay = hw.InitialDelay; + obj.Frequency = hw.Frequency; + obj.DutyCycle = hw.DutyCycle; + obj.Enable = hw.Enable; + obj.Verbose = hw.Verbose; + else % Some safe defaults + obj.Name = 'Clock'; + obj.DaqDeviceID = 'Dev1'; + obj.DaqChannelID = 'ctr0'; + end end function init(obj, ~) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 7d650fa6..6b208f78 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -70,19 +70,20 @@ DaqIds = 'Dev1' % Device ID can be found with daq.getDevices() DaqSampleRate = 1000 % rate at which daq aquires data in Hz, see Rate DaqSamplesPerNotify % determines the number of data samples to be processed each time, see Timeline.process(), constructor and NotifyWhenDataAvailableExceeds - Outputs = hw.TLOutputChrono('chrono', 'Dev1', 'port1/line0') % array of output classes, defining any signals you desire to be sent from the daq. See Also HW.TLOUTPUT, HW.TLOUTPUTCLOCK + Outputs % array of output classes, defining any signals you desire to be sent from the daq. See Also HW.TLOUTPUT, HW.TLOUTPUTCLOCK Inputs = struct('name', 'chrono',... 'arrayColumn', -1,... % -1 is default indicating unused, this is update when the channels are added during tl.start() 'daqChannelID', 'ai0',... 'measurement', 'Voltage',... - 'terminalConfig', 'SingleEnded') + 'terminalConfig', 'SingleEnded',... + 'figureScale', 1) UseInputs = {'chrono'} % array of inputs to record while tl is running StopDelay = 2 % currently pauses for at least 2 secs as 'hack' before stopping main DAQ session MaxExpectedDuration = 2*60*60 % expected experiment time so data structure is initialised to sensible size (in secs) AquiredDataType = 'double' % default data type for the acquired data array (i.e. Data.rawDAQData) UseTimeline = false % used by expServer. If true, timeline is started by default (otherwise can be toggled with the t key) LivePlot = false % if true the data are plotted as the data are aquired - LivePlotParams = []; + FigureScale = []; % figure position in normalized units, default is [0 0 1 1] (full screen) WriteBufferToDisk = false % if true the data buffer is written to disk as they're aquired NB: in the future this will happen by default end @@ -112,11 +113,17 @@ obj.DaqSamplesPerNotify = 1/obj.SamplingInterval; % calculate DaqSamplesPerNotify if nargin % if old tl hardware struct provided, use these to populate properties + % Configure the inputs obj.Inputs = hw.inputs; obj.DaqVendor = hw.daqVendor; obj.DaqIds = hw.daqDevice; obj.DaqSampleRate = hw.daqSampleRate; obj.DaqSamplesPerNotify = hw.daqSamplesPerNotify; + % Configure the outputs + outputs = catStructs(hw.Outputs); + obj.Outputs = objfun(@(o)eval([o.Class '(o)']), outputs, 'Uni', false); + obj.Outputs = [obj.Outputs{:}]; + else end end @@ -231,7 +238,8 @@ function record(obj, name, event, t) % See also TL.PTBSECSTOTIMELINE(). if nargin < 2; strict = true; end if obj.IsRunning - secs = GetSecs - obj.CurrSysTimeTimelineOffset; + idx = arrayfun(@(out)isa(out, 'hw.TLOutputChrono'), obj.Outputs); + secs = GetSecs - obj.Outputs(idx).CurrSysTimeTimelineOffset; elseif strict error('Tried to use Timeline clock when Timeline is not running'); else @@ -247,7 +255,8 @@ function record(obj, name, event, t) % from Pyschtoolbox's functions and converts to Timeline-relative time. % See also TL.TIME(). assert(obj.IsRunning, 'Timeline is not running.'); - secs = secs - obj.CurrSysTimeTimelineOffset; + idx = arrayfun(@(out)isa(out, 'hw.TLOutputChrono'), obj.Outputs); + secs = secs - obj.Outputs(idx).CurrSysTimeTimelineOffset; end function addInput(obj, name, channelID, measurement, terminalConfig, use) @@ -385,22 +394,22 @@ function stop(obj) outputClasses = arrayfun(@class, obj.Outputs, 'uni', false); chronoChan = []; nextChrono = []; acqLiveChan = []; useClock = false; clockF = []; clockD = []; LastClockSentSysTime = []; CurrSysTimeTimelineOffset = []; - chronoOutputIdx = find(strcmp(outputClasses, 'hw.tlOutputChrono'),1); + chronoOutputIdx = find(strcmp(outputClasses, 'hw.TLOutputChrono'),1); if ~isempty(chronoOutputIdx) - chronoChan = obj.Outputs(chronoOutputIdx).daqChannelID; + chronoChan = obj.Outputs(chronoOutputIdx).DaqChannelID; nextChrono = obj.Outputs(chronoOutputIdx).NextChronoSign; LastClockSentSysTime = obj.Outputs(chronoOutputIdx).LastClockSentSysTime; CurrSysTimeTimelineOffset = obj.Outputs(chronoOutputIdx).CurrSysTimeTimelineOffset; end - acqLiveOutputIdx = find(strcmp(outputClasses, 'hw.tlOutputAcqLive'),1); + acqLiveOutputIdx = find(strcmp(outputClasses, 'hw.TLOutputAcqLive'),1); if ~isempty(acqLiveOutputIdx) - acqLiveChan = obj.Outputs(acqLiveOutputIdx).daqChannelID; + acqLiveChan = obj.Outputs(acqLiveOutputIdx).DaqChannelID; end - clockOutputIdx = find(strcmp(outputClasses, 'hw.tlOutputClock'),1); + clockOutputIdx = find(strcmp(outputClasses, 'hw.TLOutputClock'),1); if ~isempty(clockOutputIdx) useClock = true; - clockF = obj.Outputs(clockOutputIdx).frequency; - clockD = obj.Outputs(clockOutputIdx).dutyCycle; + clockF = obj.Outputs(clockOutputIdx).Frequency; + clockD = obj.Outputs(clockOutputIdx).DutyCycle; end % legacy metadata @@ -422,7 +431,7 @@ function stop(obj) warning('off', 'MATLAB:structOnObject'); % sorry, don't care for outIdx = 1:numel(obj.Outputs) s = struct(obj.Outputs(outIdx)); - s.class = class(obj.Outputs(outIdx)); + s.Class = class(obj.Outputs(outIdx)); obj.Data.hw.Outputs{outIdx} = s; end warning('on', 'MATLAB:structOnObject'); @@ -588,10 +597,10 @@ function livePlot(obj, data) % TL.LIVEPLOT(source, event) plots the data aquired by the % DAQ while the PlotLive property is true. if isempty(obj.Axes) - f = figure(); % create a figure for plotting aquired data + f = figure('Units', 'Normalized', 'Position', [0 0 1 1]); % create a figure for plotting aquired data obj.Axes = gca; % store a handle to the axes - if isfield(obj.LivePlotParams, 'figPosition') && ~isempty(obj.LivePlotParams.figPosition) - set(f, 'Position', obj.LivePlotParams.figPosition); % set the figure position + if isprop(obj, 'FigurePosition') && ~isempty(obj.FigurePosition) + set(f, 'Position', obj.FigurePosition); % set the figure position end end From 789ed31267e5e81747d535e219596e5c073f6570 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 1 Feb 2018 13:50:43 +0000 Subject: [PATCH 059/507] Deletes ClockChan on stop --- +hw/TLOutputClock.m | 1 + 1 file changed, 1 insertion(+) diff --git a/+hw/TLOutputClock.m b/+hw/TLOutputClock.m index b258ae9e..624bb764 100644 --- a/+hw/TLOutputClock.m +++ b/+hw/TLOutputClock.m @@ -109,6 +109,7 @@ function stop(obj,~) stop(obj.Session); release(obj.Session); obj.Session = []; + obj.ClockChan = []; end end From 6b007d6e0b37607ac70c7e57d65ba44bf7c72c20 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 1 Feb 2018 14:07:42 +0000 Subject: [PATCH 060/507] Change to axes scaling --- +hw/Timeline.m | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 6b208f78..a4942e07 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -76,7 +76,7 @@ 'daqChannelID', 'ai0',... 'measurement', 'Voltage',... 'terminalConfig', 'SingleEnded',... - 'figureScale', 1) + 'axesScale', 1) % multiplicative vertical scaling for when live plotting the input UseInputs = {'chrono'} % array of inputs to record while tl is running StopDelay = 2 % currently pauses for at least 2 secs as 'hack' before stopping main DAQ session MaxExpectedDuration = 2*60*60 % expected experiment time so data structure is initialised to sensible size (in secs) @@ -259,7 +259,8 @@ function record(obj, name, event, t) secs = secs - obj.Outputs(idx).CurrSysTimeTimelineOffset; end - function addInput(obj, name, channelID, measurement, terminalConfig, use) + function addInput(obj, name, channelID, measurement,... + terminalConfig, axesScale, use) % Add a new input to the object's Input property % ADDINPUT(name, channelID, measurement, terminalConfig, use) % adds a new input 'name' to the Inputs list. If use is @@ -269,8 +270,11 @@ function addInput(obj, name, channelID, measurement, terminalConfig, use) % DAQ default for that port if nargin < 5; terminalConfig = []; end + % if use is not specified, assume user wants normal scaling + if nargin < 6; axesScale = 1; end + % if use is not specified, assume user wants to record input - if nargin < 6; use = true; end + if nargin < 7; use = true; end assert(~any(strcmp(name, {obj.Inputs.name})),... 'An input by the name of ''%s'' has already been added.', name); @@ -297,7 +301,8 @@ function addInput(obj, name, channelID, measurement, terminalConfig, use) 'arrayColumn', -1,... % -1 is default indicating unused 'daqChannelID', channelID,... 'measurement', measurement,... - 'terminalConfig', terminalConfig); + 'terminalConfig', terminalConfig,... + 'axesScale', axesScale); obj.Inputs = [obj.Inputs s]; % add the new input if use; obj.UseInputs = [obj.UseInputs {name}]; end % add to UseInputs @@ -616,10 +621,7 @@ function livePlot(obj, data) % (multiplicative) and can be set manually in the config. A % nicer future version would put a scroll wheel callback on the % figure and scale by scrolling the one that's hovered over - scales = ones(1, nChans); - if isfield(obj.LivePlotParams, 'figScales') && ~isempty(obj.LivePlotParams.figScales) - scales(1:numel(obj.LivePlotParams.figScales)) = obj.LivePlotParams.figScales; - end + scales = pick([obj.Inputs.axesScale], cellfun(@(x)find(strcmp({obj.Inputs.name}, x),1), obj.UseInputs)); traces = get(obj.Axes, 'Children'); if isempty(traces) From af54939731d905dbc2efd3e2f87defbc8cb292a8 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 1 Feb 2018 14:26:40 +0000 Subject: [PATCH 061/507] Better output defaults --- +hw/Timeline.m | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index a4942e07..a1b7cedd 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -70,7 +70,7 @@ DaqIds = 'Dev1' % Device ID can be found with daq.getDevices() DaqSampleRate = 1000 % rate at which daq aquires data in Hz, see Rate DaqSamplesPerNotify % determines the number of data samples to be processed each time, see Timeline.process(), constructor and NotifyWhenDataAvailableExceeds - Outputs % array of output classes, defining any signals you desire to be sent from the daq. See Also HW.TLOUTPUT, HW.TLOUTPUTCLOCK + Outputs = hw.TLOutputChrono % array of output classes, defining any signals you desire to be sent from the daq. See Also HW.TLOUTPUT, HW.TLOUTPUTCLOCK Inputs = struct('name', 'chrono',... 'arrayColumn', -1,... % -1 is default indicating unused, this is update when the channels are added during tl.start() 'daqChannelID', 'ai0',... @@ -123,7 +123,6 @@ outputs = catStructs(hw.Outputs); obj.Outputs = objfun(@(o)eval([o.Class '(o)']), outputs, 'Uni', false); obj.Outputs = [obj.Outputs{:}]; - else end end From da45bd4195cf9b6f343577e1d89c201ae6a2a82f Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 1 Feb 2018 17:07:24 +0000 Subject: [PATCH 062/507] Stopping outputs now happens after stop delay! --- +hw/Timeline.m | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index a1b7cedd..5b0a2de3 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -367,11 +367,10 @@ function stop(obj) warning('Nothing to do, Timeline is not running!') return end - - % kill acquisition output signals - arrayfun(@stop, obj.Outputs) - pause(obj.StopDelay) + + % stop acquisition output signals + arrayfun(@stop, obj.Outputs) % stop actual DAQ aquisition stop(obj.Sessions('main')); From 393c3468c631f27db0f52947d3b4fb70748a5dcd Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 1 Feb 2018 17:39:16 +0000 Subject: [PATCH 063/507] Updated the CW defaults to be more reasonable --- cortexlab/+exp/choiceWorldParams.m | 33 +++++++++++++++--------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/cortexlab/+exp/choiceWorldParams.m b/cortexlab/+exp/choiceWorldParams.m index 377678fc..cbd07930 100644 --- a/cortexlab/+exp/choiceWorldParams.m +++ b/cortexlab/+exp/choiceWorldParams.m @@ -25,17 +25,17 @@ '''Reward'' volume delivered after stimulus onset'); % trial temporal structure -setValue('onsetVisStimDelay', 0.1, 's',... +setValue('onsetVisStimDelay', 0, 's',... 'Duration between the start of the onset tone and visual stimulus presentation'); setValue('onsetToneDuration', 0.1, 's',... 'Duration of the onset tone'); setValue('onsetToneRampDuration', 0.01, 's',... 'Duration of the onset tone amplitude ramp (up and down each this length)'); -setValue('preStimQuiescentPeriod', [2; 3], 's',... +setValue('preStimQuiescentPeriod', [0.2; 0.6], 's',... 'Required period of no input before stimulus presentation'); -setValue('bgCueDelay', 0.3, 's',... +setValue('bgCueDelay', 0, 's',... 'Delay period between target column presentation and grating cue'); -setValue('cueInteractiveDelay', 1, 's',... +setValue('cueInteractiveDelay', 0, 's',... 'Delay period between grating cue presentation and interactive phase'); setValue('hideCueDelay', inf, 's',... 'Delay period between cue presentation onset to hide it'); @@ -47,7 +47,7 @@ 'Duration of negative feedback phase (with stimulus locked in response position)'); setValue('feedbackDeliveryDelay', 0, 's',... 'Delay period between response completion and feedback provided'); -setValue('negFeedbackSoundDuration', 2, 's',... +setValue('negFeedbackSoundDuration', 0.5, 's',... 'Duration of negative feedback noise burst'); setValue('interTrialDelay', 0, 's', 'Delay between trials'); setValue('waitOnEarlyResponse', false, 'logical',... @@ -58,7 +58,7 @@ % visual stimulus characteristics setValue('targetWidth', 35, '�',... 'Width of target columns (visual angle)'); -setValue('distBetweenTargets', 80, '�',... +setValue('distBetweenTargets', 180, '�',... 'Width between target column centres (visual angle)'); setValue('targetAltitude', 0, '�',... 'Visual angle of target centre above horizon'); @@ -66,31 +66,31 @@ 'Horizontal translation of targets to reach response threshold (visual angle)'); setValue('cueSpatialFrequency', 0.1, 'cyc/�',... 'Spatial frequency of grating cue at the initial location on the horizon'); -setValue('cueSigma', [5; 5], '�',... +setValue('cueSigma', [9; 9], '�',... 'Size (w,h) of the grating, in terms of Gabor ? parameter (visual angle)'); -setValue('targetOrientation', 90, '�',... +setValue('targetOrientation', 45, '�',... 'Orientation of gabor grating (cw from horizontal)'); setValue('bgColour', 0*255*ones(3, 1), 'rgb',... 'Colour of background area'); setValue('targetColour', 0.5*255*ones(3, 1), 'rgb',... 'Colour of target columns'); -setValue('visWheelGain', 3, '�/mm',... +setValue('visWheelGain', 3.5, '�/mm',... 'Visual stimulus translation per movement at wheel surface (for stimuli ahead)'); % audio setValue('onsetToneMaxAmp', 1, 'normalised',... 'Maximum amplitude of onset tone'); -setValue('onsetToneFreq', 12e3, 'Hz',... +setValue('onsetToneFreq', 11e3, 'Hz',... 'Frequency of the onset tone'); -setValue('negFeedbackSoundAmp', 0.025, 'normalised',... +setValue('negFeedbackSoundAmp', 0.01, 'normalised',... 'Amplitude of negative feedback noise burst'); % misc -setValue('quiescenceThreshold', 1, 'sensor units',... +setValue('quiescenceThreshold', 10, 'sensor units',... 'Input movement must be under this threshold to count as quiescent'); %% configure trial-specific parameters -contrast = [0.5 0.4 0.2 0.1 0.05 0]; % contrast list to use on one side or the other +contrast = [1 0.5 0.25 0.12 0.06 0]; % contrast list to use on one side or the other % compute contrast one each target - ones side has contrast, other has zero targetCon = [contrast, zeros(1, numel(contrast));... zeros(1, numel(contrast)), contrast]; @@ -100,12 +100,11 @@ -ones(1, numel(contrast)), ones(1, numel(contrast)); ... -ones(1, numel(contrast) - 1) 1 -ones(1, numel(contrast) - 1) 1]; % repeat all incorrect trials except zero contrast ones -repIncorrect = abs(diff(targetCon)) > 0; +repIncorrect = abs(diff(targetCon)) > 0.25; % by default only use only the 50% contrast condition -useConditions = abs(diff(targetCon)) == 0.5 | abs(diff(targetCon)) == 0.2... - | abs(diff(targetCon)) == 0.1; +useConditions = abs(diff(targetCon)) == 0.5 | abs(diff(targetCon)) == 1; % uniform repeats for at least 300 trials -nReps = ceil(300*useConditions./sum(useConditions)); +nReps = ceil(1000*useConditions./sum(useConditions)); setValue('visCueContrast', targetCon, 'normalised', 'Contrast of grating cue at each target'); setValue('feedbackForResponse', feedback, 'normalised', 'Feedback given for each target'); setValue('repeatIncorrectTrial', repIncorrect, 'logical',... From d6c5f2a38e23aa875ccadfc7fe6793a565a4bf73 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 1 Feb 2018 17:59:57 +0000 Subject: [PATCH 064/507] Little change to doc --- +hw/Timeline.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 5b0a2de3..e03e212a 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -40,7 +40,7 @@ % %Set tl to be started by default % timeline.UseTimeline = true; % %To set up chrono a wire must bridge the terminals defined in -% timeline.Outputs.daqChannelID and timeline.Inputs.daqChannelID +% timeline.Outputs(1).DaqChannelID and timeline.Inputs(1).daqChannelID % timeline.wiringInfo('chrono'); % %Add the rotary encoder % timeline.addInput('rotaryEncoder', 'ctr0', 'Position'); From 2adcb1546775688e80d6b20eaa184da0394f080e Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 2 Feb 2018 10:42:45 +0000 Subject: [PATCH 065/507] Added timer function for executing delayed outputs --- +hw/TLOutputAcqLive.m | 21 ++++++++++++++++++++- +hw/TLOutputClock.m | 21 +++++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/+hw/TLOutputAcqLive.m b/+hw/TLOutputAcqLive.m index faf6a999..a08b2d4b 100644 --- a/+hw/TLOutputAcqLive.m +++ b/+hw/TLOutputAcqLive.m @@ -29,6 +29,10 @@ PulseDuration = Inf; % sec, time that the pulse is on at beginning and end end + properties (Transient, Access = private) + Timer + end + methods function obj = TLOutputAcqLive(hw) % TLOUTPUTCHRONO Constructor method @@ -69,6 +73,13 @@ function init(obj, ~) obj.Session.addDigitalChannel(obj.DaqDeviceID, obj.DaqChannelID, 'OutputOnly'); warning('on', 'daq:Session:onDemandOnlyChannelsAdded'); outputSingleScan(obj.Session, false); % start in the off/false state + % If the initial delay is greater than zero, create a timer for + % starting the signal late + if obj.InitialDelay > 0 + obj.Timer = timer('StartDelay', obj.InitialDelay); + obj.Timer.TimerFcn = @(~,~)obj.start(); + obj.Timer.StopFcn = @(src,~)delete(src); + end end end @@ -79,8 +90,15 @@ function start(obj, ~) % % See Also HW.TIMELINE/START if obj.Enable + % If the initial delay is greater than 0 and the timer is empty, + % create and start the timer + if ~isempty(obj.Timer) && obj.InitialDelay > 0 ... + && strcmp(obj.Timer.Running, 'off') + start(obj.Timer); % wait for some duration before starting + return + end + if obj.Verbose; fprintf(1, 'start %s\n', obj.Name); end - pause(obj.InitialDelay); % wait for some duration before starting outputSingleScan(obj.Session, true); % set digital output true: acquisition is "live" if obj.PulseDuration ~= Inf pause(obj.PulseDuration); @@ -118,6 +136,7 @@ function stop(obj,~) stop(obj.Session); release(obj.Session); obj.Session = []; + obj.Timer = []; end end diff --git a/+hw/TLOutputClock.m b/+hw/TLOutputClock.m index 624bb764..487b8e0c 100644 --- a/+hw/TLOutputClock.m +++ b/+hw/TLOutputClock.m @@ -28,6 +28,7 @@ properties (Transient, Hidden, Access = protected) ClockChan % Holds an instance of the PulseGeneration channel + Timer end methods @@ -71,6 +72,14 @@ function init(obj, ~) clocked.DutyCycle = obj.DutyCycle; clocked.InitialDelay = obj.InitialDelay; obj.ClockChan = clocked; + + % If the initial delay is greater than zero, create a timer for + % starting the signal late + if obj.InitialDelay > 0 + obj.Timer = timer('StartDelay', obj.InitialDelay); + obj.Timer.TimerFcn = @(~,~)obj.start(); + obj.Timer.StopFcn = @(src,~)delete(src); + end end end @@ -81,8 +90,15 @@ function start(obj, ~) % % See Also HW.TIMELINE/START if obj.Enable - if obj.Verbose; fprintf(1, 'start %s\n', obj.Name); end - startBackground(obj.Session); + % If the initial delay is greater than 0 and the timer is empty, + % create and start the timer + if ~isempty(obj.Timer) && obj.InitialDelay > 0 ... + && strcmp(obj.Timer.Running, 'off') + start(obj.Timer); % wait for some duration before starting + return + end + if obj.Verbose; fprintf(1, 'start %s\n', obj.Name); end + startBackground(obj.Session); end end @@ -110,6 +126,7 @@ function stop(obj,~) release(obj.Session); obj.Session = []; obj.ClockChan = []; + obj.Timer = []; end end From 5689c4b3ce8f6220cf0922f5db34cbf601f673b5 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 2 Feb 2018 19:19:21 +0000 Subject: [PATCH 066/507] Some minor changes dar.newExp now doesn't try to create any sessions when not logged in --- +dat/newExp.m | 131 +++++++++++++++++++++--------------------- +hw/TLOutputAcqLive.m | 4 +- +hw/TLOutputClock.m | 6 +- 3 files changed, 70 insertions(+), 71 deletions(-) diff --git a/+dat/newExp.m b/+dat/newExp.m index 6e2c0720..6a86e07f 100644 --- a/+dat/newExp.m +++ b/+dat/newExp.m @@ -1,9 +1,9 @@ function [expRef, expSeq, url] = newExp(subject, expDate, expParams, AlyxInstance) %DAT.NEWEXP Create a new unique experiment in the database -% [ref, seq, url] = DAT.NEWEXP(subject, expDate, expParams[, AlyxInstance]) +% [ref, seq, url] = DAT.NEWEXP(subject, expDate, expParams[, AlyxInstance]) % 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/ @@ -66,14 +66,14 @@ % now make the folder(s) to hold the new experiment assert(all(cellfun(@(p) mkdir(p), expPath)), 'Creating experiment directories failed'); -if ~strcmp(subject,'default') % Ignore fake subject +if ~strcmp(subject, 'default') % Ignore fake subject % if the Alyx Instance is set, find or create BASE session expDate = alyx.datestr(expDate); % date in Alyx format if ~isempty(AlyxInstance) % Get list of base sessions sessions = alyx.getData(AlyxInstance,... - ['sessions?type=Base&subject=' subject]); - + ['sessions?type=Base&subject=' subject]); + %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), expDate(1:10)) @@ -83,75 +83,74 @@ d.narrative = 'auto-generated session'; d.start_time = expDate; d.type = 'Base'; -% d.users = {AlyxInstance.username}; - + % d.users = {AlyxInstance.username}; + base_submit = alyx.postData(AlyxInstance, 'sessions', d); assert(isfield(base_submit,'subject'),... - 'Submitted base session did not return appropriate values'); - + 'Submitted base session did not return appropriate values'); + %Now retrieve the sessions again sessions = alyx.getData(AlyxInstance,... - ['sessions?type=Base&subject=' subject]); + ['sessions?type=Base&subject=' subject]); end latest_base = sessions{end}; - else % If not logged in to Alyx... - latest_base.url = []; % set the base url to null - end - %Now create a new SUBSESSION, using the same experiment number - d = struct; - d.subject = subject; - d.procedures = {'Behavior training/tasks'}; - d.narrative = 'auto-generated session'; - d.start_time = expDate; - d.type = 'Experiment'; - d.parent_session = latest_base.url; - d.number = expSeq; -% d.users = {AlyxInstance.username}; + %Now create a new SUBSESSION, using the same experiment number + d = struct; + d.subject = subject; + d.procedures = {'Behavior training/tasks'}; + d.narrative = 'auto-generated session'; + d.start_time = expDate; + d.type = 'Experiment'; + d.parent_session = latest_base.url; + d.number = expSeq; + % d.users = {AlyxInstance.username}; - subsession = alyx.postData(AlyxInstance, 'sessions', d); - assert(isfield(subsession,'subject'),... - 'Failed to create new sub-session in Alyx for %s', subject); - url = subsession.url; -else - url = []; -end - -% if the parameters had an experiment definition function, save a copy in -% the experiment's folder -if isfield(expParams, 'defFunction') - assert(file.exists(expParams.defFunction),... - 'Experiment definition function does not exist: %s', expParams.defFunction); - assert(all(cellfun(@(p)copyfile(expParams.defFunction, p),... - dat.expFilePath(expRef, 'expDefFun'))),... - 'Copying definition function to experiment folders failed'); -end - -% now save the experiment parameters variable -superSave(dat.expFilePath(expRef, 'parameters'), struct('parameters', expParams)); - -try % save a copy of parameters in json - % First, change all functions to strings - f_idx = structfun(@(s)isa(s, 'function_handle'), expParams); - fields = fieldnames(expParams); - paramCell = struct2cell(expParams); - paramCell(f_idx) = cellfun(@func2str, paramCell(f_idx),'UniformOutput', false); - expParams = cell2struct(paramCell, fields); - % Generate JSON path and save - jsonPath = fullfile(fileparts(dat.expFilePath(expRef, 'parameters', 'master')),... - [expRef, '_parameters.json']); - savejson('parameters', expParams, jsonPath); - % Register our JSON parameter set to Alyx - if ~strcmp(subject,'default') - alyx.registerFile(jsonPath, 'json', url, 'Parameters', [], AlyxInstance); + try + subsession = alyx.postData(AlyxInstance, 'sessions', d); + url = subsession.url; + catch + url = []; + end + else % If not logged in to Alyx... + url = []; % set the base url to null end -catch ex - warning(ex.identifier, 'Failed to save paramters as JSON: %s.\n Registering mat file instead', ex.message) - % Register our parameter set to Alyx - if ~strcmp(subject,'default') - alyx.registerFile(dat.expFilePath(expRef, 'parameters', 'master'), 'mat',... - url, 'Parameters', [], AlyxInstance); + + % if the parameters had an experiment definition function, save a copy in + % the experiment's folder + if isfield(expParams, 'defFunction') + assert(file.exists(expParams.defFunction),... + 'Experiment definition function does not exist: %s', expParams.defFunction); + assert(all(cellfun(@(p)copyfile(expParams.defFunction, p),... + dat.expFilePath(expRef, 'expDefFun'))),... + 'Copying definition function to experiment folders failed'); end -end - + + % now save the experiment parameters variable + superSave(dat.expFilePath(expRef, 'parameters'), struct('parameters', expParams)); + + try % save a copy of parameters in json + % First, change all functions to strings + f_idx = structfun(@(s)isa(s, 'function_handle'), expParams); + fields = fieldnames(expParams); + paramCell = struct2cell(expParams); + paramCell(f_idx) = cellfun(@func2str, paramCell(f_idx),'UniformOutput', false); + expParams = cell2struct(paramCell, fields); + % Generate JSON path and save + jsonPath = fullfile(fileparts(dat.expFilePath(expRef, 'parameters', 'master')),... + [expRef, '_parameters.json']); + savejson('parameters', expParams, jsonPath); + % Register our JSON parameter set to Alyx + if ~strcmp(subject,'default') + alyx.registerFile(jsonPath, 'json', url, 'Parameters', [], AlyxInstance); + end + catch ex + warning(ex.identifier, 'Failed to save paramters as JSON: %s.\n Registering mat file instead', ex.message) + % Register our parameter set to Alyx + if ~strcmp(subject,'default') + alyx.registerFile(dat.expFilePath(expRef, 'parameters', 'master'), 'mat',... + url, 'Parameters', [], AlyxInstance); + end + end + end \ No newline at end of file diff --git a/+hw/TLOutputAcqLive.m b/+hw/TLOutputAcqLive.m index a08b2d4b..6d2cd20e 100644 --- a/+hw/TLOutputAcqLive.m +++ b/+hw/TLOutputAcqLive.m @@ -25,8 +25,8 @@ DaqDeviceID % The name of the DAQ device ID, e.g. 'Dev1', see DAQ.GETDEVICES DaqChannelID % The name of the DAQ channel ID, e.g. 'port1/line0', see DAQ.GETDEVICES DaqVendor = 'ni' % Name of the DAQ vendor - InitialDelay = 0 % sec, time to wait before starting - PulseDuration = Inf; % sec, time that the pulse is on at beginning and end + InitialDelay double {mustBeNonnegative} = 0 % sec, time to wait before starting + PulseDuration {mustBeNonnegative} = Inf; % sec, time that the pulse is on at beginning and end end properties (Transient, Access = private) diff --git a/+hw/TLOutputClock.m b/+hw/TLOutputClock.m index 487b8e0c..74830732 100644 --- a/+hw/TLOutputClock.m +++ b/+hw/TLOutputClock.m @@ -21,9 +21,9 @@ DaqDeviceID % The name of the DAQ device ID, e.g. 'Dev1', see DAQ.GETDEVICES DaqChannelID % The name of the DAQ channel ID, e.g. 'ctr0', see DAQ.GETDEVICES DaqVendor = 'ni' % Name of the DAQ vendor - InitialDelay = 0 % delay from session start to clock output - Frequency = 60; % Hz, of the clocking pulse - DutyCycle = 0.2; % proportion of each cycle that the pulse is "true" + InitialDelay double {mustBeNonnegative} = 0 % delay from session start to clock output + Frequency double = 60; % Hz, of the clocking pulse + DutyCycle double = 0.2; % proportion of each cycle that the pulse is "true" end properties (Transient, Hidden, Access = protected) From e0a45abdd9c54026306ade2286b11fa79ff8184a Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 2 Feb 2018 23:27:48 +0000 Subject: [PATCH 067/507] MC and expServer Alyx object compatibility --- +dat/addLogEntry.m | 1 + +dat/updateLogEntry.m | 6 +- +eui/AlyxPanel.m | 1183 +++++++++++++++--------------- +eui/ExpPanel.m | 15 +- +eui/MControl.m | 23 +- +exp/Experiment.m | 18 +- +exp/SignalsExp.m | 100 ++- +exp/StartServices.m | 11 +- +hw/Timeline.m | 8 +- +srv/StimulusControl.m | 6 +- +srv/expServer.m | 4 +- cortexlab/+io/MpepUDPDataHosts.m | 69 +- 12 files changed, 746 insertions(+), 698 deletions(-) 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/updateLogEntry.m b/+dat/updateLogEntry.m index fefad909..bf3e94ee 100644 --- a/+dat/updateLogEntry.m +++ b/+dat/updateLogEntry.m @@ -11,10 +11,8 @@ 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', strrep(mat2DStrTo1D(newEntry.comments),newline,'\n')); - alyx.putData(newEntry.AlyxInstance,... - newEntry.AlyxInstance.subsessionURL, data); + % Update session narrative on Alyx + newEntry.AlyxInstance.updateNarrative(subject, obj.LogEntry.comments); newEntry = rmfield(newEntry, 'AlyxInstance'); end diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 6b9704b5..86349026 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -1,626 +1,605 @@ classdef AlyxPanel < handle - % EUI.ALYXPANEL A GUI for interating with the Alyx database - % This class is emplyed by mc (but may also be used stand-alone) to - % post weights and water administations to the Alyx database. - % - % 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. - % Previous weighings and water infomation can be viewed by pressing - % the 'Subject history' button. - % Water administrations can be recorded by entering a value in ml - % into the text box. Pressing return does not post the water, but - % updates the text to the right of the box, showing the amount of - % water remaining (i.e. the amount below the subject's calculated - % minimum requirement for that day. The check box to the right of - % the text box is to indicate whether the water was liquid - % (unchecked) or gel (checked). To post the water to Alyx, press the - % 'Give water' button. - % To post gel for future date (for example weekend hydrogel), Click - % the 'Give gel in future' button and enter in all the values - % 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 - % - % 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 - 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 - 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 - 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 - WaterRemaining % Holds the current water required for the selected subject + % EUI.ALYXPANEL A GUI for interating with the Alyx database + % This class is emplyed by mc (but may also be used stand-alone) to + % post weights and water administations to the Alyx database. + % + % 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. + % Previous weighings and water infomation can be viewed by pressing + % the 'Subject history' button. + % Water administrations can be recorded by entering a value in ml + % into the text box. Pressing return does not post the water, but + % updates the text to the right of the box, showing the amount of + % water remaining (i.e. the amount below the subject's calculated + % minimum requirement for that day. The check box to the right of + % the text box is to indicate whether the water was liquid + % (unchecked) or gel (checked). To post the water to Alyx, press the + % 'Give water' button. + % To post gel for future date (for example weekend hydrogel), Click + % the 'Give gel in future' button and enter in all the values + % 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 + % + % 2017-03 NS created + % 2017-10 MW made into class + properties (SetAccess = private) + AlyxInstance = Alyx; % 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 + 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 + 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 + WaterRemaining % Holds the current water required for the selected subject + end + + events (NotifyAccess = 'protected') + Connected % Notified when logged in to database + Disconnected % Notified when logged out of database + end + + methods + function obj = AlyxPanel(parent) + % 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. + + 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]); + parent = uiextras.VBox('Parent', f,... + 'Visible', 'on'); + % subject selector + sbox = uix.HBox('Parent', parent); + bui.label('Select subject: ', sbox); + obj.NewExpSubject = bui.Selector(sbox, {'default'}); % Subject dropdown box + % set a callback on subject selection so that we can show water + % requirements for new mice as they are selected. This should + % be set by any other GUI that instantiates this object (e.g. + % MControl using this as a panel. + obj.NewExpSubject.addlistener('SelectionChanged', @(src, evt)obj.dispWaterReq(src, evt)); + end + + obj.RootContainer = uix.Panel('Parent', parent, 'Title', 'Alyx'); + alyxbox = uiextras.VBox('Parent', obj.RootContainer); + + loginbox = uix.HBox('Parent', alyxbox); + % Login infomation + obj.LoginText = bui.label('Not logged in', loginbox); + % Button to log in and out of Alyx + obj.LoginButton = uicontrol('Parent', loginbox,... + 'Style', 'pushbutton', ... + 'String', 'Login', ... + 'Enable', 'on',... + 'Callback', @(~,~)obj.login); + loginbox.Widths = [-1 75]; + + 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 + uicontrol('Parent', waterReqbox,... + 'Style', 'pushbutton', ... + 'String', 'Refresh', ... + 'Enable', 'off',... + 'Callback', @(~,~)obj.dispWaterReq); + waterReqbox.Widths = [-1 75]; + + waterbox = uix.HBox('Parent', alyxbox); + % Button to launch a dialog displaying water and weight info for a given mouse + uicontrol('Parent', waterbox,... + 'Style', 'pushbutton', ... + 'String', 'Subject history', ... + 'Enable', 'off',... + 'Callback', @(~,~)obj.viewSubjectHistory); + % Button to launch a dialog displaying water and weight info for all mice + uicontrol('Parent', waterbox,... + 'Style', 'pushbutton', ... + 'String', 'All WR subjects', ... + 'Enable', 'off',... + 'Callback', @(~,~)obj.viewAllSubjects); + % Button to open a dialog for manually submitting a mouse weight + uicontrol('Parent', waterbox,... + 'Style', 'pushbutton', ... + 'String', 'Manual weighing', ... + 'Enable', 'off',... + 'Callback', @(~,~)obj.recordWeight); + % Button to launch dialog for submitting gel administrations + % for future dates + uicontrol('Parent', waterbox,... + 'Style', 'pushbutton', ... + 'String', 'Give gel 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?', ... + 'HorizontalAlignment', 'right',... + 'Value', true, ... + 'Enable', 'off'); + % Input for submitting amount of water + obj.WaterEntry = uicontrol('Parent', waterbox,... + 'Style', 'edit',... + 'BackgroundColor', [1 1 1],... + 'HorizontalAlignment', 'right',... + 'Enable', 'off',... + 'String', '0.00', ... + 'Callback', @(src, evt)obj.changeWaterText(src, evt)); + % Button for submitting water administration + uicontrol('Parent', waterbox,... + 'Style', 'pushbutton', ... + 'String', 'Give water', ... + 'Enable', 'off',... + 'Callback', @(~,~)giveWater(obj)); + % Label Indicating the amount of water remaining + obj.WaterRemainingText = bui.label('[]', waterbox); + waterbox.Widths = [100 100 100 100 75 75 75 75]; + + launchbox = uix.HBox('Parent', alyxbox); + % Button for launching subject page in browser + uicontrol('Parent', launchbox,... + 'Style', 'pushbutton', ... + 'String', 'Launch webpage for Subject', ... + 'Enable', 'off',... + 'Callback', @(~,~)obj.launchSubjectURL); + % Button for launching (and creating) a session for a given subject in the browser + uicontrol('Parent', launchbox,... + 'Style', 'pushbutton', ... + 'String', 'Launch webpage for Session', ... + 'Enable', 'off',... + 'Callback', @(~,~)obj.launchSessionURL); + + if ~nargin + % logging message area + obj.LoggingDisplay = uicontrol('Parent', parent, 'Style', 'listbox',... + 'Enable', 'inactive', 'String', {}); + parent.Sizes = [50 150 150]; + else + % Use parent's logging display + obj.LoggingDisplay = findobj('Tag', 'Logging Display'); + end end - events (NotifyAccess = 'protected') - Connected % Notified when logged in to database - Disconnected % Notified when logged out of database + function delete(obj) + % To be called before destroying AlyxPanel object. Deletes the + % loggin timer + disp('AlyxPanel destructor called'); + if obj.RootContainer.isvalid; delete(obj.RootContainer); end + if ~isempty(obj.LoginTimer) % If there is a timer object + stop(obj.LoginTimer) % Stop the timer... + delete(obj.LoginTimer) % ... delete it... + obj.LoginTimer = []; % ... and remove it + end end - methods - function obj = AlyxPanel(parent) - % 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. - - 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]); - parent = uiextras.VBox('Parent', f,... - 'Visible', 'on'); - % subject selector - sbox = uix.HBox('Parent', parent); - bui.label('Select subject: ', sbox); - obj.NewExpSubject = bui.Selector(sbox, {'default'}); % Subject dropdown box - % set a callback on subject selection so that we can show water - % requirements for new mice as they are selected. This should - % be set by any other GUI that instantiates this object (e.g. - % MControl using this as a panel. - obj.NewExpSubject.addlistener('SelectionChanged', @(src, evt)obj.dispWaterReq(src, evt)); - end - - obj.RootContainer = uix.Panel('Parent', parent, 'Title', 'Alyx'); - alyxbox = uiextras.VBox('Parent', obj.RootContainer); - - loginbox = uix.HBox('Parent', alyxbox); - % Login infomation - obj.LoginText = bui.label('Not logged in', loginbox); - % Button to log in and out of Alyx - obj.LoginButton = uicontrol('Parent', loginbox,... - 'Style', 'pushbutton', ... - 'String', 'Login', ... - 'Enable', 'on',... - 'Callback', @(~,~)obj.login); - loginbox.Widths = [-1 75]; - - 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 - uicontrol('Parent', waterReqbox,... - 'Style', 'pushbutton', ... - 'String', 'Refresh', ... - 'Enable', 'off',... - 'Callback', @(~,~)obj.dispWaterReq); - waterReqbox.Widths = [-1 75]; - - waterbox = uix.HBox('Parent', alyxbox); - % Button to launch a dialog displaying water and weight info for a given mouse - uicontrol('Parent', waterbox,... - 'Style', 'pushbutton', ... - 'String', 'Subject history', ... - 'Enable', 'off',... - 'Callback', @(~,~)obj.viewSubjectHistory); - % Button to launch a dialog displaying water and weight info for all mice - uicontrol('Parent', waterbox,... - 'Style', 'pushbutton', ... - 'String', 'All WR subjects', ... - 'Enable', 'off',... - 'Callback', @(~,~)obj.viewAllSubjects); - % Button to open a dialog for manually submitting a mouse weight - uicontrol('Parent', waterbox,... - 'Style', 'pushbutton', ... - 'String', 'Manual weighing', ... - 'Enable', 'off',... - 'Callback', @(~,~)obj.recordWeight); - % Button to launch dialog for submitting gel administrations - % for future dates - uicontrol('Parent', waterbox,... - 'Style', 'pushbutton', ... - 'String', 'Give gel 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?', ... - 'HorizontalAlignment', 'right',... - 'Value', true, ... - 'Enable', 'off'); - % Input for submitting amount of water - obj.WaterEntry = uicontrol('Parent', waterbox,... - 'Style', 'edit',... - 'BackgroundColor', [1 1 1],... - 'HorizontalAlignment', 'right',... - 'Enable', 'off',... - 'String', '0.00', ... - 'Callback', @(src, evt)obj.changeWaterText(src, evt)); - % Button for submitting water administration - uicontrol('Parent', waterbox,... - 'Style', 'pushbutton', ... - 'String', 'Give water', ... - 'Enable', 'off',... - 'Callback', @(~,~)giveWater(obj)); - % Label Indicating the amount of water remaining - obj.WaterRemainingText = bui.label('[]', waterbox); - waterbox.Widths = [100 100 100 100 75 75 75 75]; - - launchbox = uix.HBox('Parent', alyxbox); - % Button for launching subject page in browser - uicontrol('Parent', launchbox,... - 'Style', 'pushbutton', ... - 'String', 'Launch webpage for Subject', ... - 'Enable', 'off',... - 'Callback', @(~,~)obj.launchSubjectURL); - % Button for launching (and creating) a session for a given subject in the browser - uicontrol('Parent', launchbox,... - 'Style', 'pushbutton', ... - 'String', 'Launch webpage for Session', ... - 'Enable', 'off',... - 'Callback', @(~,~)obj.launchSessionURL); - - if ~nargin - % logging message area - obj.LoggingDisplay = uicontrol('Parent', parent, 'Style', 'listbox',... - 'Enable', 'inactive', 'String', {}); - parent.Sizes = [50 150 150]; - else - % Use parent's logging display - obj.LoggingDisplay = findobj('Tag', 'Logging Display'); + function login(obj) + % 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. + + % Are we logging in or out? + if ~obj.AlyxInstance.IsLoggedIn % logging in + % attempt login + obj.AlyxInstance.login(); % 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); + start(obj.LoginTimer) + % Enable all buttons + set(findall(obj.RootContainer, '-property', 'Enable'), 'Enable', 'on'); + set(obj.LoginText, '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 = obj.AlyxInstance.getData('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, obj.AlyxInstance.User))); + 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]; + obj.NewExpSubject.Option = newSubs; + obj.SubjectList = newSubs; + + notify(obj, 'Connected'); % Notify listeners of login + 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. + firstTimeSubs = newSubs(~ismember(newSubs, dat.listSubjects)); + for fts = 1:length(firstTimeSubs) + thisDir = fullfile(dat.reposPath('expInfo', 'master'), firstTimeSubs{fts}); + if ~exist(thisDir, 'dir') + fprintf(1, 'making expInfo directory for %s\n', firstTimeSubs{fts}); + mkdir(thisDir); end + end + else + obj.log('Did not log into Alyx'); end - - function delete(obj) - % To be called before destroying AlyxPanel object. Deletes the - % loggin timer - disp('AlyxPanel destructor called'); - if obj.RootContainer.isvalid; delete(obj.RootContainer); end - if ~isempty(obj.LoginTimer) % If there is a timer object - stop(obj.LoginTimer) % Stop the timer... - delete(obj.LoginTimer) % ... delete it... - obj.LoginTimer = []; % ... and remove it - end + else % logging out + obj.AlyxInstance.logout; + if ~isempty(obj.LoginTimer) % If there is a timer object + stop(obj.LoginTimer) % Stop the timer... + delete(obj.LoginTimer) % ... delete it... + obj.LoginTimer = []; % ... and remove it end - - function login(obj) - % 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. - - % Are we logging in or out? - if isempty(obj.AlyxInstance) % 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; - % 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); - 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.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]; - obj.NewExpSubject.Option = newSubs; - obj.SubjectList = newSubs; - - notify(obj, 'Connected'); % Notify listeners of login - obj.log('Logged into Alyx successfully as %s', username); - - % any database subjects that weren't in the old list of - % subjects will need a folder in expInfo. - firstTimeSubs = newSubs(~ismember(newSubs, dat.listSubjects)); - for fts = 1:length(firstTimeSubs) - thisDir = fullfile(dat.reposPath('expInfo', 'master'), firstTimeSubs{fts}); - if ~exist(thisDir, 'dir') - fprintf(1, 'making expInfo 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 - else - obj.log('Did not log into Alyx'); - end - else % logging out - obj.AlyxInstance = []; - obj.SubjectList = []; - if ~isempty(obj.LoginTimer) % If there is a timer object - stop(obj.LoginTimer) % Stop the timer... - delete(obj.LoginTimer) % ... delete it... - obj.LoginTimer = []; % ... and remove it - end - set(obj.LoginText, 'String', 'Not logged in') - % Disable all buttons - set(findall(obj.RootContainer, '-property', 'Enable'), 'Enable', 'off') - set(obj.LoginButton, 'Enable', 'on', 'String', 'Login') % ... except the login button - notify(obj, 'Disconnected'); % Notify listeners of logout - obj.log('Logged out of Alyx'); - end + set(obj.LoginText, 'String', 'Not logged in') + % Disable all buttons + set(findall(obj.RootContainer, '-property', 'Enable'), 'Enable', 'off') + set(obj.LoginButton, 'Enable', 'on', 'String', 'Login') % ... except the login button + notify(obj, 'Disconnected'); % Notify listeners of logout + obj.log('Logged out of Alyx'); + end + 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 + thisDate = now; + amount = str2double(get(obj.WaterEntry, 'String')); + isHydrogel = logical(get(obj.IsHydrogel, 'Value')); + if obj.AlyxInstance.IsLoggedIn && amount~=0 && ~isnan(amount) + wa = obj.AlyxInstance.postWater(obj.Subject, amount, thisDate, isHydrogel); + 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); 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); - 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); - end - end - % update the water required text - dispWaterReq(obj); + end + % update the water required text + dispWaterReq(obj); + end + + function giveFutureGel(obj) + % Open a dialog allowing one to input water submissions for + % future dates + 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)||~obj.AlyxInstance.IsLoggedIn + return % user pressed 'Close' or 'x' + end + amount = str2num(answer{:}); %#ok + weekendDates = thisDate + (1:length(amount)); + for d = 1:length(weekendDates) + if amount(d) > 0 + obj.AlyxInstance.postWater(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 - - function giveFutureGel(obj) - % Open a dialog allowing one to input water submissions for - % future dates - ai = obj.AlyxInstance; - 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 + 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, '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(ai.makeEndpoint(['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)); + obj.WaterRemaining = s.water_requirement_remaining; 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 isempty(ai) - set(obj.WaterRequiredText, 'String', 'Log in to see water requirements'); + 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)); + 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 + 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 recordWeight(obj, weight, subject) + % Post a subject's weight to Alyx. If no inputs are provided, + % create an input dialog for the user to input a weight. If no + % subject is provided, use this object's currently selected + % subject. + % + % See also VIEWSUBJECTHISTORY, VIEWALLSUBJECTS + ai = obj.AlyxInstance; + if nargin < 3; subject = obj.Subject; end + if nargin < 2 + prompt = {sprintf('weight of %s:', subject)}; + dlgTitle = 'Manual weight logging'; + numLines = 1; + defaultAns = {'',''}; + weight = inputdlg(prompt, dlgTitle, numLines, defaultAns); + if isempty(weight); return; end + end + % inputdlg 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}); + try + w = postWeight(ai, weight, subject); %FIXME: If multiple things flushed, length(w)>1 + obj.log('Alyx weight posting succeeded: %.2f for %s', w.weight, w.subject); + catch + 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!'); + end + end + % Update weight and refresh login timer + obj.dispWaterReq + end + + function 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. + % + % See also LAUNCHSUBJECTURL + ai = obj.AlyxInstance; + % determine whether there is a session for this subj and date + thisDate = ai.datestr(now); + sessions = ai.getData(['sessions?type=Base&subject=' obj.Subject]); + + % 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) || ~strcmp(sessions{end}.start_time(1:10), thisDate(1:10)) + % 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 base session?', ... + ['No base session exists for ' datestr(now, 'yyyy-mm-dd')], ... + 'Yes','No','No'); + % Handle response + switch choice + case 'Yes' + % 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'; + + 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 - % 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)); - obj.WaterRemaining = s.water_requirement_remaining; - 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)); - end - end + case 'No' + return end + else + thisSess = sessions{end}; + end + + % parse the uuid from the url in the session object + u = thisSess.url; + uuid = u(find(u=='/', 1, 'last')+1:end); + + % make the admin url + adminURL = fullfile(ai.baseURL, 'admin', 'actions', 'session', uuid, 'change'); + + % launch the website + web(adminURL, '-browser'); + end + + function launchSubjectURL(obj) + ai = obj.AlyxInstance; + if ~ai.IsLoggedIn + s = ai.getData(ai.makeEndpoint(['subjects/' obj.Subject])); + subjURL = fullfile(ai.BaseURL, 'admin', 'subjects', 'subject', s.id, 'change'); % this is wrong - need uuid + web(subjURL, '-browser'); + end + end + + 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 + + % 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 + endpnt = sprintf('water-requirement/%s?start_date=2016-01-01&end_date=%s', obj.Subject, datestr(now, 'yyyy-mm-dd')); + wr = obj.AlyxInstance.getData(endpnt); + 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 + 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); + end + + plot(ax, dates, [records.weight_measured], '.-'); + hold(ax, 'on'); + plot(ax, dates, [records.weight_expected]*0.7, 'r', 'LineWidth', 2.0); + plot(ax, dates, [records.weight_expected]*0.8, 'LineWidth', 2.0, 'Color', [244, 191, 66]/255); + box(ax, 'off'); + if numel(dates) > 1; xlim(ax, [min(dates) max(dates)]); end + if nargin == 1 + set(ax, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false)) + else + 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(ax, dates, [records.weight_measured]./[records.weight_expected], '.-'); + 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) max(dates)]); + set(ax, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false)) + ylabel(ax, 'weight as pct (%)'); - 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) && ~isempty(obj.WaterRemaining) - rem = obj.WaterRemaining; - curr = str2double(src.String); - set(obj.WaterRemainingText, 'String', sprintf('(%.2f)', rem-curr)); - end - end + axWater = axes('Parent',plotBox); + plot(axWater, dates, [records.water_given]+[records.hydrogel_given], '.-'); + hold(axWater, 'on'); + plot(axWater, dates, [records.hydrogel_given], '.-'); + plot(axWater, dates, [records.water_given], '.-'); + plot(axWater, dates, [records.water_expected], 'r', 'LineWidth', 2.0); + box(axWater, 'off'); + xlim(axWater, [min(dates) max(dates)]); + set(axWater, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(axWater, 'XTick'), 'uni', false)) + ylabel(axWater, 'water/hydrogel (mL)'); - function recordWeight(obj, weight, subject) - % Post a subject's weight to Alyx. If no inputs are provided, - % create an input dialog for the user to input a weight. If no - % subject is provided, use this object's currently selected - % subject. - % - % See also VIEWSUBJECTHISTORY, VIEWALLSUBJECTS - ai = obj.AlyxInstance; - if nargin < 3; subject = obj.Subject; end - if nargin < 2 - prompt = {sprintf('weight of %s:', subject)}; - dlgTitle = 'Manual weight logging'; - numLines = 1; - defaultAns = {'',''}; - weight = inputdlg(prompt, dlgTitle, numLines, defaultAns); - if isempty(weight); return; end - end - % inputdlg 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 - d.user = ai.username; - 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!'); - end - end - end + % Create table of useful weight and water information, + % sorted by date + histTable = uitable('Parent', histbox,... + 'FontName', 'Consolas',... + 'RowName', []); + weightsByDate = num2cell([records.weight_measured]); + weightsByDate = cellfun(@(x)sprintf('%.1f', x), weightsByDate, 'uni', false); + weightsByDate(isnan([records.weight_measured])) = {[]}; + weightPctByDate = num2cell([records.weight_measured]./[records.weight_expected]); + weightPctByDate = cellfun(@(x)sprintf('%.1f', x*100), weightPctByDate, 'uni', false); + weightPctByDate(isnan([records.weight_measured])) = {[]}; - function 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. - % - % 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=Base&subject=' obj.Subject]); - - % 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) || ~strcmp(sessions{end}.start_time(1:10), thisDate(1:10)) - % 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 base session?', ... - ['No base session exists for ' datestr(now, 'yyyy-mm-dd')], ... - 'Yes','No','No'); - % Handle response - switch choice - case 'Yes' - % 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'; - - thisSess = alyx.postData(ai, '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 - case 'No' - return - end - else - thisSess = sessions{end}; - end - - % parse the uuid from the url in the session object - u = thisSess.url; - uuid = u(find(u=='/', 1, 'last')+1:end); - - % make the admin url - adminURL = fullfile(ai.baseURL, 'admin', 'actions', 'session', uuid, 'change'); - - % launch the website - web(adminURL, '-browser'); - end + dat = horzcat(... + arrayfun(@(x)datestr(x), dates', 'uni', false), ... + weightsByDate', ... + arrayfun(@(x)sprintf('%.1f', 0.8*x), [records.weight_expected]', 'uni', false), ... + weightPctByDate'); + waterDat = (... + num2cell(horzcat([records.water_given]', [records.hydrogel_given]', ... + [records.water_given]'+[records.hydrogel_given]', [records.water_expected]',... + [records.water_given]'+[records.hydrogel_given]'-[records.water_expected]'))); + waterDat = cellfun(@(x)sprintf('%.2f', x), waterDat, 'uni', false); + dat = horzcat(dat, waterDat); - function launchSubjectURL(obj) - 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 - end + set(histTable, 'ColumnName', {'date', 'meas. weight', '80% weight', 'weight pct', 'water', 'hydrogel', 'total', 'min water', 'excess'}, ... + 'Data', dat(end:-1:1,:),... + 'ColumnEditable', false(1,5)); + histbox.Widths = [ -1 725]; + end + end + + function viewAllSubjects(obj) + ai = obj.AlyxInstance; + if ai.IsLoggedIn + wr = ai.getData(ai.makeEndpoint('water-restricted-subjects')); - 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 not logged in or 'default' is selected, return - if isempty(ai)||strcmp(obj.Subject, 'default'); return; end - % 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); - 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 - 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); - end - - plot(ax, dates, [records.weight_measured], '.-'); - hold(ax, 'on'); - plot(ax, dates, [records.weight_expected]*0.7, 'r', 'LineWidth', 2.0); - plot(ax, dates, [records.weight_expected]*0.8, 'LineWidth', 2.0, 'Color', [244, 191, 66]/255); - box(ax, 'off'); - if numel(dates) > 1; xlim(ax, [min(dates) max(dates)]); end - if nargin == 1 - set(ax, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false)) - else - 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(ax, dates, [records.weight_measured]./[records.weight_expected], '.-'); - 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) max(dates)]); - set(ax, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false)) - ylabel(ax, 'weight as pct (%)'); - - axWater = axes('Parent',plotBox); - plot(axWater, dates, [records.water_given]+[records.hydrogel_given], '.-'); - hold(axWater, 'on'); - plot(axWater, dates, [records.hydrogel_given], '.-'); - plot(axWater, dates, [records.water_given], '.-'); - plot(axWater, dates, [records.water_expected], 'r', 'LineWidth', 2.0); - box(axWater, 'off'); - xlim(axWater, [min(dates) max(dates)]); - set(axWater, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(axWater, 'XTick'), 'uni', false)) - ylabel(axWater, 'water/hydrogel (mL)'); - - % Create table of useful weight and water information, - % sorted by date - histTable = uitable('Parent', histbox,... - 'FontName', 'Consolas',... - 'RowName', []); - weightsByDate = num2cell([records.weight_measured]); - weightsByDate = cellfun(@(x)sprintf('%.1f', x), weightsByDate, 'uni', false); - weightsByDate(isnan([records.weight_measured])) = {[]}; - weightPctByDate = num2cell([records.weight_measured]./[records.weight_expected]); - weightPctByDate = cellfun(@(x)sprintf('%.1f', x*100), weightPctByDate, 'uni', false); - weightPctByDate(isnan([records.weight_measured])) = {[]}; - - dat = horzcat(... - arrayfun(@(x)datestr(x), dates', 'uni', false), ... - weightsByDate', ... - arrayfun(@(x)sprintf('%.1f', 0.8*x), [records.weight_expected]', 'uni', false), ... - weightPctByDate'); - waterDat = (... - num2cell(horzcat([records.water_given]', [records.hydrogel_given]', ... - [records.water_given]'+[records.hydrogel_given]', [records.water_expected]',... - [records.water_given]'+[records.hydrogel_given]'-[records.water_expected]'))); - waterDat = cellfun(@(x)sprintf('%.2f', x), waterDat, 'uni', false); - dat = horzcat(dat, waterDat); - - set(histTable, 'ColumnName', {'date', 'meas. weight', '80% weight', 'weight pct', 'water', 'hydrogel', 'total', 'min water', 'excess'}, ... - 'Data', dat(end:-1:1,:),... - 'ColumnEditable', false(1,5)); - histbox.Widths = [ -1 725]; - end - end + 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); - 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); - - % build a figure to show it - f = figure; % popup a new figure for this - wrBox = uix.VBox('Parent', f); - wrTable = uitable('Parent', wrBox,... - 'FontName', 'Consolas',... - 'RowName', []); - - htmlColor = @(colorNum)reshape(dec2hex(round(colorNum'*255),2)',1,6); - % 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); - - set(wrTable, 'ColumnName', {'Name', 'Water Required', 'Remaining Requirement'}, ... - 'Data', horzcat(subjs', ... - cellfun(@(x)sprintf('%.2f',x),waterReqTotal', 'uni', false), ... - wrdat'), ... - 'ColumnEditable', false(1,3)); - end - end + % build a figure to show it + f = figure; % popup a new figure for this + wrBox = uix.VBox('Parent', f); + wrTable = uitable('Parent', wrBox,... + 'FontName', 'Consolas',... + 'RowName', []); - function log(obj, varargin) - % Function for displaying timestamped information about - % occurrences. If the LoggingDisplay property is unset, the - % message is printed to the command prompt. - % log(formatSpec, A1,... An) - % - % See also FPRINTF - message = sprintf(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'); - %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 + htmlColor = @(colorNum)reshape(dec2hex(round(colorNum'*255),2)',1,6); + % 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); + + set(wrTable, 'ColumnName', {'Name', 'Water Required', 'Remaining Requirement'}, ... + 'Data', horzcat(subjs', ... + cellfun(@(x)sprintf('%.2f',x),waterReqTotal', 'uni', false), ... + wrdat'), ... + 'ColumnEditable', false(1,3)); + end end + function log(obj, varargin) + % Function for displaying timestamped information about + % occurrences. If the LoggingDisplay property is unset, the + % message is printed to the command prompt. + % log(formatSpec, A1,... An) + % + % See also FPRINTF + message = sprintf(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'); + %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 diff --git a/+eui/ExpPanel.m b/+eui/ExpPanel.m index 5ccb95b6..4e6c2cb0 100644 --- a/+eui/ExpPanel.m +++ b/+eui/ExpPanel.m @@ -11,7 +11,6 @@ % experiment type, for example CHOICEEXPPANEL for ChoiceWorld and % SQUEAKEXPPANEL for Signals experiments. % - % % % See also SQUEAKEXPPANEL, CHOICEEXPPANEL, MCONTROL, MC % @@ -207,12 +206,12 @@ function expStarted(obj, rig, evt) end end - function expStopped(obj, rig, evt) + function expStopped(obj, rig, ~) % EXPSTOPPED Callback for the ExpStopped event. - % Updates the ExpRunning flag, the panel title and status label to - % show that the experiment has ended. This function also records to Alyx the - % amount of water, if any, that the subject received during the - % task. + % expStopped(obj, rig, event) Updates the ExpRunning flag, the + % panel title and status label to show that the experiment has + % ended. This function also records to Alyx the amount of water, + % if any, that the subject received during the task. % % See also EXPSTARTED, ALYX.POSTWATER set(obj.StatusLabel, 'String', 'Completed'); %staus to completed @@ -245,7 +244,7 @@ function expStopped(obj, rig, evt) end if ~any(amount); return; end % Return if no water was given try - alyx.postWater(ai, subject, amount*0.001, now, false); + ai.postWater(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 @@ -299,7 +298,7 @@ function saveLogEntry(obj) % subsession's narrative field. % % See also DAT.UPDATELOGENTRY, COMMENTSCHANGED - dat.updateLogEntry(obj.SubjectRef, obj.LogEntry.id, obj.LogEntry); + dat.updateLogEntry(dat.parseExpRef(obj.SubjectRef), obj.LogEntry.id, obj.LogEntry); end function viewParams(obj) diff --git a/+eui/MControl.m b/+eui/MControl.m index 0f09f9ff..9d706860 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -121,7 +121,7 @@ function newScalesReading(obj, ~, ~) function tabChanged(obj) % Function to change which subject Alyx uses when user changes tab - if isempty(obj.AlyxPanel.AlyxInstance); return; end + if ~obj.AlyxPanel.AlyxInstance.IsLoggedIn; return; end if obj.TabPanel.SelectedChild == 1 % Log tab obj.AlyxPanel.dispWaterReq(obj.LogSubject); else % SelectedChild == 2 Experiment tab @@ -335,11 +335,10 @@ function rigExpStopped(obj, rig, evt) % Announce that the experiment has stopped set([obj.BeginExpButton obj.RigOptionsButton], '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); @@ -347,7 +346,9 @@ function rigExpStopped(obj, rig, evt) % Announce that the experiment has stopped 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); + rig.AlyxInstance = []; end end @@ -532,7 +533,8 @@ function beginExp(obj) set([obj.BeginExpButton obj.RigOptionsButton], 'Enable', 'off'); % Grey out buttons rig = obj.RemoteRigs.Selected; % Find which rig is selected % 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') + ai = obj.AlyxPanel.AlyxInstance; + if ~ai.IsLoggedIn && ~strcmp(obj.NewExpSubject.Selected,'default') try obj.AlyxPanel.login(); catch @@ -546,12 +548,11 @@ function beginExp(obj) obj.Parameters.set('services', services(:),... 'List of experiment services to use during the experiment'); % Create new experiment reference - [expRef, ~, url] = dat.newExp(obj.NewExpSubject.Selected, now,... - obj.Parameters.Struct, obj.AlyxPanel.AlyxInstance); + [expRef, ~] = ai.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.subsessionURL = url; + rig.AlyxInstance = ai.copy; panel = eui.ExpPanel.live(obj.ActiveExpsGrid, expRef, rig, obj.Parameters.Struct); obj.LastExpPanel = panel; @@ -569,7 +570,7 @@ function updateWeightPlot(obj) entries = obj.Log.entriesByType('weight-grams'); datenums = floor([entries.date]); obj.WeightAxes.clear(); - if ~isempty(obj.AlyxPanel.AlyxInstance)&&~strcmp(obj.LogSubject.Selected,'default') + if obj.AlyxPanel.AlyxInstance.IsLoggedIn && ~strcmp(obj.LogSubject.Selected,'default') obj.AlyxPanel.viewSubjectHistory(obj.WeightAxes.Handle) rotateticklabel(obj.WeightAxes.Handle, 45); else diff --git a/+exp/Experiment.m b/+exp/Experiment.m index b0229875..e26860a0 100644 --- a/+exp/Experiment.m +++ b/+exp/Experiment.m @@ -773,20 +773,20 @@ function saveData(obj) savepaths = dat.expFilePath(obj.Data.expRef, 'block'); superSave(savepaths, struct('block', obj.Data)); - if isempty(obj.AlyxInstance) + if ~obj.AlyxInstance.IsLoggedIn warning('No Alyx token set'); else try - [subject,~,~] = dat.parseExpRef(obj.Data.expRef); - if strcmp(subject,'default'); return; end + subject = dat.parseExpRef(obj.Data.expRef); + if strcmp(subject, 'default'); return; end % Register saved files - alyx.registerFile(savepaths{end}, 'mat',... - obj.AlyxInstance.subsessionURL, 'Block', [], obj.AlyxInstance); + obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... + obj.AlyxInstance.subsessionURL, 'Block', []); % Save the session end time - alyx.putData(obj.AlyxInstance, obj.AlyxInstance.subsessionURL,... - struct('end_time', alyx.datestr(now), 'subject', subject)); - catch - warning('couldnt register files to alyx because no subsession found'); + obj.AlyxInstance.putData(obj.AlyxInstance.SessionURL,... + struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject)); + catch ex + warning(ex.identifer, 'Failed to register files to Alyx: %s', ex.message); end end end diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 74c3e059..96d8a62f 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -1,5 +1,5 @@ classdef SignalsExp < handle - %exp.SignalsExp Base class for stimuli-delivering experiments + %EXP.SIGNALSEXP Base class for stimuli-delivering experiments % The class defines a framework for event- and state-based experiments. % Visual and auditory stimuli can be controlled by experiment phases. % Phases changes are managed by an event-handling system. @@ -33,6 +33,8 @@ %saved into the block data field 'rigName'. RigName + %Communcator object for sending signals updates to mc. Set by + %expServer Communicator = io.DummyCommunicator %Delay (secs) before starting main experiment phase after experiment @@ -44,21 +46,30 @@ %wasn't requested). PostDelay = 0 - IsPaused = false %flag indicating whether the experiment is paused + %Flag indicating whether the experiment is paused + IsPaused = false + %Holds the wheel object, 'mouseInput' from the rig object. See also + %USERIG, HW.DAQROTARYENCODER Wheel + %Holds the object for interating with the lick detector. See also + %HW.DAQEDGECOUNTER LickDetector + %Holds the object for interating with the DAQ outputs (reward valve, + %etc.) See also HW.DAQCONTROLLER DaqController + %Get the handle to the PTB window opened by expServer StimWindowPtr TextureById LayersByStim - Occ % occulus model + %Occulus viewing model + Occ Time @@ -70,20 +81,28 @@ Visual - Audio + Audio % = aud.AudioRegistry + %Holds the parameters structure for this experiment Params ParamsLog + %The bounds for the photodiode square SyncBounds + %Sync colour cycle (usually [0, 255]) - cycles through these each + %time the screen flips. SyncColourCycle - NextSyncIdx %index into SyncColourCycle for next sync colour -% Audio = aud.AudioRegistry + %Index into SyncColourCycle for next sync colour + NextSyncIdx + + %Holds the session for the DAQ photodiode echo, used if + %rig.stimWindow.DaqSyncEchoPort is defined. See also USERIG + DaqSyncEcho - %AlyxToken from client + % Alyx instance from client. See also SAVEDATA AlyxInstance = [] end @@ -94,6 +113,9 @@ %Data from the currently running experiment, if any. Data = struct + %Data binary file ID and pars file ID for intermittent saving + DataFID + %Currently active phases of the experiment. Cell array of their names %(i.e. strings) ActivePhases = {} @@ -104,7 +126,6 @@ SignalUpdates = struct('name', cell(500,1), 'value', cell(500,1), 'timestamp', cell(500,1)) NumSignalUpdates = 0 - end properties (Access = protected) @@ -112,7 +133,8 @@ %are awaiting activation pending completion of their delay period. Pending - IsLooping = false %flag indicating whether to continue in experiment loop + %Flag indicating whether to continue in experiment loop + IsLooping = false AsyncFlipping = false @@ -191,19 +213,48 @@ end function useRig(obj, rig) + % USERIG(OBJ, RIG) Initialize all hardware for experiment + % Takes the rig hardware structure and loads the relevant + % parameters into the class properties, namely the DAQ output + % channels and the stimWindow properties. + % + % See Also HW.PTB.WINDOW, DAQCONTROLLER + obj.Clock = rig.clock; obj.Data.rigName = rig.name; + % Sync bounds parameter for photodiode square obj.SyncBounds = rig.stimWindow.SyncBounds; + % Sync colour cycle (usually [0, 255]) - cycles through these each + % time the screen flips. obj.SyncColourCycle = rig.stimWindow.SyncColourCycle; + % If the DaqSyncEchoPort is defined, each time the screen flips, the + % DAQ will output an alternating high or low. This signals will + % follow the photodiode signal. + if ~isempty(rig.stimWindow.DaqSyncEchoPort) + % Create the DAQ session for the sync echo + obj.DaqSyncEcho = daq.createSession(rig.stimWindow.DaqVendor); + % Add the digital channel using parameters defined in + % rig.stimWindow + obj.DaqSession.addDigitalChannel(rig.stimWindow.DaqDev,... + rig.stimWindow.DaqSyncEchoPort, 'OutputOnly'); + % Output an initial 0V signal + obj.DaqSession.outputSingleScan(false); + end + % Initialize the sync square colour to be index 1 obj.NextSyncIdx = 1; + % Get the handle to the PTB window opened by expServer obj.StimWindowPtr = rig.stimWindow.PtbHandle; + % Generate the viewing model based on the screen information from the + % rig struct, if availiable obj.Occ = vis.init(obj.StimWindowPtr); if isfield(rig, 'screens') obj.Occ.screens = rig.screens; else warning('squeak:hw', 'No screen configuration specified. Visual locations will be wrong.'); end + % Load the DAQ outputs obj.DaqController = rig.daqController; + % Load the wheel obj.Wheel = rig.mouseInput; if isfield(rig, 'lickDetector') obj.LickDetector = rig.lickDetector; @@ -562,6 +613,15 @@ function init(obj) outlist = mapToCell(@(n,v)queuefun(['outputs.' n],v),... fieldnames(obj.Outputs), struct2cell(obj.Outputs)); obj.Listeners = vertcat(obj.Listeners, evtlist(:), outlist(:)); + + % open binary file for saving block data. This can later be retrieved + % in case of a crash + fprintf(1, 'opening binary file for writing\n'); + localPath = dat.expFilePath(obj.Data.expRef, 'block', 'local'); % get the local exp data path + obj.DataFID = fopen([localPath(1:end-4) '.dat'], 'w'); % open a binary data file + % save params now so if things crash later you at least have this record of the data type and size so you can load the dat + obj.DataFID(2) = fopen([localPath(1:end-4) '.par'], 'w'); % open a parameter file + end function cleanup(obj) @@ -642,7 +702,6 @@ function mainLoop(obj) %% check for and process any input checkInput(obj); - %% execute pending event handlers that have become due for i = 1:ndue due = obj.Pending(dueIdx(i)); @@ -680,6 +739,10 @@ function mainLoop(obj) Screen('FillRect', obj.StimWindowPtr, col, obj.SyncBounds); % cyclically increment the next sync idx obj.NextSyncIdx = mod(obj.NextSyncIdx, size(obj.SyncColourCycle, 1)) + 1; + if ~isempty(obj.DaqSyncEcho) + % update sync echo + outputSingleScan(obj.DaqSyncEcho, mean(col) > 0); + end end renderTime = now(obj.Clock); % start the 'flip' of the frame onto the screen @@ -828,21 +891,20 @@ function saveData(obj) savepaths = dat.expFilePath(obj.Data.expRef, 'block'); superSave(savepaths, struct('block', obj.Data)); - if isempty(obj.AlyxInstance) + if ~obj.AlyxInstance.IsLoggedIn warning('No Alyx token set'); else try - [subject,~,~] = dat.parseExpRef(obj.Data.expRef); - if strcmp(subject,'default'); return; end + subject = dat.parseExpRef(obj.Data.expRef); + if strcmp(subject, 'default'); return; end % Register saved files - alyx.registerFile(savepaths{end}, 'mat',... - obj.AlyxInstance.subsessionURL, 'Block', [], obj.AlyxInstance); + obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... + obj.AlyxInstance.subsessionURL, 'Block', []); % Save the session end time - alyx.putData(obj.AlyxInstance, obj.AlyxInstance.subsessionURL,... - struct('end_time', alyx.datestr(now), 'subject', subject)); + obj.AlyxInstance.putData(obj.AlyxInstance.SessionURL,... + struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject)); catch ex - warning('couldnt register files to alyx'); - disp(ex) + warning(ex.identifer, 'Failed to register files to Alyx: %s', ex.message); end end diff --git a/+exp/StartServices.m b/+exp/StartServices.m index 0c3ac13c..8c8b72bb 100644 --- a/+exp/StartServices.m +++ b/+exp/StartServices.m @@ -27,13 +27,16 @@ obj.Services = value; end - function perform(obj, eventInfo, dueTime) - ref = dat.parseAlyxInstance(eventInfo.Experiment.Data.expRef,... - eventInfo.Experiment.AlyxInstance); + function perform(obj, eventInfo, ~) + %PERFORM Starts each service sequentially + % perform(obj, eventInfo, dueTime) + % + expRef = eventInfo.Experiment.Data.expRef; + ai = eventInfo.Experiment.AlyxInstance; n = numel(obj.Services); for i = 1:n try - obj.Services{i}.start(ref); + obj.Services{i}.start(expRef, ai); fprintf('Started ''%s''\n', obj.Services{i}.Title); catch ex %stop services that were started up till now diff --git a/+hw/Timeline.m b/+hw/Timeline.m index e03e212a..e83b455e 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -463,11 +463,11 @@ function stop(obj) end % register Timeline.mat file to Alyx database - [subject,~,~] = dat.parseExpRef(obj.Data.expRef); - if ~isempty(obj.AlyxInstance) && ~strcmp(subject,'default') + subject = dat.parseExpRef(obj.Data.expRef); + if obj.AlyxInstance.IsLoggedIn && ~strcmp(subject,'default') try - alyx.registerFile(obj.Data.savePaths{end}, 'mat',... - obj.AlyxInstance.subsessionURL, 'Timeline', [], obj.AlyxInstance); + obj.AlyxInstance.registerFile(obj.Data.savePaths{end}, 'mat',... + obj.AlyxInstance.SessionURL, 'Timeline', []); catch warning('couldn''t register files to alyx'); end diff --git a/+srv/StimulusControl.m b/+srv/StimulusControl.m index a8b4e1b8..34db88fe 100644 --- a/+srv/StimulusControl.m +++ b/+srv/StimulusControl.m @@ -268,8 +268,10 @@ function send(obj, id, data) assert(~isNil(msg), 'Timed out waiting for message with id ''%s''', id); remove(obj.Responses, id); % no longer waiting, remove place holder end - - function errorOnFail(obj, r) + end + + methods (Static) + function errorOnFail(r) if iscell(r) && strcmp(r{1}, 'fail') error(r{3}); end diff --git a/+srv/expServer.m b/+srv/expServer.m index 4e4bec05..1ac91302 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -7,7 +7,7 @@ function expServer(useTimelineOverride, bgColour) % 2013-06 CB created %% Parameters -global AGL GL GLU +global AGL GL GLU %#ok listenPort = io.WSJCommunicator.DefaultListenPort; quitKey = KbName('q'); rewardToggleKey = KbName('w'); @@ -23,7 +23,7 @@ function expServer(useTimelineOverride, bgColour) rng('shuffle'); % communicator for receiving commands from clients communicator = io.WSJCommunicator.server(listenPort); -listener = event.listener(communicator, 'MessageReceived',... +addlistener(communicator, 'MessageReceived',... @(~,msg) handleMessage(msg.Id, msg.Data, msg.Sender)); communicator.EventMode = false; communicator.open(); diff --git a/cortexlab/+io/MpepUDPDataHosts.m b/cortexlab/+io/MpepUDPDataHosts.m index 1f476d97..07bd3cea 100644 --- a/cortexlab/+io/MpepUDPDataHosts.m +++ b/cortexlab/+io/MpepUDPDataHosts.m @@ -20,7 +20,7 @@ DigitalOutDaqChannelId Verbose = false % whether to output I/O messages etc Timeline % An instance of timeline for for recording UDP messages - AlyxInstance + AlyxInstance % An instance of Alyx for registering files, etc. end properties (SetAccess = protected) @@ -41,8 +41,8 @@ properties (Access = private) ExpRef pResponseTimeout = 10 - %When an mpep UDP is sent out, the message is saved here to later check - %the same message is received back + % When an mpep UDP is sent out, the message is saved here to later check + % the same message is received back LastSentMessage DigitalOutSession Timer @@ -62,11 +62,11 @@ function open(obj) obj.Socket = pnet('udpsocket', obj.LocalPort); % bind listening socket - % set timeout to intial value + % Set timeout to intial value pnet(obj.Socket, 'setreadtimeout', obj.ResponseTimeout); - % save IP addresses for remote hosts + % Save IP addresses for remote hosts obj.RemoteIPs = ipaddress(obj.RemoteHosts); - % open the DAQ session, if configured + % Open the DAQ session, if configured if ~isempty(obj.DigitalOutDaqChannelId) obj.DigitalOutSession = daq.createSession(obj.DaqVendor); obj.DigitalOutSession.addDigitalChannel(... @@ -76,7 +76,7 @@ function open(obj) end function close(obj) - % cleanup timeout timer, close network socket and release DAQ session + % Cleanup timeout timer, close network socket and release DAQ session if ~isempty(obj.Timer) stop(obj.Timer); delete(obj.Timer); @@ -109,12 +109,12 @@ function delete(obj) function expStarted(obj, ref) obj.ExpRef = ref; % save the experiment reference - %% send the ExpStart UDP + % Send the ExpStart UDP [subject, seriesNum, expNum] = dat.expRefToMpep(obj.ExpRef); expStartMsg = sprintf('ExpStart %s %d %d', subject, seriesNum, expNum); confirmedBroadcast(obj, expStartMsg); - %% send the BlockStart UDP + % Send the BlockStart UDP % start a block (we only use one per experiment) blockStartMsg = sprintf('BlockStart %s %d %d 1', subject, seriesNum, expNum); confirmedBroadcast(obj, blockStartMsg); @@ -123,16 +123,16 @@ function expStarted(obj, ref) function stimStarted(obj, num, duration) validateResponses(obj); % validate any outstanding responses obj.StimNum = num; - %% send the StimStart UDP + % Send the StimStart UDP [subject, seriesNum, expNum] = dat.expRefToMpep(obj.ExpRef); msg = sprintf('StimStart %s %d %d 1 %d %d',... %2014/4/8 DS: why trial number is always 1??? subject, seriesNum, expNum, obj.StimNum, duration + 1); confirmedBroadcast(obj, msg); - % create a timeout timer + % Create a timeout timer timeoutTimer = timer('StartDelay', duration, 'TimerFcn', @(t,d) stimEnded(obj, num)); - %% set digital out line up if any + % Set digital out line up if any if ~isempty(obj.DigitalOutSession) obj.DigitalOutSession.outputSingleScan(true); if obj.Verbose @@ -159,14 +159,14 @@ function stimEnded(obj, num) delete(obj.Timer); obj.Timer = []; end - %% set digital out line down if any + % Set digital out line down if any if ~isempty(obj.DigitalOutSession) obj.DigitalOutSession.outputSingleScan(false); if obj.Verbose fprintf('DAQ digital out -> false\n'); end end - %% send the StimEnd UDP + % Send the StimEnd UDPS [subject, seriesNum, expNum] = dat.expRefToMpep(obj.ExpRef); msg = sprintf('StimEnd %s %d %d 1 %d', subject, seriesNum, expNum, num); broadcast(obj, msg); @@ -182,38 +182,41 @@ function stimEnded(obj, num) end function expEnded(obj) - %% If StimStart was sent without corresponding StimEnd, send it now + % If StimStart was sent without corresponding StimEnd, send it now if obj.StimOn stimEnded(obj); end validateResponses(obj); % validate any outstanding responses - %% send the BlockEnd UDP + % Send the BlockEnd UDP [subject, seriesNum, expNum] = dat.expRefToMpep(obj.ExpRef); blockEndMsg = sprintf('BlockEnd %s %d %d 1', subject, seriesNum, expNum); confirmedBroadcast(obj, blockEndMsg); - %% send the ExpEnd UDP - % start a block (we only use one per experiment) + % Send the ExpEnd UDP + % Start a block (we only use one per experiment) expEndMsg = sprintf('ExpEnd %s %d %d', subject, seriesNum, expNum); confirmedBroadcast(obj, expEndMsg); obj.ExpRef = []; end - function start(obj, ref) - [expRef, ai] = dat.parseAlyxInstance(ref); - obj.AlyxInstance = ai; - [subject, seriesNum, expNum] = dat.expRefToMpep(expRef); - alyxmsg = sprintf('alyx %s %d %d %s', subject, seriesNum, expNum, ref); - confirmedBroadcast(obj, alyxmsg); + function start(obj, expRef, ai) + % Deal with Alyx instance first + if ~isempty(ai) + obj.AlyxInstance = ai; + UDP_msg = ai.parseAlyxInstance; + [subject, seriesNum, expNum] = dat.expRefToMpep(expRef); + alyxmsg = sprintf('alyx %s %d %d %s', subject, seriesNum, expNum, UDP_msg); + confirmedBroadcast(obj, alyxmsg); + end - % equivalent to startExp(expRef) + % Equivalent to startExp(expRef) expStarted(obj, expRef); end function stop(obj) - % equivalent to endExp() + % Equivalent to endExp() expEnded(obj); end @@ -221,7 +224,7 @@ function stop(obj) obj.broadcast('hello'); try ok = awaitResponses(obj); - catch ex + catch ok = false; end end @@ -243,14 +246,14 @@ function confirmedBroadcast(obj, msg) end function broadcast(obj, msg) - % send UDP to all remote hosts + % Send UDP to all remote hosts cellfun(@(host) obj.sendPacket(msg, host), obj.RemoteHosts); obj.LastSentMessage = msg; end function validateResponses(obj) - %If a recent UDP message was sent and the response hasn't been - %received and checked that it matches what was sent, check it now. + % If a recent UDP message was sent and the response hasn't been + % received and checked that it matches what was sent, check it now. if ~isempty(obj.LastSentMessage) ok = awaitResponses(obj); assert(all(ok), 'A valid UDP confirmation was not received within timeout period'); @@ -258,12 +261,12 @@ function validateResponses(obj) end function ok = awaitResponses(obj) - %% check response is what we sent (with Timeout) + % Check response is what we sent (with Timeout) expecting = obj.LastSentMessage; % IP address of remote hosts we are expecting confirmation from waiting = ipaddress(obj.RemoteHosts); ok = false(size(waiting)); - % receive 'num remote hosts' of packets + % Receive 'num remote hosts' of packets for i = 1:numel(obj.RemoteHosts) tic [msg, ip] = obj.readPacket; @@ -279,7 +282,7 @@ function validateResponses(obj) end function sendPacket(obj, msg, host) - % send the packet with 'msg' + % Send the packet with 'msg' pnet(obj.Socket, 'write', msg); pnet(obj.Socket, 'writepacket', host, obj.RemotePort); if obj.Verbose From c84aecbb3419556469b71b1e2faef2343abbb0e9 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 5 Feb 2018 12:18:13 +0000 Subject: [PATCH 068/507] Moved non-alyx related changes to different branch --- +exp/SignalsExp.m | 63 +++++------------------------------------------ 1 file changed, 6 insertions(+), 57 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 96d8a62f..cbcacb1f 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -69,7 +69,7 @@ LayersByStim %Occulus viewing model - Occ + Occ Time @@ -96,13 +96,9 @@ SyncColourCycle %Index into SyncColourCycle for next sync colour - NextSyncIdx + NextSyncIdx - %Holds the session for the DAQ photodiode echo, used if - %rig.stimWindow.DaqSyncEchoPort is defined. See also USERIG - DaqSyncEcho - - % Alyx instance from client. See also SAVEDATA + %Alyx instance from client. See also SAVEDATA AlyxInstance = [] end @@ -113,9 +109,6 @@ %Data from the currently running experiment, if any. Data = struct - %Data binary file ID and pars file ID for intermittent saving - DataFID - %Currently active phases of the experiment. Cell array of their names %(i.e. strings) ActivePhases = {} @@ -126,6 +119,7 @@ SignalUpdates = struct('name', cell(500,1), 'value', cell(500,1), 'timestamp', cell(500,1)) NumSignalUpdates = 0 + end properties (Access = protected) @@ -133,8 +127,7 @@ %are awaiting activation pending completion of their delay period. Pending - %Flag indicating whether to continue in experiment loop - IsLooping = false + IsLooping = false %flag indicating whether to continue in experiment loop AsyncFlipping = false @@ -213,48 +206,19 @@ end function useRig(obj, rig) - % USERIG(OBJ, RIG) Initialize all hardware for experiment - % Takes the rig hardware structure and loads the relevant - % parameters into the class properties, namely the DAQ output - % channels and the stimWindow properties. - % - % See Also HW.PTB.WINDOW, DAQCONTROLLER - obj.Clock = rig.clock; obj.Data.rigName = rig.name; - % Sync bounds parameter for photodiode square obj.SyncBounds = rig.stimWindow.SyncBounds; - % Sync colour cycle (usually [0, 255]) - cycles through these each - % time the screen flips. obj.SyncColourCycle = rig.stimWindow.SyncColourCycle; - % If the DaqSyncEchoPort is defined, each time the screen flips, the - % DAQ will output an alternating high or low. This signals will - % follow the photodiode signal. - if ~isempty(rig.stimWindow.DaqSyncEchoPort) - % Create the DAQ session for the sync echo - obj.DaqSyncEcho = daq.createSession(rig.stimWindow.DaqVendor); - % Add the digital channel using parameters defined in - % rig.stimWindow - obj.DaqSession.addDigitalChannel(rig.stimWindow.DaqDev,... - rig.stimWindow.DaqSyncEchoPort, 'OutputOnly'); - % Output an initial 0V signal - obj.DaqSession.outputSingleScan(false); - end - % Initialize the sync square colour to be index 1 obj.NextSyncIdx = 1; - % Get the handle to the PTB window opened by expServer obj.StimWindowPtr = rig.stimWindow.PtbHandle; - % Generate the viewing model based on the screen information from the - % rig struct, if availiable obj.Occ = vis.init(obj.StimWindowPtr); if isfield(rig, 'screens') obj.Occ.screens = rig.screens; else warning('squeak:hw', 'No screen configuration specified. Visual locations will be wrong.'); end - % Load the DAQ outputs obj.DaqController = rig.daqController; - % Load the wheel obj.Wheel = rig.mouseInput; if isfield(rig, 'lickDetector') obj.LickDetector = rig.lickDetector; @@ -613,15 +577,6 @@ function init(obj) outlist = mapToCell(@(n,v)queuefun(['outputs.' n],v),... fieldnames(obj.Outputs), struct2cell(obj.Outputs)); obj.Listeners = vertcat(obj.Listeners, evtlist(:), outlist(:)); - - % open binary file for saving block data. This can later be retrieved - % in case of a crash - fprintf(1, 'opening binary file for writing\n'); - localPath = dat.expFilePath(obj.Data.expRef, 'block', 'local'); % get the local exp data path - obj.DataFID = fopen([localPath(1:end-4) '.dat'], 'w'); % open a binary data file - % save params now so if things crash later you at least have this record of the data type and size so you can load the dat - obj.DataFID(2) = fopen([localPath(1:end-4) '.par'], 'w'); % open a parameter file - end function cleanup(obj) @@ -739,10 +694,6 @@ function mainLoop(obj) Screen('FillRect', obj.StimWindowPtr, col, obj.SyncBounds); % cyclically increment the next sync idx obj.NextSyncIdx = mod(obj.NextSyncIdx, size(obj.SyncColourCycle, 1)) + 1; - if ~isempty(obj.DaqSyncEcho) - % update sync echo - outputSingleScan(obj.DaqSyncEcho, mean(col) > 0); - end end renderTime = now(obj.Clock); % start the 'flip' of the frame onto the screen @@ -907,9 +858,7 @@ function saveData(obj) warning(ex.identifer, 'Failed to register files to Alyx: %s', ex.message); end end - end end -end - +end \ No newline at end of file From 71115a34a1b9b153402b539e94f1b503546b6c27 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 5 Feb 2018 13:19:43 +0000 Subject: [PATCH 069/507] Fix for new experiments without alyx instance --- +dat/newExp.m | 108 +++++++++++++++++++++++++------------------------- 1 file changed, 53 insertions(+), 55 deletions(-) diff --git a/+dat/newExp.m b/+dat/newExp.m index 6a86e07f..acd3c48e 100644 --- a/+dat/newExp.m +++ b/+dat/newExp.m @@ -30,9 +30,9 @@ expParams = []; end -if nargin < 4 +if nargin < 4 || isempty(AlyxInstance) % no instance of Alyx, don't create session on Alyx - AlyxInstance = []; + AlyxInstance = alyx.loginWindow; end if ischar(expDate) @@ -66,69 +66,67 @@ % now make the folder(s) to hold the new experiment assert(all(cellfun(@(p) mkdir(p), expPath)), 'Creating experiment directories failed'); -if ~strcmp(subject, 'default') % Ignore fake subject +if ~strcmp(subject,'default') % Ignore fake subject % if the Alyx Instance is set, find or create BASE session expDate = alyx.datestr(expDate); % date in Alyx format - if ~isempty(AlyxInstance) - % Get list of base sessions - sessions = alyx.getData(AlyxInstance,... - ['sessions?type=Base&subject=' subject]); - - %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), expDate(1:10)) - d = struct; - d.subject = subject; - d.procedures = {'Behavior training/tasks'}; - d.narrative = 'auto-generated session'; - d.start_time = expDate; - d.type = 'Base'; - % d.users = {AlyxInstance.username}; - - base_submit = alyx.postData(AlyxInstance, 'sessions', d); - assert(isfield(base_submit,'subject'),... - 'Submitted base session did not return appropriate values'); - - %Now retrieve the sessions again - sessions = alyx.getData(AlyxInstance,... - ['sessions?type=Base&subject=' subject]); - end - latest_base = sessions{end}; - - %Now create a new SUBSESSION, using the same experiment number + % Get list of base sessions + sessions = alyx.getData(AlyxInstance,... + ['sessions?type=Base&subject=' subject]); + + %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), expDate(1:10)) d = struct; d.subject = subject; d.procedures = {'Behavior training/tasks'}; d.narrative = 'auto-generated session'; d.start_time = expDate; - d.type = 'Experiment'; - d.parent_session = latest_base.url; - d.number = expSeq; - % d.users = {AlyxInstance.username}; + d.type = 'Base'; + % d.users = {AlyxInstance.username}; - try - subsession = alyx.postData(AlyxInstance, 'sessions', d); - url = subsession.url; - catch - url = []; - end - else % If not logged in to Alyx... - url = []; % set the base url to null - end - - % if the parameters had an experiment definition function, save a copy in - % the experiment's folder - if isfield(expParams, 'defFunction') - assert(file.exists(expParams.defFunction),... - 'Experiment definition function does not exist: %s', expParams.defFunction); - assert(all(cellfun(@(p)copyfile(expParams.defFunction, p),... - dat.expFilePath(expRef, 'expDefFun'))),... - 'Copying definition function to experiment folders failed'); + base_submit = alyx.postData(AlyxInstance, 'sessions', d); + assert(isfield(base_submit,'subject'),... + 'Submitted base session did not return appropriate values'); + + %Now retrieve the sessions again + sessions = alyx.getData(AlyxInstance,... + ['sessions?type=Base&subject=' subject]); end + latest_base = sessions{end}; - % now save the experiment parameters variable - superSave(dat.expFilePath(expRef, 'parameters'), struct('parameters', expParams)); + %Now create a new SUBSESSION, using the same experiment number + d = struct; + d.subject = subject; + d.procedures = {'Behavior training/tasks'}; + d.narrative = 'auto-generated session'; + d.start_time = expDate; + d.type = 'Experiment'; + d.parent_session = latest_base.url; + d.number = expSeq; + % d.users = {AlyxInstance.username}; + subsession = alyx.postData(AlyxInstance, 'sessions', d); + assert(isfield(subsession,'subject'),... + 'Failed to create new sub-session in Alyx for %s', subject); + url = subsession.url; +else + url = []; +end + +% if the parameters had an experiment definition function, save a copy in +% the experiment's folder +if isfield(expParams, 'defFunction') + assert(file.exists(expParams.defFunction),... + 'Experiment definition function does not exist: %s', expParams.defFunction); + assert(all(cellfun(@(p)copyfile(expParams.defFunction, p),... + dat.expFilePath(expRef, 'expDefFun'))),... + 'Copying definition function to experiment folders failed'); +end + +% now save the experiment parameters variable +superSave(dat.expFilePath(expRef, 'parameters'), struct('parameters', expParams)); + +if ~isempty(expParams) try % save a copy of parameters in json % First, change all functions to strings f_idx = structfun(@(s)isa(s, 'function_handle'), expParams); @@ -152,5 +150,5 @@ url, 'Parameters', [], AlyxInstance); end end - +end end \ No newline at end of file From 9621ddcb42fbd68a43de46164e46b7c8136ec470 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 5 Feb 2018 13:47:05 +0000 Subject: [PATCH 070/507] Doesn't force login if subject is 'default' --- +dat/newExp.m | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/+dat/newExp.m b/+dat/newExp.m index acd3c48e..2358ac04 100644 --- a/+dat/newExp.m +++ b/+dat/newExp.m @@ -30,7 +30,7 @@ expParams = []; end -if nargin < 4 || isempty(AlyxInstance) +if (nargin < 4 || isempty(AlyxInstance)) && ~strcmp(subject, 'default') % no instance of Alyx, don't create session on Alyx AlyxInstance = alyx.loginWindow; end @@ -66,7 +66,7 @@ % now make the folder(s) to hold the new experiment assert(all(cellfun(@(p) mkdir(p), expPath)), 'Creating experiment directories failed'); -if ~strcmp(subject,'default') % Ignore fake subject +if ~strcmp(subject, 'default') % Ignore fake subject % if the Alyx Instance is set, find or create BASE session expDate = alyx.datestr(expDate); % date in Alyx format % Get list of base sessions @@ -139,13 +139,13 @@ [expRef, '_parameters.json']); savejson('parameters', expParams, jsonPath); % Register our JSON parameter set to Alyx - if ~strcmp(subject,'default') + if ~strcmp(subject, 'default') alyx.registerFile(jsonPath, 'json', url, 'Parameters', [], AlyxInstance); end catch ex warning(ex.identifier, 'Failed to save paramters as JSON: %s.\n Registering mat file instead', ex.message) % Register our parameter set to Alyx - if ~strcmp(subject,'default') + if ~strcmp(subject, 'default') alyx.registerFile(dat.expFilePath(expRef, 'parameters', 'master'), 'mat',... url, 'Parameters', [], AlyxInstance); end From 87184782e29a7ad7beb2ee1f69cb304ac9f0982c Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 6 Feb 2018 18:19:24 +0000 Subject: [PATCH 071/507] Fixes for file registration Fix'd some typos --- +exp/Experiment.m | 4 ++-- +exp/SignalsExp.m | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/+exp/Experiment.m b/+exp/Experiment.m index e26860a0..5671b983 100644 --- a/+exp/Experiment.m +++ b/+exp/Experiment.m @@ -781,12 +781,12 @@ function saveData(obj) if strcmp(subject, 'default'); return; end % Register saved files obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... - obj.AlyxInstance.subsessionURL, 'Block', []); + obj.AlyxInstance.SessionURL, 'Block', []); % Save the session end time obj.AlyxInstance.putData(obj.AlyxInstance.SessionURL,... struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject)); catch ex - warning(ex.identifer, 'Failed to register files to Alyx: %s', ex.message); + warning(ex.identifier, 'Failed to register files to Alyx: %s', ex.message); end end end diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index cbcacb1f..f83e238b 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -850,12 +850,12 @@ function saveData(obj) if strcmp(subject, 'default'); return; end % Register saved files obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... - obj.AlyxInstance.subsessionURL, 'Block', []); + obj.AlyxInstance.SessionURL, 'Block', []); % Save the session end time obj.AlyxInstance.putData(obj.AlyxInstance.SessionURL,... struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject)); catch ex - warning(ex.identifer, 'Failed to register files to Alyx: %s', ex.message); + warning(ex.identifier, 'Failed to register files to Alyx: %s', ex.message); end end end From 48d13432569b08d0686552145f60996fa3508cce Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 7 Feb 2018 13:10:15 +0000 Subject: [PATCH 072/507] Fix'd expServer quit bug Listeners properly deleted and expServer now ends when q key is pressed, as before. --- +srv/expServer.m | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/+srv/expServer.m b/+srv/expServer.m index 1ac91302..a4c6149d 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -23,7 +23,7 @@ function expServer(useTimelineOverride, bgColour) rng('shuffle'); % communicator for receiving commands from clients communicator = io.WSJCommunicator.server(listenPort); -addlistener(communicator, 'MessageReceived',... +listener = event.listener(communicator, 'MessageReceived',... @(~,msg) handleMessage(msg.Id, msg.Data, msg.Sender)); communicator.EventMode = false; communicator.open(); @@ -46,6 +46,7 @@ function expServer(useTimelineOverride, bgColour) cleanup = onCleanup(@() fun.applyForce({ @() communicator.close(),... + @() delete(listener),... @ShowCursor,... @KbQueueRelease,... @() rig.stimWindow.close(),... From 2d464423799b26edb9ee0830dfee78b32d2d9d47 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 7 Feb 2018 18:59:49 +0000 Subject: [PATCH 073/507] Fixes to AlyxPanel and ExpPanel * Comments now saved properly in ExpPanel * More detail given about subject in stand-alone AlyxPanel * PutData now a public method :'( --- +dat/updateLogEntry.m | 6 ++++-- +eui/AlyxPanel.m | 36 ++++++++++++++++++++++++++++++------ +eui/ExpPanel.m | 2 +- +eui/MControl.m | 3 ++- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/+dat/updateLogEntry.m b/+dat/updateLogEntry.m index bf3e94ee..ecbf3265 100644 --- a/+dat/updateLogEntry.m +++ b/+dat/updateLogEntry.m @@ -10,9 +10,11 @@ function updateLogEntry(subject, id, newEntry) % 2013-03 CB created -if isfield(newEntry, 'AlyxInstance')&&~isempty(newEntry.comments) +if isfield(newEntry, 'AlyxInstance') % Update session narrative on Alyx - newEntry.AlyxInstance.updateNarrative(subject, obj.LogEntry.comments); + if ~isempty(newEntry.comments) + newEntry.comments = newEntry.AlyxInstance.updateNarrative(subject, newEntry.comments); + end newEntry = rmfield(newEntry, 'AlyxInstance'); end diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 86349026..48fb4f70 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -263,6 +263,7 @@ function login(obj) notify(obj, 'Disconnected'); % Notify listeners of logout obj.log('Logged out of Alyx'); end + obj.dispWaterReq() end function giveWater(obj) @@ -320,12 +321,35 @@ function dispWaterReq(obj, src, ~) stop(obj.LoginTimer); start(obj.LoginTimer) try s = ai.getData(ai.makeEndpoint(['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)); + if s.water_requirement_total==0 % Subject not on water restriction + set(obj.WaterRequiredText, 'ForegroundColor', 'black',... + '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)); + % 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 + record = wr.records{1}; + weight = getOr(record, 'weight_measured', NaN); % Get today's measured weight + water = getOr(record, 'water_given', 0); % Get total water given + gel = getOr(record, 'hydrogel_given', 0); % Get total gel given + % Set colour based on weight percentage + weight_pct = weight/record.weight_expected; + if weight_pct < 0.8 % Mouse below 80% original weight + colour = [0.91, 0.41, 0.17]; % Orange + weight_pct = '< 80%'; + elseif weight_pct < 0.7 % Mouse below 70% original weight + colour = 'red'; + weight_pct = '< 70%'; + else + colour = 'black'; % Mouse above 80% or no weight measured today + weight_pct = '> 80%'; + end + % 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, s.water_requirement_remaining, s.water_requirement_total, weight, weight_pct, sum([water gel]))); + % Set WaterRemaining attribute for changeWaterText callback obj.WaterRemaining = s.water_requirement_remaining; end catch me @@ -345,7 +369,7 @@ function changeWaterText(obj, src, ~) % return will display this without posting to Alyx % % See also DISPWATERREQ, GIVEWATER - if ~obj.AlyxInstance.IsLoggedIn && ~isempty(obj.WaterRemaining) + if obj.AlyxInstance.IsLoggedIn && ~isempty(obj.WaterRemaining) rem = obj.WaterRemaining; curr = str2double(src.String); set(obj.WaterRemainingText, 'String', sprintf('(%.2f)', rem-curr)); diff --git a/+eui/ExpPanel.m b/+eui/ExpPanel.m index 4e6c2cb0..7878f6da 100644 --- a/+eui/ExpPanel.m +++ b/+eui/ExpPanel.m @@ -298,7 +298,7 @@ function saveLogEntry(obj) % subsession's narrative field. % % See also DAT.UPDATELOGENTRY, COMMENTSCHANGED - dat.updateLogEntry(dat.parseExpRef(obj.SubjectRef), obj.LogEntry.id, obj.LogEntry); + dat.updateLogEntry(obj.SubjectRef, obj.LogEntry.id, obj.LogEntry); end function viewParams(obj) diff --git a/+eui/MControl.m b/+eui/MControl.m index 9d706860..23beda8f 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -537,8 +537,9 @@ function beginExp(obj) if ~ai.IsLoggedIn && ~strcmp(obj.NewExpSubject.Selected,'default') try obj.AlyxPanel.login(); + assert(ai.IsLoggedIn); 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 From 4f9eaeae0732d6e8fdaf53d331dddad057f5d6a2 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 8 Feb 2018 11:29:46 +0000 Subject: [PATCH 074/507] Headless flag set by expServer * expServer now won't try to show dialogs * update to posting Alyx narrative --- +dat/updateLogEntry.m | 2 +- +srv/expServer.m | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/+dat/updateLogEntry.m b/+dat/updateLogEntry.m index ecbf3265..7970edea 100644 --- a/+dat/updateLogEntry.m +++ b/+dat/updateLogEntry.m @@ -13,7 +13,7 @@ function updateLogEntry(subject, id, newEntry) if isfield(newEntry, 'AlyxInstance') % Update session narrative on Alyx if ~isempty(newEntry.comments) - newEntry.comments = newEntry.AlyxInstance.updateNarrative(subject, newEntry.comments); + newEntry.comments = newEntry.AlyxInstance.updateNarrative(newEntry.comments); end newEntry = rmfield(newEntry, 'AlyxInstance'); end diff --git a/+srv/expServer.m b/+srv/expServer.m index a4c6149d..f43a1749 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -171,6 +171,7 @@ function handleMessage(id, data, host) case 'run' % exp run request [expRef, preDelay, postDelay, Alyx] = args{:}; + Alyx.Headless = true; % Supress all dialog prompts if dat.expExists(expRef) log('Starting experiment ''%s''', expRef); communicator.send(id, []); From 0b1b5c61327ec528033ae876a9c548ad3353b22c Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 9 Feb 2018 14:45:52 +0000 Subject: [PATCH 075/507] Fix for when record empty in dispWaterReq --- +eui/AlyxPanel.m | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 48fb4f70..1340d164 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -314,7 +314,8 @@ function dispWaterReq(obj, src, ~) % Set the selected subject if it is an input if nargin>1; obj.Subject = src.Selected; end if ~ai.IsLoggedIn - set(obj.WaterRequiredText, 'String', 'Log in to see water requirements'); + set(obj.WaterRequiredText, 'ForegroundColor', 'black',... + 'String', 'Log in to see water requirements'); return end % Refresh the timer as the user isn't inactive @@ -329,12 +330,17 @@ function dispWaterReq(obj, src, ~) 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 - record = wr.records{1}; + if ~isempty(wr.records) + record = wr.records{end}; + else + record = struct(); + end weight = getOr(record, 'weight_measured', NaN); % Get today's measured weight water = getOr(record, 'water_given', 0); % Get total water given gel = getOr(record, 'hydrogel_given', 0); % Get total gel given + weight_expected = getOr(record, 'weight_expected', NaN); % Set colour based on weight percentage - weight_pct = weight/record.weight_expected; + weight_pct = weight/weight_expected; if weight_pct < 0.8 % Mouse below 80% original weight colour = [0.91, 0.41, 0.17]; % Orange weight_pct = '< 80%'; @@ -355,7 +361,8 @@ function dispWaterReq(obj, src, ~) 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)); + set(obj.WaterRequiredText, 'ForegroundColor', 'black',... + 'String', sprintf('Subject %s not found in alyx', obj.Subject)); end end end From d813ff04c25ae26d738c415afed32f105af6bc5e Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 13 Feb 2018 10:52:52 +0000 Subject: [PATCH 076/507] Alyx now a value class We need it to be copyable and there are no plans for events --- +eui/AlyxPanel.m | 9 +++++---- +eui/MControl.m | 14 +++++++------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 1340d164..ee9618af 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -205,12 +205,13 @@ function login(obj) % Are we logging in or out? if ~obj.AlyxInstance.IsLoggedIn % logging in % attempt login - obj.AlyxInstance.login(); % returns an instance if success, empty if you cancel + obj.AlyxInstance = obj.AlyxInstance.login(); % 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', 30*60, 'TimerFcn',... + @(~,~)obj.login, 'BusyMode', 'queue'); start(obj.LoginTimer) % Enable all buttons set(findall(obj.RootContainer, '-property', 'Enable'), 'Enable', 'on'); @@ -250,7 +251,7 @@ function login(obj) obj.log('Did not log into Alyx'); end else % logging out - obj.AlyxInstance.logout; + obj = obj.AlyxInstance.logout; if ~isempty(obj.LoginTimer) % If there is a timer object stop(obj.LoginTimer) % Stop the timer... delete(obj.LoginTimer) % ... delete it... @@ -319,7 +320,7 @@ function dispWaterReq(obj, src, ~) return end % Refresh the timer as the user isn't inactive - stop(obj.LoginTimer); start(obj.LoginTimer) + stop(obj.LoginTimer); start(obj.LoginTimer) try s = ai.getData(ai.makeEndpoint(['subjects/' obj.Subject])); % struct with data about the subject if s.water_requirement_total==0 % Subject not on water restriction diff --git a/+eui/MControl.m b/+eui/MControl.m index 23beda8f..d0dbc286 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -347,7 +347,8 @@ function rigExpStopped(obj, rig, evt) % Announce that the experiment has stopped obj.log('Warning: unable to query Alyx about %s''s water requirements', subject); end % Remove AlyxInstance from rig; no longer required - delete(rig.AlyxInstance); +% delete(rig.AlyxInstance); % Line invalid now Alyx no longer +% handel class rig.AlyxInstance = []; end end @@ -533,11 +534,10 @@ function beginExp(obj) set([obj.BeginExpButton obj.RigOptionsButton], 'Enable', 'off'); % Grey out buttons rig = obj.RemoteRigs.Selected; % Find which rig is selected % Save the current instance of Alyx so that eui.ExpPanel can register water to the correct account - ai = obj.AlyxPanel.AlyxInstance; - if ~ai.IsLoggedIn && ~strcmp(obj.NewExpSubject.Selected,'default') + if ~obj.AlyxPanel.AlyxInstance.IsLoggedIn && ~strcmp(obj.NewExpSubject.Selected,'default') try obj.AlyxPanel.login(); - assert(ai.IsLoggedIn); + assert(obj.AlyxPanel.AlyxInstance.IsLoggedIn); catch obj.log('Warning: Must be logged in to Alyx before running an experiment') return @@ -549,11 +549,11 @@ function beginExp(obj) obj.Parameters.set('services', services(:),... 'List of experiment services to use during the experiment'); % Create new experiment reference - [expRef, ~] = ai.newExp(obj.NewExpSubject.Selected, now,... - obj.Parameters.Struct); + [expRef, ~] = 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 = ai.copy; + rig.AlyxInstance = obj.AlyxPanel.AlyxInstance; panel = eui.ExpPanel.live(obj.ActiveExpsGrid, expRef, rig, obj.Parameters.Struct); obj.LastExpPanel = panel; From 723986c2dea77e9c6285a34d5cf8e19646e433e3 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 13 Feb 2018 13:28:16 +0000 Subject: [PATCH 077/507] Log out bug fix --- +eui/AlyxPanel.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index ee9618af..a5bff35b 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -251,7 +251,7 @@ function login(obj) obj.log('Did not log into Alyx'); end else % logging out - obj = obj.AlyxInstance.logout; + 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... From daa0e0d2a70d3235816a53c56614579f704d5516 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 14 Feb 2018 10:47:01 +0000 Subject: [PATCH 078/507] Registration without sessionURL --- +eui/MControl.m | 3 ++- +exp/Experiment.m | 12 ++++++++---- +exp/SignalsExp.m | 12 ++++++++---- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/+eui/MControl.m b/+eui/MControl.m index d0dbc286..3dd65802 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -549,11 +549,12 @@ function beginExp(obj) obj.Parameters.set('services', services(:),... 'List of experiment services to use during the experiment'); % Create new experiment reference - [expRef, ~] = obj.AlyxPanel.AlyxInstance.newExp(... + [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; diff --git a/+exp/Experiment.m b/+exp/Experiment.m index 5671b983..a4c6bf33 100644 --- a/+exp/Experiment.m +++ b/+exp/Experiment.m @@ -777,14 +777,18 @@ function saveData(obj) warning('No Alyx token set'); else try - subject = dat.parseExpRef(obj.Data.expRef); + [subject, expDate, seq] = dat.parseExpRef(obj.Data.expRef); if strcmp(subject, 'default'); return; end % Register saved files obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... - obj.AlyxInstance.SessionURL, 'Block', []); + {subject, expDate, seq}, 'Block', []); % Save the session end time - obj.AlyxInstance.putData(obj.AlyxInstance.SessionURL,... - struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject)); + if ~isempty(obj.AlyxInstance.SessionURL) + obj.AlyxInstance.putData(obj.AlyxInstance.SessionURL,... + struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject)); + else + % Infer from date session and retrieve using expFilePath + end catch ex warning(ex.identifier, 'Failed to register files to Alyx: %s', ex.message); end diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index f83e238b..12309f75 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -846,14 +846,18 @@ function saveData(obj) warning('No Alyx token set'); else try - subject = dat.parseExpRef(obj.Data.expRef); + [subject, expDate, seq] = dat.parseExpRef(obj.Data.expRef); if strcmp(subject, 'default'); return; end % Register saved files obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... - obj.AlyxInstance.SessionURL, 'Block', []); + {subject, expDate, seq}, 'Block', []); % Save the session end time - obj.AlyxInstance.putData(obj.AlyxInstance.SessionURL,... - struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject)); + if ~isempty(obj.AlyxInstance.SessionURL) + obj.AlyxInstance.putData(obj.AlyxInstance.SessionURL,... + struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject)); + else + % Infer from date session and retrieve using expFilePath + end catch ex warning(ex.identifier, 'Failed to register files to Alyx: %s', ex.message); end From 1593968d6c52b9283bf230aad4150ba6455885b5 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 14 Feb 2018 10:48:50 +0000 Subject: [PATCH 079/507] Timeline registers files without URL --- +hw/Timeline.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index e83b455e..50593790 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -463,11 +463,11 @@ function stop(obj) end % register Timeline.mat file to Alyx database - subject = dat.parseExpRef(obj.Data.expRef); + [subject, expDate, seq] = dat.parseExpRef(obj.Data.expRef); if obj.AlyxInstance.IsLoggedIn && ~strcmp(subject,'default') try obj.AlyxInstance.registerFile(obj.Data.savePaths{end}, 'mat',... - obj.AlyxInstance.SessionURL, 'Timeline', []); + {subject, expDate, seq}, 'Timeline', []); catch warning('couldn''t register files to alyx'); end From 6addcfbc56693729c148c758fa5269f485e0b90f Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 14 Feb 2018 11:23:33 +0000 Subject: [PATCH 080/507] UI Changes to MC * Rig list alphabetized * Can no longer begin experiment without loading params --- +eui/MControl.m | 17 ++++++++++++++--- +srv/stimulusControllers.m | 4 +++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/+eui/MControl.m b/+eui/MControl.m index 3dd65802..367f9428 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -264,6 +264,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, '') @@ -308,6 +309,7 @@ function loadParamProfile(obj, profile) if ~isempty(paramStruct) % Now parameters are loaded, pass to ParamEditor for display, etc. 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); + set(obj.BeginExpButton, 'Enable', 'on') % Re-enable start button end end @@ -332,7 +334,10 @@ 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 rig.AlyxInstance.IsLoggedIn @@ -394,7 +399,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 @@ -445,7 +453,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 diff --git a/+srv/stimulusControllers.m b/+srv/stimulusControllers.m index 05406f51..0ac89a83 100644 --- a/+srv/stimulusControllers.m +++ b/+srv/stimulusControllers.m @@ -9,6 +9,8 @@ p = dat.paths; sc = loadVar(fullfile(p.globalConfig, 'remote.mat'), 'stimulusControllers'); - +% Order alphabetically +[~, I] = sort(arrayfun(@(o)o.Name, sc, 'UniformOutput', false)); +sc = sc(I); end From efa4a5a8960bf0de6bf3f06ce579c2488b76f1af Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 14 Feb 2018 19:30:50 +0000 Subject: [PATCH 081/507] Fixes to BasicUDPService --- +hw/Timeline.m | 4 ++-- +srv/BasicUDPService.m | 33 ++++++++++++++++++++++----------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 50593790..e9a6aac2 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -468,8 +468,8 @@ function stop(obj) try obj.AlyxInstance.registerFile(obj.Data.savePaths{end}, 'mat',... {subject, expDate, seq}, 'Timeline', []); - catch - warning('couldn''t register files to alyx'); + catch ex + warning(ex.identifier, 'couldn''t register files to Alyx: %s', ex.message); end end %TODO: Register ALF components to alyx, incl TimelineHW.json diff --git a/+srv/BasicUDPService.m b/+srv/BasicUDPService.m index f384704b..91779d86 100644 --- a/+srv/BasicUDPService.m +++ b/+srv/BasicUDPService.m @@ -131,13 +131,15 @@ function bind(obj) fopen(obj.Socket); end - function start(obj, expRef) + function start(obj, expRef, ai) + ref = Alyx.parseAlyxInstance(expRef, ai); % Send start message to remotehost and await confirmation - obj.confirmedSend(sprintf('GOGO%s*%s', expRef, hostname)); + obj.confirmedSend(sprintf('GOGO%s*%s', ref, hostname)); while obj.AwaitingConfirmation&&... ~strcmp(obj.LastReceivedMessage, obj.LastSentMessage) pause(0.2); end + if strcmp(obj.Status, 'exception'); error('Confirmation failed'); end end function stop(obj) @@ -163,14 +165,13 @@ function confirmedSend(obj, msg) % Add timer to impose a response timeout if ~isinf(obj.ResponseTimeout) obj.ResponseTimer = timer('StartDelay', obj.ResponseTimeout,... - 'TimerFcn', @(src,evt)obj.processMsg(src,evt), 'StopFcn', @(src,~)delete(src)); + 'TimerFcn', @(src,evt)obj.processMsg(src,evt), 'StopFcn', @(src,~)stop(src)); start(obj.ResponseTimer) % start the timer end end function receiveUDP(obj) obj.LastReceivedMessage = strtrim(fscanf(obj.Socket)); - % Remove any more accumulated inputs to the listener % obj.Socket.flushinput(); notify(obj, 'MessageReceived') @@ -196,16 +197,24 @@ function processMsg(obj, src, evt) return end % We no longer need the timer, stop it - if ~isempty(obj.ResponseTimer); stop(obj.ResponseTimer); end + if ~isempty(obj.ResponseTimer) + stop(obj.ResponseTimer); + delete(obj.ResponseTimer); + obj.ResponseTimer = []; + end if obj.AwaitingConfirmation % Reset AwaitingConfirmation obj.AwaitingConfirmation = false; - % Check the confirmation message is the same as the sent message - assert(~isempty(response)&&... % something received - strcmp(response.status, 'WHAT')||... % status update - strcmp(obj.LastReceivedMessage, obj.LastSentMessage),... % is echo - 'Confirmation failed') + try % Check the confirmation message is the same as the sent message + assert(~isempty(response)&&... % something received + strcmp(response.status, 'WHAT')||... % status update + strcmp(obj.LastReceivedMessage, obj.LastSentMessage),... % is echo + 'Confirmation failed') + catch ex + obj.Status = 'exception'; + rethrow(ex) + end end % At the moment we just disply some stuff, other functions listening % to the MessageReceived event can do their thing @@ -224,6 +233,7 @@ function processMsg(obj, src, evt) obj.LocalStatus = 'running'; obj.sendUDP(obj.LastReceivedMessage) catch ex + obj.LocalStatus = 'exception'; error('Failed to start service: %s', ex.message) end end @@ -241,6 +251,7 @@ function processMsg(obj, src, evt) obj.LocalStatus = 'idle'; obj.sendUDP(obj.LastReceivedMessage) catch + obj.LocalStatus = 'exception'; error('Failed to stop service') end end @@ -261,7 +272,7 @@ function processMsg(obj, src, evt) obj.Status = 'unavailable'; end else - % Ignore if it is our on + % Ignore if it is our own if strcmp(response.host, hostname); return; end try obj.sendUDP(['WHAT' parsed.id obj.LocalStatus '*' obj.RemoteHost]) From dc3d43de84f440dc264ad09ae7c6d78386927ac4 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 15 Feb 2018 16:24:45 +0000 Subject: [PATCH 082/507] List subjects now added to Alyx --- +eui/AlyxPanel.m | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index a5bff35b..4a097d5a 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -217,20 +217,8 @@ function login(obj) set(findall(obj.RootContainer, '-property', 'Enable'), 'Enable', 'on'); set(obj.LoginText, '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 = obj.AlyxInstance.getData('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, obj.AlyxInstance.User))); - 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; From b266dd454f6f553a302d77b3c7b49bd3a321cbf8 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 15 Feb 2018 17:06:55 +0000 Subject: [PATCH 083/507] AlyxPanel enhancement now more accurately determines whether a mouse is on water restriction by comparing with data from water-restricted-subjects endpoint --- +eui/AlyxPanel.m | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 4a097d5a..a391f2da 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -217,7 +217,8 @@ function login(obj) set(findall(obj.RootContainer, '-property', 'Enable'), 'Enable', 'on'); set(obj.LoginText, '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 newSubs = obj.AlyxInstance.listSubjects; obj.NewExpSubject.Option = newSubs; obj.SubjectList = newSubs; @@ -310,8 +311,9 @@ function dispWaterReq(obj, src, ~) % Refresh the timer as the user isn't inactive stop(obj.LoginTimer); start(obj.LoginTimer) try - s = ai.getData(ai.makeEndpoint(['subjects/' obj.Subject])); % struct with data about the subject - if s.water_requirement_total==0 % Subject not on water restriction + s = catStructs(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 @@ -343,9 +345,9 @@ function dispWaterReq(obj, src, ~) % 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, s.water_requirement_remaining, s.water_requirement_total, weight, weight_pct, sum([water gel]))); + obj.Subject, s(idx).water_requirement_remaining, s(idx).water_requirement_total, weight, weight_pct, sum([water gel]))); % Set WaterRemaining attribute for changeWaterText callback - obj.WaterRemaining = s.water_requirement_remaining; + obj.WaterRemaining = s(idx).water_requirement_remaining; end catch me d = loadjson(me.message); From 5926288265d9fa5de1ee29b4f3f70c9340a9d625 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 15 Feb 2018 17:08:47 +0000 Subject: [PATCH 084/507] doesn't try to post 'default' comments Won't post default's comments to Alyx --- +dat/updateLogEntry.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+dat/updateLogEntry.m b/+dat/updateLogEntry.m index 7970edea..0882ba2a 100644 --- a/+dat/updateLogEntry.m +++ b/+dat/updateLogEntry.m @@ -12,7 +12,7 @@ function updateLogEntry(subject, id, newEntry) if isfield(newEntry, 'AlyxInstance') % Update session narrative on Alyx - if ~isempty(newEntry.comments) + if ~isempty(newEntry.comments) && ~strcmp(subject, 'default') newEntry.comments = newEntry.AlyxInstance.updateNarrative(newEntry.comments); end newEntry = rmfield(newEntry, 'AlyxInstance'); From fcf2a2b1326a632ab3437bfce2ae0501e67831f6 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 19 Feb 2018 14:24:27 +0000 Subject: [PATCH 085/507] Temp roll-back register with url until alyx dev merge --- +exp/SignalsExp.m | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 12309f75..6723095d 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -850,7 +850,9 @@ function saveData(obj) if strcmp(subject, 'default'); return; end % Register saved files obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... - {subject, expDate, seq}, 'Block', []); + obj.AlyxInstance.SessionURL, 'Block', []); +% obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... +% {subject, expDate, seq}, 'Block', []); % Save the session end time if ~isempty(obj.AlyxInstance.SessionURL) obj.AlyxInstance.putData(obj.AlyxInstance.SessionURL,... From b5fa0a02d2729a1e89dc2dfb694f4dc0675b7ff2 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 20 Feb 2018 17:45:29 +0000 Subject: [PATCH 086/507] Fix'd beginExp bug can no longer start experiment on rig already running one --- +eui/MControl.m | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/+eui/MControl.m b/+eui/MControl.m index 367f9428..f46c4e96 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -309,7 +309,9 @@ function loadParamProfile(obj, profile) if ~isempty(paramStruct) % Now parameters are loaded, pass to ParamEditor for display, etc. 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); - set(obj.BeginExpButton, 'Enable', 'on') % Re-enable start button + if strcmp(obj.RemoteRigs.Selected.Status, 'idle') + set(obj.BeginExpButton, 'Enable', 'on') % Re-enable start button + end end end From 7ff31c20291cb283f2ed3d7f5197825b79f05946 Mon Sep 17 00:00:00 2001 From: jjlee42 Date: Wed, 21 Feb 2018 20:20:06 +0000 Subject: [PATCH 087/507] Errors clearer in MpepUDPDataHosts --- cortexlab/+io/MpepUDPDataHosts.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cortexlab/+io/MpepUDPDataHosts.m b/cortexlab/+io/MpepUDPDataHosts.m index 07bd3cea..c6b4f34f 100644 --- a/cortexlab/+io/MpepUDPDataHosts.m +++ b/cortexlab/+io/MpepUDPDataHosts.m @@ -273,8 +273,8 @@ function validateResponses(obj) dt = toc; match = find(strcmp(waiting, ip), 1); assert(~isempty(match),... - 'Received UDP packet after %.2fs from unexpected IP address ''%s'',\nmessage was ''%s''',... - dt, ip, msg); + 'Received UDP packet after %.2fs from unexpected IP address ''%s'',\nmessage was ''%s''\nAwaiting response from %s',... + dt, ip, msg, strjoin(obj.RemoteHosts, ', ')); waiting(match) = []; % remove matching IP from confirmation list ok(i) = isequal(expecting, msg); end From 8e98c779e306983aaace80d11dd22a20d3975f3a Mon Sep 17 00:00:00 2001 From: jjlee42 Date: Wed, 21 Feb 2018 20:26:09 +0000 Subject: [PATCH 088/507] Fix'd parseAlyxInstance --- cortexlab/+io/MpepUDPDataHosts.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexlab/+io/MpepUDPDataHosts.m b/cortexlab/+io/MpepUDPDataHosts.m index c6b4f34f..1f743832 100644 --- a/cortexlab/+io/MpepUDPDataHosts.m +++ b/cortexlab/+io/MpepUDPDataHosts.m @@ -205,7 +205,7 @@ function start(obj, expRef, ai) % Deal with Alyx instance first if ~isempty(ai) obj.AlyxInstance = ai; - UDP_msg = ai.parseAlyxInstance; + UDP_msg = Alyx.parseAlyxInstance(expRef, ai); [subject, seriesNum, expNum] = dat.expRefToMpep(expRef); alyxmsg = sprintf('alyx %s %d %d %s', subject, seriesNum, expNum, UDP_msg); confirmedBroadcast(obj, alyxmsg); From 93c69f0f4a98eee45f0832d5181ebf4eceb92874 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 21 Feb 2018 20:29:17 +0000 Subject: [PATCH 089/507] Updated bindMpepServer --- +hw/Timeline.m | 10 +++++----- cortexlab/+dat/subjectSelector.m | 4 ++-- cortexlab/+tl/bindMpepServer.m | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index e9a6aac2..0613a3b5 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -126,9 +126,9 @@ end end - function start(obj, expRef, Alyx) + function start(obj, expRef, ai) % START Starts timeline data acquisition - % START(obj, ref, Alyx) starts all DAQ sessions and adds + % START(obj, ref, AlyxInstance) starts all DAQ sessions and adds % the relevent output and input channels. % % See Also HW.TLOUTPUT/START @@ -138,7 +138,7 @@ function start(obj, expRef, Alyx) obj.stop(); end obj.Ref = expRef; % set the current experiment ref - obj.AlyxInstance = Alyx; % set the current instance of Alyx + obj.AlyxInstance = ai; % set the current instance of Alyx init(obj); % start the relevent sessions and add channels obj.Listener = obj.Sessions('main').addlistener('DataAvailable', @obj.process); % add listener @@ -463,11 +463,11 @@ function stop(obj) end % register Timeline.mat file to Alyx database - [subject, expDate, seq] = dat.parseExpRef(obj.Data.expRef); + [subject, ~, ~] = dat.parseExpRef(obj.Data.expRef); if obj.AlyxInstance.IsLoggedIn && ~strcmp(subject,'default') try obj.AlyxInstance.registerFile(obj.Data.savePaths{end}, 'mat',... - {subject, expDate, seq}, 'Timeline', []); + obj.AlyxInstance.SessionURL, 'Timeline', []); catch ex warning(ex.identifier, 'couldn''t register files to Alyx: %s', ex.message); end diff --git a/cortexlab/+dat/subjectSelector.m b/cortexlab/+dat/subjectSelector.m index 23df89b9..17097fe7 100644 --- a/cortexlab/+dat/subjectSelector.m +++ b/cortexlab/+dat/subjectSelector.m @@ -6,7 +6,7 @@ % from alyx; otherwise, from dat.listSubjects % % example usage: -% >> alyxInstance = alyx.loginWindow(); +% >> alyxInstance = Alyx.login; % >> [subj, expNum] = subjectSelector([], alyxInstance); % % Created by NS 2017 @@ -44,7 +44,7 @@ if nargin>1 ai = varargin{2}; - set(subjectDropdown, 'String', dat.listSubjects(ai)); + set(subjectDropdown, 'String', ai.listSubjects); else set(subjectDropdown, 'String', dat.listSubjects); end diff --git a/cortexlab/+tl/bindMpepServer.m b/cortexlab/+tl/bindMpepServer.m index 533e375b..9e15219e 100644 --- a/cortexlab/+tl/bindMpepServer.m +++ b/cortexlab/+tl/bindMpepServer.m @@ -81,7 +81,7 @@ function processMpep(listener, msg) case 'alyx' fprintf(1, 'received alyx token message\n'); idx = find(msg==' ', 1, 'last'); - [~, ai] = dat.parseAlyxInstance(msg(idx+1:end)); + [~, ai] = Alyx.parseAlyxInstance(msg(idx+1:end)); tls.AlyxInstance = ai; case 'expstart' % create a file path & experiment ref based on experiment info @@ -132,7 +132,7 @@ function listen() if isempty(tls.AlyxInstance) % first get an alyx instance - ai = alyx.loginWindow(); + ai = Alyx; else ai = tls.AlyxInstance; end @@ -142,8 +142,8 @@ function listen() if ~isempty(mouseName) clear expParams; expParams.experimentType = 'timelineManualStart'; - [newExpRef, ~, subsessionURL] = dat.newExp(mouseName, now, expParams, ai); - ai.subsessionURL = subsessionURL; + [newExpRef, ~, subsessionURL] = ai.newExp(mouseName, now, expParams); + ai.SessionURL = subsessionURL; tls.AlyxInstance = ai; tlObj.start(newExpRef, ai); end From 4999899343876473bcf6d50947838ce21e37e7ba Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 22 Feb 2018 21:25:34 +0000 Subject: [PATCH 090/507] Fixed Web page launch buttons in AlyxPanel --- +eui/AlyxPanel.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index a391f2da..5bfdeec6 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -462,7 +462,7 @@ function launchSessionURL(obj) uuid = u(find(u=='/', 1, 'last')+1:end); % make the admin url - adminURL = fullfile(ai.baseURL, 'admin', 'actions', 'session', uuid, 'change'); + adminURL = fullfile(ai.BaseURL, 'admin', 'actions', 'session', uuid, 'change'); % launch the website web(adminURL, '-browser'); @@ -470,7 +470,7 @@ function launchSessionURL(obj) function launchSubjectURL(obj) ai = obj.AlyxInstance; - if ~ai.IsLoggedIn + if ai.IsLoggedIn s = ai.getData(ai.makeEndpoint(['subjects/' obj.Subject])); subjURL = fullfile(ai.BaseURL, 'admin', 'subjects', 'subject', s.id, 'change'); % this is wrong - need uuid web(subjURL, '-browser'); From 185a3617083077726502f6ac019dce80551f88b0 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 28 Feb 2018 11:11:47 +0000 Subject: [PATCH 091/507] Optimized sqeakExpPanel & reverted newExp * Removed for loop in SqueakExpPanel * Improved plotting of discrimination task * dat.newExp no longer interacts with Alyx. For this, use Alyx.newExp. --- +dat/newExp.m | 113 +++++------------------------- +eui/SqueakExpPanel.m | 6 +- cortexlab/+psy/plot2AUFC.m | 140 ++++++++++++++++++++++--------------- 3 files changed, 100 insertions(+), 159 deletions(-) diff --git a/+dat/newExp.m b/+dat/newExp.m index 6a86e07f..7618d371 100644 --- a/+dat/newExp.m +++ b/+dat/newExp.m @@ -1,4 +1,4 @@ -function [expRef, expSeq, url] = newExp(subject, expDate, expParams, AlyxInstance) +function [expRef, expSeq] = newExp(subject, expDate, expParams) %DAT.NEWEXP Create a new unique experiment in the database % [ref, seq, url] = DAT.NEWEXP(subject, expDate, expParams[, AlyxInstance]) % Create a new experiment by creating the relevant folder tree in the @@ -9,10 +9,7 @@ % |_ expSeq/ % % If experiment parameters are passed into the function, they are saved -% here, as a mat and in JSON (if possible). If an instance of Alyx is -% passed and a base session for the experiment date is not found, one is -% created in the Alyx database. A corresponding subsession is also -% created and the parameters file is registered with the sub-session. +% here. % % See also DAT.PATHS % @@ -30,11 +27,6 @@ expParams = []; end -if nargin < 4 - % no instance of Alyx, don't create session on Alyx - AlyxInstance = []; -end - if ischar(expDate) % if the passed expDate is a string, parse it into a datenum expDate = datenum(expDate, 'yyyy-mm-dd'); @@ -66,91 +58,18 @@ % now make the folder(s) to hold the new experiment assert(all(cellfun(@(p) mkdir(p), expPath)), 'Creating experiment directories failed'); -if ~strcmp(subject, 'default') % Ignore fake subject - % if the Alyx Instance is set, find or create BASE session - expDate = alyx.datestr(expDate); % date in Alyx format - if ~isempty(AlyxInstance) - % Get list of base sessions - sessions = alyx.getData(AlyxInstance,... - ['sessions?type=Base&subject=' subject]); - - %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), expDate(1:10)) - d = struct; - d.subject = subject; - d.procedures = {'Behavior training/tasks'}; - d.narrative = 'auto-generated session'; - d.start_time = expDate; - d.type = 'Base'; - % d.users = {AlyxInstance.username}; - - base_submit = alyx.postData(AlyxInstance, 'sessions', d); - assert(isfield(base_submit,'subject'),... - 'Submitted base session did not return appropriate values'); - - %Now retrieve the sessions again - sessions = alyx.getData(AlyxInstance,... - ['sessions?type=Base&subject=' subject]); - end - latest_base = sessions{end}; - - %Now create a new SUBSESSION, using the same experiment number - d = struct; - d.subject = subject; - d.procedures = {'Behavior training/tasks'}; - d.narrative = 'auto-generated session'; - d.start_time = expDate; - d.type = 'Experiment'; - d.parent_session = latest_base.url; - d.number = expSeq; - % d.users = {AlyxInstance.username}; - - try - subsession = alyx.postData(AlyxInstance, 'sessions', d); - url = subsession.url; - catch - url = []; - end - else % If not logged in to Alyx... - url = []; % set the base url to null - end - - % if the parameters had an experiment definition function, save a copy in - % the experiment's folder - if isfield(expParams, 'defFunction') - assert(file.exists(expParams.defFunction),... - 'Experiment definition function does not exist: %s', expParams.defFunction); - assert(all(cellfun(@(p)copyfile(expParams.defFunction, p),... - dat.expFilePath(expRef, 'expDefFun'))),... - 'Copying definition function to experiment folders failed'); - end - - % now save the experiment parameters variable - superSave(dat.expFilePath(expRef, 'parameters'), struct('parameters', expParams)); - - try % save a copy of parameters in json - % First, change all functions to strings - f_idx = structfun(@(s)isa(s, 'function_handle'), expParams); - fields = fieldnames(expParams); - paramCell = struct2cell(expParams); - paramCell(f_idx) = cellfun(@func2str, paramCell(f_idx),'UniformOutput', false); - expParams = cell2struct(paramCell, fields); - % Generate JSON path and save - jsonPath = fullfile(fileparts(dat.expFilePath(expRef, 'parameters', 'master')),... - [expRef, '_parameters.json']); - savejson('parameters', expParams, jsonPath); - % Register our JSON parameter set to Alyx - if ~strcmp(subject,'default') - alyx.registerFile(jsonPath, 'json', url, 'Parameters', [], AlyxInstance); - end - catch ex - warning(ex.identifier, 'Failed to save paramters as JSON: %s.\n Registering mat file instead', ex.message) - % Register our parameter set to Alyx - if ~strcmp(subject,'default') - alyx.registerFile(dat.expFilePath(expRef, 'parameters', 'master'), 'mat',... - url, 'Parameters', [], AlyxInstance); - end - end - + +% if the parameters had an experiment definition function, save a copy in +% the experiment's folder +if isfield(expParams, 'defFunction') + assert(file.exists(expParams.defFunction),... + 'Experiment definition function does not exist: %s', expParams.defFunction); + assert(all(cellfun(@(p)copyfile(expParams.defFunction, p),... + dat.expFilePath(expRef, 'expDefFun'))),... + 'Copying definition function to experiment folders failed'); +end + +% now save the experiment parameters variable +superSave(dat.expFilePath(expRef, 'parameters'), struct('parameters', expParams)); + end \ No newline at end of file diff --git a/+eui/SqueakExpPanel.m b/+eui/SqueakExpPanel.m index 865a0c0d..39320746 100644 --- a/+eui/SqueakExpPanel.m +++ b/+eui/SqueakExpPanel.m @@ -23,12 +23,8 @@ function update(obj) update@eui.ExpPanel(obj); processUpdates(obj); % update labels with latest signal values -% labels = cell2mat(values(obj.LabelsMap))'; labelsMapVals = values(obj.LabelsMap)'; - labels = gobjects(size(values(obj.LabelsMap))); - for i=1:length(labelsMapVals) % using for loop (sorry Chris!) to populate object array 2017-02-14 MW - labels(i) = labelsMapVals{i}; - end + labels = deal([labelsMapVals{:}]); if ~isempty(labels) % colour decay by recency on labels dt = cellfun(@(t)etime(clock,t),... ensureCell(get(labels, 'UserData'))); diff --git a/cortexlab/+psy/plot2AUFC.m b/cortexlab/+psy/plot2AUFC.m index 0c260f24..66037cb6 100644 --- a/cortexlab/+psy/plot2AUFC.m +++ b/cortexlab/+psy/plot2AUFC.m @@ -1,73 +1,99 @@ function plot2AUFC(ax, block) - -% numCompletedTrials = block.numCompletedTrials; - -[block.trial(arrayfun(@(a)isempty(a.contrast), block.trial)).contrast] = deal(nan); +[block.trial(arrayfun(@(a)isempty(a.contrastLeft), block.trial)).contrastLeft] = deal(nan); +[block.trial(arrayfun(@(a)isempty(a.contrastRight), block.trial)).contrastRight] = deal(nan); [block.trial(arrayfun(@(a)isempty(a.response), block.trial)).response] = deal(nan); [block.trial(arrayfun(@(a)isempty(a.repeatNum), block.trial)).repeatNum] = deal(nan); [block.trial(arrayfun(@(a)isempty(a.feedback), block.trial)).feedback] = deal(nan); -contrast = [block.trial.contrast]; +contrast = []; +contrast(1,:) = [block.trial.contrastLeft]; +contrast(2,:) = [block.trial.contrastRight]; +% contrast = diff(contrast); response = [block.trial.response]; repeatNum = [block.trial.repeatNum]; -% feedback = [block.trial.feedback]; -if any(structfun(@isempty, block.trial(end))) % strip incomplete trials - contrast = contrast(1:end-1); - response = response(1:end-1); - repeatNum = repeatNum(1:end-1); +if any(structfun(@isnan, block.trial(end))) % strip incomplete trials + contrast = contrast(:,1:end-1); + response = response(1:end-1); + repeatNum = repeatNum(1:end-1); end respTypes = unique(response); numRespTypes = numel(respTypes); -cVals = unique(contrast); +if size(contrast, 1) > 1 + allContrast = contrast; + contrast = diff(contrast, [], 1); +else + allContrast = [0;0]; +end -psychoM = zeros(numRespTypes,length(cVals)); -psychoMCI = zeros(numRespTypes,length(cVals)); -numTrials = zeros(1,length(cVals)); -numChooseR = zeros(numRespTypes, length(cVals)); -for r = 1:numRespTypes - for c = 1:length(cVals) - incl = repeatNum==1&contrast==cVals(c); - numTrials(c) = sum(incl); - numChooseR(r,c) = sum(response==respTypes(r)&incl); - - psychoM(r, c) = numChooseR(r,c)/numTrials(c); - psychoMCI(r, c) = 1.96*sqrt(psychoM(r, c)*(1-psychoM(r, c))/numTrials(c)); +if any(allContrast(1,:)>0 & allContrast(2,:)>0) + + % mode for plotting task with two stimuli at once + cValsLeft = unique(allContrast(1,:)); + cValsRight = unique(allContrast(2,:)); + nCL = numel(cValsLeft); + nCR = numel(cValsRight); + % pedVals = cVals(1:end-1); + % numPeds = numel(pedVals); + + respTypes = unique(response); + numRespTypes = numel(respTypes); + numTrials = nan(1, nCL, nCR); + numChooseR = nan(numRespTypes, nCL, nCR); + psychoM = nan(numRespTypes, nCL, nCR); + for r = 1:numRespTypes + for c1 = 1:nCL + for c2 = 1:nCR + incl = repeatNum==1&allContrast(1,:)==cValsLeft(c1)&allContrast(2,:) == cValsRight(c2); + numTrials(1,c1,c2) = sum(incl); + numChooseR(r,c1,c2) = sum(response==respTypes(r)&incl); + psychoM(r,c1,c2) = numChooseR(r,c1,c2)/numTrials(1,c1,c2); + %psychoMCI(r, c,las) = 1.96*sqrt(psychoM(r, c,las)*(1-psychoM(r, c,las))/numTrials(1,c,las)); + end end -end - -colors = [0 1 1 - 1 0 0 - 0 1 0];%hsv(numRespTypes); -% hsv(3) - -for r = 1:numRespTypes - - xdata = 100*cVals; - ydata = 100*psychoM(r,:); -% errBars = 100*psychoMCI(r,:); - - plot(ax, xdata, ydata, '-o', 'Color', colors(r,:), 'LineWidth', 1.0); - - % set all NaN values to 0 so the fill function can proceed just - % skipping over those points. -% ydata(isnan(ydata)) = 0; -% errBars(isnan(errBars)) = 0; - - %TODO:update to use plt.hshade -% fillhandle = fill([xdata xdata(end:-1:1)],... -% [ydata+errBars ydata(end:-1:1)-errBars(end:-1:1)], colors(r,:),... -% 'Parent', ax); -% set(fillhandle, 'FaceAlpha', 0.15, 'EdgeAlpha', 0); - %,... + end + cla(ax) + psychoMCmap = reshape(permute(psychoM, [2 1 3]), numRespTypes*nCR, nCL)'; + psychoMCmap(isnan(psychoMCmap))=-1; + imagesc(ax, psychoMCmap) + colormap(colormap_pinkgreyscale) + + set(ax, 'XTick', 1:nCR*numRespTypes, 'XTickLabel', cValsRight(repmat(1:nCR, [1 numRespTypes]))); + set(ax, 'YTick', 1:nCL, 'YTickLabel', cValsLeft(1:nCL)); + + for r = 1:numRespTypes-1 + plot(ax, nCR*r+[0.5 0.5], [0.5 nCL+0.5], 'Color', [0.8 0.8 0.8], 'LineWidth', 2.0); + end + + xlim(ax, [0.5 nCR*numRespTypes+0.5]) + ylim(ax, [0.5 nCL+0.5]) + caxis(ax, [-1 1]); + axis(ax, 'image') +else + + cVals = unique(contrast); + colors = iff(numRespTypes>2,[0 1 1; 0 1 0; 1 0 0], [0 1 1; 1 0 0]); + psychoM = zeros(numRespTypes,length(cVals)); + psychoMCI = zeros(numRespTypes,length(cVals)); + numTrials = zeros(1,length(cVals)); + numChooseR = zeros(numRespTypes, length(cVals)); + for r = 1:numRespTypes + for c = 1:length(cVals) + incl = repeatNum==1&contrast==cVals(c); + numTrials(c) = sum(incl); + numChooseR(r,c) = sum(response==respTypes(r)&incl); - -% hold on; - - + psychoM(r, c) = numChooseR(r,c)/numTrials(c); + psychoMCI(r, c) = 1.96*sqrt(psychoM(r, c)*(1-psychoM(r, c))/numTrials(c)); + end + errorbar(ax, 100*cVals, 100*psychoM(r,:), 100*psychoMCI(r,:),... + '-o', 'Color', colors(r,:), 'LineWidth', 1.0); + end + + ylim(ax, [-1 101]); + xdata = cVals(~isnan(cVals))*100; + if numel(xdata) > 1 + xlim(ax, xdata([1 end])*1.1); + end end -ylim(ax, [-1 101]); -xdata = xdata(~isnan(xdata)); -if numel(xdata) > 1 - xlim(ax, xdata([1 end])*1.1); end From 3963d2298e7bcaae70e3fc3887495f7f1b350596 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 1 Mar 2018 17:23:20 +0000 Subject: [PATCH 092/507] Fix'd empty AlyxInstance bug Alyx instance now less likely to cause error when unset --- +eui/AlyxPanel.m | 2 +- +hw/Timeline.m | 2 +- cortexlab/+tl/bindMpepServer.m | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 5bfdeec6..368aae21 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -32,7 +32,7 @@ % 2017-03 NS created % 2017-10 MW made into class properties (SetAccess = private) - AlyxInstance = Alyx; % An Alyx object to interfacing with the database + AlyxInstance = Alyx('',''); % 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 diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 0613a3b5..4850b519 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -464,7 +464,7 @@ function stop(obj) % register Timeline.mat file to Alyx database [subject, ~, ~] = dat.parseExpRef(obj.Data.expRef); - if obj.AlyxInstance.IsLoggedIn && ~strcmp(subject,'default') + if ~isempty(obj.AlyxInstance) && obj.AlyxInstance.IsLoggedIn && ~strcmp(subject,'default') try obj.AlyxInstance.registerFile(obj.Data.savePaths{end}, 'mat',... obj.AlyxInstance.SessionURL, 'Timeline', []); diff --git a/cortexlab/+tl/bindMpepServer.m b/cortexlab/+tl/bindMpepServer.m index 9e15219e..9b4a5024 100644 --- a/cortexlab/+tl/bindMpepServer.m +++ b/cortexlab/+tl/bindMpepServer.m @@ -36,7 +36,7 @@ tls.close = @closeConns; tls.process = @process; tls.listen = @listen; -tls.AlyxInstance = []; +tls.AlyxInstance = Alyx('',''); %% Initialize timeline From 1d870f72ed178f464f7a20a0e5cbbe51b43f1ee3 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 15 Mar 2018 17:26:29 +0000 Subject: [PATCH 093/507] Behavioural data saved and registered as npy files If expDef contains 'choiceworld', key data are extracted from the block file and saved as npy files, before registering to Alyx. --- +exp/SignalsExp.m | 133 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 108 insertions(+), 25 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 6723095d..c22e1e10 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -838,32 +838,115 @@ function activateEventHandler(obj, handler, eventInfo, dueTime) end function saveData(obj) - % save the data to the appropriate locations indicated by expRef - savepaths = dat.expFilePath(obj.Data.expRef, 'block'); - superSave(savepaths, struct('block', obj.Data)); - - if ~obj.AlyxInstance.IsLoggedIn - warning('No Alyx token set'); - else - try - [subject, expDate, seq] = dat.parseExpRef(obj.Data.expRef); - if strcmp(subject, 'default'); return; end - % Register saved files - obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... - obj.AlyxInstance.SessionURL, 'Block', []); -% obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... -% {subject, expDate, seq}, 'Block', []); - % Save the session end time - if ~isempty(obj.AlyxInstance.SessionURL) - obj.AlyxInstance.putData(obj.AlyxInstance.SessionURL,... - struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject)); - else - % Infer from date session and retrieve using expFilePath - end - catch ex - warning(ex.identifier, 'Failed to register files to Alyx: %s', ex.message); - end + % save the data to the appropriate locations indicated by expRef + savepaths = dat.expFilePath(obj.Data.expRef, 'block'); + superSave(savepaths, struct('block', obj.Data)); + [subject, ~, ~] = dat.parseExpRef(obj.Data.expRef); + + % if this is a 'ChoiceWorld' experiment, let's save out for + % relevant data for basic behavioural analysis and register them to + % Alyx + if contains(lower(obj.Data.expDef), 'choiceworld') ... + && ~strcmp(subject, 'default') && isfield(obj.Data, 'events') ... + && ~strcmp(obj.Data.endStatus,'aborted') + try + expPath = dat.expPath(obj.Data.expRef, 'main', 'master'); + % Write feedback + + feedback = getOr(obj.Data.events, 'feedbackValues', NaN); + feedback = double(feedback); + feedback(feedback == 0) = -1; + if ~isnan(feedback) + writeNPY(feedback(:), fullfile(expPath, 'cwFeedback.type.npy')); + alf.writeEventseries(expPath, 'cwFeedback',... + obj.Data.events.feedbackTimes, [], []); + else + warning('No ''feedback'' events recorded, cannot register to Alyx') + end + + % Write go cue + interactiveOn = getOr(obj.Data.events, 'interactiveOnTimes', NaN); + if ~isnan(interactiveOn) + alf.writeEventseries(expPath, 'cwGoCue', interactiveOn, [], []); + else + warning('No ''interactiveOn'' events recorded, cannot register to Alyx') + end + + % Write response + response = getOr(obj.Data.events, 'responseValues', NaN); + if min(response) == -1 + response(response == 0) = 3; + response(response == 1) = 2; + response(response == -1) = 1; + end + if ~isnan(response) + writeNPY(response(:), fullfile(expPath, 'cwResponse.choice.npy')); + alf.writeEventseries(expPath, 'cwResponse',... + obj.Data.events.responseTimes, [], []); + else + warning('No ''feedback'' events recorded, cannot register to Alyx') + end + + % Write stim on times + stimOnTimes = getOr(obj.Data.events, 'stimulusOnTimes', NaN); + if ~isnan(stimOnTimes) + alf.writeEventseries(expPath, 'cwStimOn', stimOnTimes, [], []); + else + warning('No ''stimulusOn'' events recorded, cannot register to Alyx') + end + contL = getOr(obj.Data.events, 'contrastLeftValues', NaN); + contR = getOr(obj.Data.events, 'contrastRightValues', NaN); + if ~isnan(contL)&&~isnan(contR) + writeNPY(contL(:), fullfile(expPath, 'cwStimOn.contrastLeft.npy')); + writeNPY(contR(:), fullfile(expPath, 'cwStimOn.contrastRight.npy')); + else + warning('No ''contrastLeft'' and/or ''contrastRight'' events recorded, cannot register to Alyx') + end + + % Write trial intervals + alf.writeInterval(expPath, 'cwTrials',... + obj.Data.events.newTrialTimes(:), obj.Data.events.endTrialTimes(:), [], []); + repNum = obj.Data.events.repeatNumValues(:); + writeNPY(repNum == 1, fullfile(expPath, 'cwTrials.inclTrials.npy')); + writeNPY(repNum, fullfile(expPath, 'cwTrials.repNum.npy')); + + % Write wheel times, position and velocity + wheelValues = obj.Data.inputs.wheelValues(:); + wheelValues = wheelValues*(3.1*2*pi/(4*1024)); + wheelTimes = obj.Data.inputs.wheelTimes(:); + alf.writeTimeseries(expPath, 'Wheel', wheelTimes, [], []); + writeNPY(wheelValues, fullfile(expPath, 'Wheel.position.npy')); + writeNPY(wheelValues./wheelTimes, fullfile(expPath, 'Wheel.velocity.npy')); + + % Register them to Alyx + obj.AlyxInstance.registerALFtoAlyx(expPath); + catch ex + warning(ex.identifier, 'Failed to register alf files: %s.', ex.message); + end + end + + if isempty(obj.AlyxInstance) + warning('No Alyx token set'); + else + try + [subject, expDate, seq] = dat.parseExpRef(obj.Data.expRef); + if strcmp(subject, 'default'); return; end + % Register saved files + obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... + obj.AlyxInstance.SessionURL, 'Block', []); +% obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... +% {subject, expDate, seq}, 'Block', []); + % Save the session end time + if ~isempty(obj.AlyxInstance.SessionURL) + obj.AlyxInstance.putData(obj.AlyxInstance.SessionURL,... + struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject)); + else + % Infer from date session and retrieve using expFilePath + end + catch ex + warning(ex.identifier, 'Failed to register files to Alyx: %s', ex.message); end + end end end From 5520c4cb9b2e00d617321d54599e5fd9ad49562d Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 15 Mar 2018 17:43:03 +0000 Subject: [PATCH 094/507] Reverted to session url registration Registering files with expRef not yet implemented --- +exp/Experiment.m | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/+exp/Experiment.m b/+exp/Experiment.m index a4c6bf33..acdd7f9f 100644 --- a/+exp/Experiment.m +++ b/+exp/Experiment.m @@ -781,7 +781,9 @@ function saveData(obj) if strcmp(subject, 'default'); return; end % Register saved files obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... - {subject, expDate, seq}, 'Block', []); + obj.AlyxInstance.SessionURL, 'Block', []); +% obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... +% {subject, expDate, seq}, 'Block', []); % Save the session end time if ~isempty(obj.AlyxInstance.SessionURL) obj.AlyxInstance.putData(obj.AlyxInstance.SessionURL,... From 47042707d0f395fd46287fb2530093a6c4f1e645 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 16 Mar 2018 11:30:03 +0000 Subject: [PATCH 095/507] Added lick detector input to signals --- +exp/SignalsExp.m | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index c22e1e10..5d9b00d7 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -156,6 +156,7 @@ obj.Events.newTrial = net.origin('newTrial'); obj.Events.expStop = net.origin('expStop'); obj.Inputs.wheel = net.origin('wheel'); + obj.Inputs.lick = net.origin('lick'); obj.Inputs.keyboard = net.origin('keyboard'); % get global parameters & conditional parameters structs [~, globalStruct, allCondStruct] = toConditionServer(... @@ -674,6 +675,14 @@ function mainLoop(obj) % tic wx = readAbsolutePosition(obj.Wheel); post(obj.Inputs.wheel, wx); + if ~isempty(obj.LickDetector) + % read and log the current lick count + [nlicks, ~, licked] = readPosition(obj.LickDetector); + if licked + post(obj.Inputs.lick, nlicks); + fprintf('lick count now %i\n', nlicks); + end + end post(obj.Time, now(obj.Clock)); runSchedule(obj.Net); From 867d98ff5218cb876b6652d9752b4668b91daafb Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 16 Mar 2018 11:55:33 +0000 Subject: [PATCH 096/507] Parameters now charector type friendly Also default numTrials is 1000 total trials --- +exp/inferParameters.m | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/+exp/inferParameters.m b/+exp/inferParameters.m index bc879029..145f5978 100644 --- a/+exp/inferParameters.m +++ b/+exp/inferParameters.m @@ -42,7 +42,9 @@ end sz = iff(isempty(fieldnames(parsStruct)), 1,... % if there are no paramters sz = 1 structfun(@(a)size(a,2), parsStruct)); % otherwise get number of columns - parsStruct.numRepeats = ones(1,max(sz)); % add 'numRepeats' parameter + isChar = structfun(@ischar, parsStruct); % we disregard charecter arrays + % add 'numRepeats' parameter, where total number of trials = 1000 + parsStruct.numRepeats = ones(1,max(sz(~isChar)))*floor(1000/max(sz(~isChar))); parsStruct.defFunction = expdef; parsStruct.type = 'custom'; % Define the ExpPanel to use (automatically by name convention for now) From 6b4b46bda74122aa12ed9907674e89856f0982e2 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 16 Mar 2018 12:42:00 +0000 Subject: [PATCH 097/507] Fix'd a couple of indexing bugs --- +exp/SignalsExp.m | 4 ++-- +exp/inferParameters.m | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 5d9b00d7..12dc3732 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -905,7 +905,7 @@ function saveData(obj) end contL = getOr(obj.Data.events, 'contrastLeftValues', NaN); contR = getOr(obj.Data.events, 'contrastRightValues', NaN); - if ~isnan(contL)&&~isnan(contR) + if ~any(isnan(contL))&&~any(isnan(contR)) writeNPY(contL(:), fullfile(expPath, 'cwStimOn.contrastLeft.npy')); writeNPY(contR(:), fullfile(expPath, 'cwStimOn.contrastRight.npy')); else @@ -928,7 +928,7 @@ function saveData(obj) writeNPY(wheelValues./wheelTimes, fullfile(expPath, 'Wheel.velocity.npy')); % Register them to Alyx - obj.AlyxInstance.registerALFtoAlyx(expPath); + obj.AlyxInstance.registerALF(expPath); catch ex warning(ex.identifier, 'Failed to register alf files: %s.', ex.message); end diff --git a/+exp/inferParameters.m b/+exp/inferParameters.m index 145f5978..51c57a01 100644 --- a/+exp/inferParameters.m +++ b/+exp/inferParameters.m @@ -43,8 +43,9 @@ sz = iff(isempty(fieldnames(parsStruct)), 1,... % if there are no paramters sz = 1 structfun(@(a)size(a,2), parsStruct)); % otherwise get number of columns isChar = structfun(@ischar, parsStruct); % we disregard charecter arrays + if any(isChar); sz = sz(~isChar); end % add 'numRepeats' parameter, where total number of trials = 1000 - parsStruct.numRepeats = ones(1,max(sz(~isChar)))*floor(1000/max(sz(~isChar))); + parsStruct.numRepeats = ones(1,max(sz))*floor(1000/max(sz)); parsStruct.defFunction = expdef; parsStruct.type = 'custom'; % Define the ExpPanel to use (automatically by name convention for now) From 8a51bd3cdb892d8015a2edab7f5b7b8562fcfa32 Mon Sep 17 00:00:00 2001 From: nsteinme Date: Mon, 19 Mar 2018 17:02:00 +0000 Subject: [PATCH 098/507] update bindMpepServerWithWS to modern --- cortexlab/+tl/bindMpepServerWithWS.m | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cortexlab/+tl/bindMpepServerWithWS.m b/cortexlab/+tl/bindMpepServerWithWS.m index 0adbafca..32ef7ade 100644 --- a/cortexlab/+tl/bindMpepServerWithWS.m +++ b/cortexlab/+tl/bindMpepServerWithWS.m @@ -156,7 +156,7 @@ function listen() if isempty(tls.AlyxInstance) % first get an alyx instance - ai = alyx.loginWindow(); + ai = Alyx; else ai = tls.AlyxInstance; end @@ -166,8 +166,8 @@ function listen() if ~isempty(mouseName) clear expParams; expParams.experimentType = 'timelineManualStart'; - [newExpRef, newExpSeq, subsessionURL] = dat.newExp(mouseName, now, expParams, ai); - ai.subsessionURL = subsessionURL; + [newExpRef, ~, subsessionURL] = ai.newExp(mouseName, now, expParams); + ai.SessionURL = subsessionURL; tls.AlyxInstance = ai; %[subjectRef, expDate, expSequence] = dat.parseExpRef(newExpRef); From 2e92e454ab507b7803aeadf60ada83b24b78322b Mon Sep 17 00:00:00 2001 From: nsteinme Date: Mon, 19 Mar 2018 18:12:07 +0000 Subject: [PATCH 099/507] add offset option to sinepulsegen --- +hw/SinePulseGenerator.m | 2 ++ 1 file changed, 2 insertions(+) diff --git a/+hw/SinePulseGenerator.m b/+hw/SinePulseGenerator.m index 95576183..3dc9f174 100644 --- a/+hw/SinePulseGenerator.m +++ b/+hw/SinePulseGenerator.m @@ -38,6 +38,8 @@ % add a zero so it turns off at the end samples = [samples'; 0]; + + samples = samples+obj.Offset; end end From fbaa46231b696579c21d5efb4956a503fc2c0fba Mon Sep 17 00:00:00 2001 From: nsteinme Date: Mon, 19 Mar 2018 18:12:32 +0000 Subject: [PATCH 100/507] add set-able samplerate for daq controller --- +hw/DaqController.m | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/+hw/DaqController.m b/+hw/DaqController.m index 4a480fcf..a9835d40 100644 --- a/+hw/DaqController.m +++ b/+hw/DaqController.m @@ -45,6 +45,9 @@ SignalGenerators = hw.PulseSwitcher.empty DaqIds = 'Dev1' % device ID's for each channel, e.g. 'Dev1' DaqChannelIds = {} % DAQ's ID for each channel, e.g. 'ao0' + SampleRate = 1000 % output sample rate ("scans/sec") of the daq device + % 1000 is also the default of the ni daq devices themselves, so if + % you don't change this, it doesn't actually do anything. end properties (Transient) @@ -66,6 +69,7 @@ function createDaqChannels(obj) if isempty(obj.DaqSession) obj.DaqSession = daq.createSession('ni'); + obj.DaqSession.Rate = obj.SampleRate; end if isempty(obj.DigitalDaqSession)&&any(~obj.AnalogueChannelsIdx) obj.DigitalDaqSession = daq.createSession('ni'); From e598fc6084cf47089e6f5a7ec6cac1bc304ac658 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 20 Mar 2018 18:12:43 +0000 Subject: [PATCH 101/507] StimulusControl has default Alyx object --- +srv/StimulusControl.m | 2 +- cortexlab/+psy/plot2AUFC.m | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/+srv/StimulusControl.m b/+srv/StimulusControl.m index 34db88fe..9e6f8114 100644 --- a/+srv/StimulusControl.m +++ b/+srv/StimulusControl.m @@ -43,7 +43,7 @@ end properties (Transient, Hidden) - AlyxInstance = [] % Property to store rig specific Alyx token + AlyxInstance = Alyx('','') % Property to store rig specific Alyx token end properties (Constant) diff --git a/cortexlab/+psy/plot2AUFC.m b/cortexlab/+psy/plot2AUFC.m index 66037cb6..61744ebf 100644 --- a/cortexlab/+psy/plot2AUFC.m +++ b/cortexlab/+psy/plot2AUFC.m @@ -10,11 +10,15 @@ function plot2AUFC(ax, block) % contrast = diff(contrast); response = [block.trial.response]; repeatNum = [block.trial.repeatNum]; -if any(structfun(@isnan, block.trial(end))) % strip incomplete trials - contrast = contrast(:,1:end-1); - response = response(1:end-1); - repeatNum = repeatNum(1:end-1); -end +incl = ~any(isnan([contrast;response;repeatNum])); +contrast = contrast(incl); +response = response(incl); +repeatNum = repeatNum(incl); +% if any(structfun(@isnan, block.trial(end))) % strip incomplete trials +% contrast = contrast(:,1:end-1); +% response = response(1:end-1); +% repeatNum = repeatNum(1:end-1); +% end respTypes = unique(response); numRespTypes = numel(respTypes); From 283ef018a1543dee97ae584787f78d2016632517 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 22 Mar 2018 12:30:37 +0000 Subject: [PATCH 102/507] AlyxPanel now has active flag when inactive, Alyx instance is set to Headless mode and the login button is disabled. --- +eui/AlyxPanel.m | 24 ++++++++++++++++++------ +eui/MControl.m | 4 ---- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 368aae21..4945e0ca 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -57,12 +57,15 @@ end methods - function obj = AlyxPanel(parent) - % 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. + 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. If + % the active flag is set to false (default is true), the panel is + % inactive and the instance of Alyx will be set to headless. + % + % See also Alyx if ~nargin % No parant object: create new figure f = figure('Name', 'alyx GUI',... @@ -84,6 +87,9 @@ obj.NewExpSubject.addlistener('SelectionChanged', @(src, evt)obj.dispWaterReq(src, evt)); end + % Default to active AlyxPanel + if nargin < 2; active = true; end + obj.RootContainer = uix.Panel('Parent', parent, 'Title', 'Alyx'); alyxbox = uiextras.VBox('Parent', obj.RootContainer); @@ -98,6 +104,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 diff --git a/+eui/MControl.m b/+eui/MControl.m index f46c4e96..c7ab5901 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 From 2a210616e44b0eda830b8015a5c6bb44268dafe6 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 22 Mar 2018 14:06:13 +0000 Subject: [PATCH 103/507] Restored stop delay to former function * Upon calling stop method, timeline immediatley stops all outputs before pausing for th duration of the StopDelay, then stops the main aquisition session. * A warning is now issued if the user sets the stop delay to a value less than 2. --- +hw/Timeline.m | 100 +++++++++++++++++++++++++++---------------------- 1 file changed, 55 insertions(+), 45 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 4850b519..30c613c6 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -14,13 +14,13 @@ % assumed that the time between sending the chrono pulse and recieving it % is negligible. % -% There are other available clocking signals, for instance: 'acqLive' and 'clock'. -% The former outputs a high (+5V) signal the entire time tl is aquiring -% (0V otherwise), and can be used to trigger devices with a TTL input. -% The 'clock' output is a regular pulse at a frequency of +% There are other available clocking signals, for instance: 'acqLive' and +% 'clock'. The former outputs a high (+5V) signal the entire time tl is +% aquiring (0V otherwise), and can be used to trigger devices with a TTL +% input. The 'clock' output is a regular pulse at a frequency of % ClockOutputFrequency and duty cycle of ClockOutputDutyCycle. This can -% be used to trigger a camera at a specific frame rate. See "properties" below for -% further details on output configurations. +% be used to trigger a camera at a specific frame rate. See "properties" +% below for further details on output configurations. % % Besides the chrono signal, tl can aquire any number of inputs and % record their values on the same clock. For example a photodiode to @@ -43,9 +43,9 @@ % timeline.Outputs(1).DaqChannelID and timeline.Inputs(1).daqChannelID % timeline.wiringInfo('chrono'); % %Add the rotary encoder -% timeline.addInput('rotaryEncoder', 'ctr0', 'Position'); +% timeline.addInput('rotaryEncoder', 'ctr0', 'Position'); % %For a lick detector -% timeline.addInput('lickDetector', 'ctr2', 'EdgeCount'); +% timeline.addInput('lickDetector', 'ctr2', 'EdgeCount'); % %We want use camera frame acquisition trigger by default % timeline.UseOutputs{end+1} = 'clock'; % %Save your hardware.mat file @@ -64,7 +64,7 @@ % 2014-01 CB created % 2017-10 MW updated - + properties DaqVendor = 'ni' % 'ni' is using National Instruments USB-6211 DAQ DaqIds = 'Dev1' % Device ID can be found with daq.getDevices() @@ -78,26 +78,26 @@ 'terminalConfig', 'SingleEnded',... 'axesScale', 1) % multiplicative vertical scaling for when live plotting the input UseInputs = {'chrono'} % array of inputs to record while tl is running - StopDelay = 2 % currently pauses for at least 2 secs as 'hack' before stopping main DAQ session - MaxExpectedDuration = 2*60*60 % expected experiment time so data structure is initialised to sensible size (in secs) + StopDelay = 2 % currently pauses for at least 2 secs as 'hack' before stopping main DAQ session to allow + MaxExpectedDuration = 2*60*60 % expected experiment time so data structure is initialised to sensible size (in secs) AquiredDataType = 'double' % default data type for the acquired data array (i.e. Data.rawDAQData) UseTimeline = false % used by expServer. If true, timeline is started by default (otherwise can be toggled with the t key) LivePlot = false % if true the data are plotted as the data are aquired FigureScale = []; % figure position in normalized units, default is [0 0 1 1] (full screen) WriteBufferToDisk = false % if true the data buffer is written to disk as they're aquired NB: in the future this will happen by default end - + properties (Dependent) SamplingInterval % defined as 1/DaqSampleRate IsRunning = false % flag is set to true when the first chrono pulse is aquired and set to false when tl is stopped (and everything saved), see tl.process and tl.stop end - + properties (Transient, Access = protected) Listener % holds the listener for 'DataAvailable', see DataAvailable and Timeline.process() - Sessions = containers.Map % map of daq sessions and their channels, created at tl.start() + Sessions = containers.Map % map of daq sessions and their channels, created at tl.start() LastTimestamp % the last timestamp returned from the daq during the DataAvailable event. Used to check sampling continuity, see tl.process() Ref % the expRef string. See tl.start() - AlyxInstance % a struct contraining the Alyx token, user and url for ile registration. See tl.start() + AlyxInstance % a struct contraining the Alyx token, user and url for ile registration. See tl.start() Data % A structure containing timeline data Axes % A figure handle for plotting the aquired data as it's processed DataFID % The data file ID for writing aquired data directly to disk @@ -111,7 +111,7 @@ % Adds chrono, aquireLive and clock to the outputs list, % along with default ports and delays - obj.DaqSamplesPerNotify = 1/obj.SamplingInterval; % calculate DaqSamplesPerNotify + obj.DaqSamplesPerNotify = 1/obj.SamplingInterval; % calculate DaqSamplesPerNotify if nargin % if old tl hardware struct provided, use these to populate properties % Configure the inputs obj.Inputs = hw.inputs; @@ -139,7 +139,7 @@ function start(obj, expRef, ai) end obj.Ref = expRef; % set the current experiment ref obj.AlyxInstance = ai; % set the current instance of Alyx - init(obj); % start the relevent sessions and add channels + init(obj); % start the relevent sessions and add channels obj.Listener = obj.Sessions('main').addlistener('DataAvailable', @obj.process); % add listener @@ -162,7 +162,7 @@ function start(obj, expRef, ai) fprintf(parfid, 'Fs = %d\n', obj.DaqSampleRate); % record the DAQ sample date fclose(parfid); % close the file end - + obj.Data.rawDAQData = zeros(numSamples, numInputChannels, obj.AquiredDataType); obj.Data.rawDAQSampleCount = 0; obj.Data.startDateTime = now; @@ -233,7 +233,7 @@ function record(obj, name, event, t) % Timeline data acquistion. 'strict' is optional (defaults to true), and % if true, this function will fail if Timeline is not running. If false, % it will just return the time using Psychtoolbox GetSecs if it's not - % running. + % running. % See also TL.PTBSECSTOTIMELINE(). if nargin < 2; strict = true; end if obj.IsRunning @@ -259,7 +259,7 @@ function record(obj, name, event, t) end function addInput(obj, name, channelID, measurement,... - terminalConfig, axesScale, use) + terminalConfig, axesScale, use) % Add a new input to the object's Input property % ADDINPUT(name, channelID, measurement, terminalConfig, use) % adds a new input 'name' to the Inputs list. If use is @@ -268,7 +268,7 @@ function addInput(obj, name, channelID, measurement,... % if no terminal config specified, leave empty which means use the % DAQ default for that port if nargin < 5; terminalConfig = []; end - + % if use is not specified, assume user wants normal scaling if nargin < 6; axesScale = 1; end @@ -322,7 +322,7 @@ function wiringInfo(obj, name) outputClasses = arrayfun(@class, obj.Outputs, 'uni', false); if strcmp(name, 'chrono') % Chrono wiring info idI = cellfun(@(s2)strcmp('chrono',s2), {obj.Inputs.name}); - idO = find(cellfun(@(s2)strcmp('tlOutputChrono',s2), outputClasses),1); + idO = find(cellfun(@(s2)strcmp('tlOutputChrono',s2), outputClasses),1); fprintf('Bridge terminals %s and %s\n',... obj.Outputs(idO).daqChannelID, obj.Inputs(idI).daqChannelID) elseif any(strcmp(name, {obj.Outputs.name})) % Output wiring info @@ -351,33 +351,43 @@ function wiringInfo(obj, name) if isfield(obj.Data, 'rawDAQSampleCount')&&... obj.Data.rawDAQSampleCount > 0 % obj.Data.rawDAQSampleCount is greater than 0 during the first call to tl.process - bool = true; + bool = true; else % obj.Data is cleared in tl.stop, after all data are saved - bool = false; + bool = false; end end + function set.StopDelay(obj, delay) + if delay < 2 + warning('Timeline:StopDelay:DelayTooShort',... + 'A stop delay less than 2s may cause some output samples to be missed upon stopping'); + end + obj.StopDelay = delay; + end + function stop(obj) %TL.STOP Stops Timeline data acquisition % TL.STOP() Deletes the listener, saves the aquired data, - % stops all running DAQ sessions - % + % stops all running DAQ sessions + % % See Also HW.TLOUTPUT/STOP if ~obj.IsRunning warning('Nothing to do, Timeline is not running!') return end - pause(obj.StopDelay) - + % stop acquisition output signals arrayfun(@stop, obj.Outputs) + % wait for the final samples to be aquired + pause(obj.StopDelay) + % stop actual DAQ aquisition stop(obj.Sessions('main')); - % wait before deleting the listener to ensure most recent samples are - % collected + % wait before deleting the listener to ensure most recent + % samples are processed pause(1.5); - delete(obj.Listener) % now delete the data listener + delete(obj.Listener) % now delete the data listener % only keep the used part of the daq input array obj.Data.rawDAQData((obj.Data.rawDAQSampleCount + 1):end,:) = []; @@ -403,7 +413,7 @@ function stop(obj) nextChrono = obj.Outputs(chronoOutputIdx).NextChronoSign; LastClockSentSysTime = obj.Outputs(chronoOutputIdx).LastClockSentSysTime; CurrSysTimeTimelineOffset = obj.Outputs(chronoOutputIdx).CurrSysTimeTimelineOffset; - end + end acqLiveOutputIdx = find(strcmp(outputClasses, 'hw.TLOutputAcqLive'),1); if ~isempty(acqLiveOutputIdx) acqLiveChan = obj.Outputs(acqLiveOutputIdx).DaqChannelID; @@ -411,7 +421,7 @@ function stop(obj) clockOutputIdx = find(strcmp(outputClasses, 'hw.TLOutputClock'),1); if ~isempty(clockOutputIdx) useClock = true; - clockF = obj.Outputs(clockOutputIdx).Frequency; + clockF = obj.Outputs(clockOutputIdx).Frequency; clockD = obj.Outputs(clockOutputIdx).DutyCycle; end @@ -430,7 +440,7 @@ function stop(obj) obj.Data.lastClockSentSysTime = LastClockSentSysTime; obj.Data.currSysTimeTimelineOffset = CurrSysTimeTimelineOffset; - % saving hardware metadata for each output + % saving hardware metadata for each output warning('off', 'MATLAB:structOnObject'); % sorry, don't care for outIdx = 1:numel(obj.Outputs) s = struct(obj.Outputs(outIdx)); @@ -441,11 +451,11 @@ function stop(obj) % save tl to all paths superSave(obj.Data.savePaths, struct('Timeline', obj.Data)); - + % write hardware info to a JSON file for compatibility with database if exist('savejson', 'file') % save local copy - savejson('hw', obj.Data.hw, fullfile(fileparts(obj.Data.savePaths{1}), 'TimelineHW.json')); + savejson('hw', obj.Data.hw, fullfile(fileparts(obj.Data.savePaths{1}), 'TimelineHW.json')); % save server copy savejson('hw', obj.Data.hw, fullfile(fileparts(obj.Data.savePaths{2}), 'TimelineHW.json')); else @@ -461,7 +471,7 @@ function stop(obj) else warning('did not write files into alf format. Check that alyx-matlab and npy-matlab repositories are in path'); end - + % register Timeline.mat file to Alyx database [subject, ~, ~] = dat.parseExpRef(obj.Data.expRef); if ~isempty(obj.AlyxInstance) && obj.AlyxInstance.IsLoggedIn && ~strcmp(subject,'default') @@ -507,10 +517,10 @@ function init(obj) % TL.INIT() creates all the DAQ sessions % and stores them in the Sessions map by their Outputs name. % Also add a 'main' session to which all input channels are - % added. + % added. % - % See Also DAQ.CREATESESSION - + % See Also DAQ.CREATESESSION + %%reate channels for each input [use, idx] = intersect({obj.Inputs.name}, obj.UseInputs);% find which inputs to use assert(numel(idx) == numel(obj.UseInputs), 'Not all inputs were recognised'); @@ -553,8 +563,8 @@ function process(obj, ~, event) % flipped on each call (at LastClockSentSysTime), and the % time of the previous flip is found in the data and its % timestamp noted. This is used by tl.time() to convert - % between system time and acquisition time. - % + % between system time and acquisition time. + % % LastTimestamp is the time of the last scan in the previous % data chunk, and is used to ensure no data samples have been % lost. @@ -565,7 +575,7 @@ function process(obj, ~, event) assert(abs(event.TimeStamps(1) - obj.LastTimestamp - obj.SamplingInterval) < 1e-8,... 'Discontinuity of DAQ acquistion detected: last timestamp was %f and this one is %f',... obj.LastTimestamp, event.TimeStamps(1)); - + %Process methods for outputs arrayfun(@(out)out.process(obj, event), obj.Outputs); @@ -621,7 +631,7 @@ function livePlot(obj, data) % figure and scale by scrolling the one that's hovered over scales = pick([obj.Inputs.axesScale], cellfun(@(x)find(strcmp({obj.Inputs.name}, x),1), obj.UseInputs)); - traces = get(obj.Axes, 'Children'); + traces = get(obj.Axes, 'Children'); if isempty(traces) Fs = obj.DaqSampleRate; plot((1:Fs*10)/Fs, zeros(Fs*10, length(names))+repmat(offsets, Fs*10, 1)); @@ -650,7 +660,7 @@ function livePlot(obj, data) % necessary to prevent the value from wandering way off % the range and making it impossible to see any of the % other traces. Plus it is probably more useful, - % anyway. + % anyway. yy(end-nSamps+1:end) = conv(diff([data(1,t); data(:,t)]),... gausswin(50)./sum(gausswin(50)), 'same') * scales(t) + offsets(t); else From ee3638f875db7b44fd4000641415d7fb43a0beee Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 26 Mar 2018 18:23:40 +0100 Subject: [PATCH 104/507] Fix'd bugs in plot2AFUC Plotting works correctly now; various bug fixes --- cortexlab/+psy/plot2AUFC.m | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/cortexlab/+psy/plot2AUFC.m b/cortexlab/+psy/plot2AUFC.m index 61744ebf..be2e4724 100644 --- a/cortexlab/+psy/plot2AUFC.m +++ b/cortexlab/+psy/plot2AUFC.m @@ -11,7 +11,7 @@ function plot2AUFC(ax, block) response = [block.trial.response]; repeatNum = [block.trial.repeatNum]; incl = ~any(isnan([contrast;response;repeatNum])); -contrast = contrast(incl); +contrast = contrast(:,incl); response = response(incl); repeatNum = repeatNum(incl); % if any(structfun(@isnan, block.trial(end))) % strip incomplete trials @@ -22,18 +22,11 @@ function plot2AUFC(ax, block) respTypes = unique(response); numRespTypes = numel(respTypes); -if size(contrast, 1) > 1 - allContrast = contrast; - contrast = diff(contrast, [], 1); -else - allContrast = [0;0]; -end - -if any(allContrast(1,:)>0 & allContrast(2,:)>0) +if any(contrast(1,:)>0 & contrast(2,:)>0) % mode for plotting task with two stimuli at once - cValsLeft = unique(allContrast(1,:)); - cValsRight = unique(allContrast(2,:)); + cValsLeft = unique(contrast(1,:)); + cValsRight = unique(contrast(2,:)); nCL = numel(cValsLeft); nCR = numel(cValsRight); % pedVals = cVals(1:end-1); @@ -47,7 +40,7 @@ function plot2AUFC(ax, block) for r = 1:numRespTypes for c1 = 1:nCL for c2 = 1:nCR - incl = repeatNum==1&allContrast(1,:)==cValsLeft(c1)&allContrast(2,:) == cValsRight(c2); + incl = repeatNum==1&contrast(1,:)==cValsLeft(c1)&contrast(2,:) == cValsRight(c2); numTrials(1,c1,c2) = sum(incl); numChooseR(r,c1,c2) = sum(response==respTypes(r)&incl); @@ -74,7 +67,7 @@ function plot2AUFC(ax, block) caxis(ax, [-1 1]); axis(ax, 'image') else - + contrast = diff(contrast, [], 1); cVals = unique(contrast); colors = iff(numRespTypes>2,[0 1 1; 0 1 0; 1 0 0], [0 1 1; 1 0 0]); psychoM = zeros(numRespTypes,length(cVals)); From 8884002ce3296f1de2acdc50b6c48c6c1a00f474 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 26 Mar 2018 18:59:53 +0100 Subject: [PATCH 105/507] Added colourmap used by psy.plot2AUFC --- cortexlab/+psy/colormap_pinkgreyscale.m | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 cortexlab/+psy/colormap_pinkgreyscale.m diff --git a/cortexlab/+psy/colormap_pinkgreyscale.m b/cortexlab/+psy/colormap_pinkgreyscale.m new file mode 100644 index 00000000..0ae7753c --- /dev/null +++ b/cortexlab/+psy/colormap_pinkgreyscale.m @@ -0,0 +1,9 @@ +function map = colormap_pinkgreyscale() +% pink for one extreme, greyscale for the other half. + +map = zeros(100,3); +map(50:-1:1,:) = repmat([(1:50)/50]',1,3); +map(100,:) = [1 0 1]; + +map = map(end:-1:1,:); + From 433635d69d7034dba826a9482854b0091b6cc113 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 9 Apr 2018 19:45:11 +0100 Subject: [PATCH 106/507] Added rewardSize ALF file save NPY file called cwFeedback.rewardVolume.npy now saved at the end of the experiment along with the other ALF files. --- +exp/SignalsExp.m | 1 + 1 file changed, 1 insertion(+) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 12dc3732..6073d9a0 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -869,6 +869,7 @@ function saveData(obj) writeNPY(feedback(:), fullfile(expPath, 'cwFeedback.type.npy')); alf.writeEventseries(expPath, 'cwFeedback',... obj.Data.events.feedbackTimes, [], []); + writeNPY([obj.Data.outputs.rewardValues]', fullfile(expPath, 'cwFeedback.rewardVolume.npy')); else warning('No ''feedback'' events recorded, cannot register to Alyx') end From e3390c6e40f085ef37b7bc4a34eff431cb3de13f Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 10 Apr 2018 11:36:26 +0100 Subject: [PATCH 107/507] Removed old/legacy code from hw.devices Also added FIXME comment to eui.AlyxPanel: low weight thresholds inaccurate, requires change to endpoint --- +eui/AlyxPanel.m | 2 ++ +hw/devices.m | 26 -------------------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 4945e0ca..a9689776 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -521,6 +521,8 @@ function viewSubjectHistory(obj, ax) plot(ax, dates, [records.weight_measured], '.-'); hold(ax, 'on'); + % FIXME: This weights are inaccurate - should be + % ([records.weight_expected]-implantWeight)*0.7 + implantWeight plot(ax, dates, [records.weight_expected]*0.7, 'r', 'LineWidth', 2.0); plot(ax, dates, [records.weight_expected]*0.8, 'LineWidth', 2.0, 'Color', [244, 191, 66]/255); box(ax, 'off'); diff --git a/+hw/devices.m b/+hw/devices.m index 82598ab6..4a1a4616 100644 --- a/+hw/devices.m +++ b/+hw/devices.m @@ -19,13 +19,6 @@ paths = dat.paths(name); -% if strcmp(name, 'zen') -% baseDir = 'D:\Users\Chris\Documents\MATLAB\Experiments'; -% configDir = fullfile(fullfile(baseDir, 'config'), name); -% else -% baseDir = '\\zserver\code\Rigging'; -% configDir = fullfile(fullfile(baseDir, 'config'), name); -% end %% Basic initialisation fn = fullfile(paths.rigConfig, 'hardware.mat'); if ~file.exists(fn) @@ -43,26 +36,7 @@ %% Configure common devices, if present configure('mouseInput'); -% configure('rewardController'); configure('lickDetector'); -if isfield(rig, 'laser') - configure('laser', rig.rewardController.DaqSession); -end - -%% Deal with reward controller calibrations -% if init && isfield(rig, 'rewardController') -% if isfield(rig, 'rewardCalibrations') -% % use most recent reward calibration -% [newestDate, idx] = max([rig.rewardCalibrations.dateTime]); -% rig.rewardController.MeasuredDeliveries =... -% rig.rewardCalibrations(idx).measuredDeliveries; -% fprintf('\nApplying reward calibration performed on %s\n', datestr(newestDate)); -% else -% %create an empty structure -% rig.rewardCalibrations = struct('dateTime', {}, 'measuredDeliveries', {}); -% warning('Rigbox:hw:calibration', 'No reward calibrations found'); -% end -% end %% Set up controllers if init From ffce90c29ff25993e608d046ad690b4fabfc856d Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 16 Apr 2018 17:56:05 +0100 Subject: [PATCH 108/507] Fixes for new file registration end point * Added dependant submodules to the repo * Add rigbox paths now does much of the setting up for the user * AlyxPanel percent weights now take into account the implant weight * Contrast ALF files now saved in % units * Timeline and tlMpepListner now allows one to toggle the live plot --- +dat/expExists.m | 5 +- +dat/listSubjects.m | 4 +- +eui/AlyxPanel.m | 32 ++++--- +exp/SignalsExp.m | 22 +++-- +hw/Timeline.m | 21 ++--- .gitmodules | 9 ++ addRigboxPaths.m | 158 +++++++++++++++++++++++++-------- alyx-matlab | 1 + cortexlab/+tl/bindMpepServer.m | 9 +- npy-matlab | 1 + readme.md | 5 +- signals | 1 + 12 files changed, 190 insertions(+), 78 deletions(-) create mode 100644 .gitmodules create mode 160000 alyx-matlab create mode 160000 npy-matlab create mode 160000 signals diff --git a/+dat/expExists.m b/+dat/expExists.m index ffd6d8f4..88ea312e 100644 --- a/+dat/expExists.m +++ b/+dat/expExists.m @@ -1,6 +1,9 @@ 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. +% +% See Also DAT.LISTEXPS, DAT.PATHS % % Part of Rigbox diff --git a/+dat/listSubjects.m b/+dat/listSubjects.m index dbac4793..cca333d7 100644 --- a/+dat/listSubjects.m +++ b/+dat/listSubjects.m @@ -9,7 +9,7 @@ % Part of Rigbox % 2013-03 CB created -% 2018-01 NS added alyx compatibility +% 2018-01 NS added Alyx compatibility if nargin>0 && ~isempty(varargin{1}) % user provided an alyx instance ai = varargin{1}; % an alyx instance @@ -38,6 +38,6 @@ expInfoPath = dat.reposPath('expInfo', 'master'); dirs = file.list(expInfoPath, 'dirs'); - subjects = setdiff(dirs, {'misc'}); %exclude the misc directory + subjects = dirs(~cellfun(@(d)startsWith(d, '@'), dirs)); % exclude misc directories end end \ No newline at end of file diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index a9689776..9aa5ee11 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -343,7 +343,7 @@ function dispWaterReq(obj, src, ~) gel = getOr(record, 'hydrogel_given', 0); % Get total gel given weight_expected = getOr(record, 'weight_expected', NaN); % Set colour based on weight percentage - weight_pct = weight/weight_expected; + weight_pct = (weight-wr.implant_weight)/(weight_expected-wr.implant_weight); if weight_pct < 0.8 % Mouse below 80% original weight colour = [0.91, 0.41, 0.17]; % Orange weight_pct = '< 80%'; @@ -408,7 +408,7 @@ function recordWeight(obj, weight, subject) % convert to double if weight is a string weight = iff(ischar(weight{1}), str2double(weight{1}), weight{1}); try - w = postWeight(ai, weight, subject); %FIXME: If multiple things flushed, length(w)>1 + w = postWeight(ai, weight, subject); obj.log('Alyx weight posting succeeded: %.2f for %s', w.weight, w.subject); catch if ~ai.IsLoggedIn % if not logged in, save the weight for later @@ -501,7 +501,10 @@ function viewSubjectHistory(obj, ax) % 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 = obj.AlyxInstance.getData(endpnt); + iw = wr.implant_weight; records = catStructs(wr.records, nan); + expected = [records.weight_expected]; + expected(expected==0) = nan; % no weighings found if isempty(wr.records) obj.log('No weight data found for subject %s', obj.Subject); @@ -521,10 +524,8 @@ function viewSubjectHistory(obj, ax) plot(ax, dates, [records.weight_measured], '.-'); hold(ax, 'on'); - % FIXME: This weights are inaccurate - should be - % ([records.weight_expected]-implantWeight)*0.7 + implantWeight - plot(ax, dates, [records.weight_expected]*0.7, 'r', 'LineWidth', 2.0); - plot(ax, dates, [records.weight_expected]*0.8, 'LineWidth', 2.0, 'Color', [244, 191, 66]/255); + 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'); if numel(dates) > 1; xlim(ax, [min(dates) max(dates)]); end if nargin == 1 @@ -536,7 +537,7 @@ function viewSubjectHistory(obj, ax) if nargin==1 ax = axes('Parent', plotBox); - plot(ax, dates, [records.weight_measured]./[records.weight_expected], '.-'); + plot(ax, dates, ([records.weight_measured]-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); @@ -564,14 +565,14 @@ function viewSubjectHistory(obj, ax) weightsByDate = num2cell([records.weight_measured]); weightsByDate = cellfun(@(x)sprintf('%.1f', x), weightsByDate, 'uni', false); weightsByDate(isnan([records.weight_measured])) = {[]}; - weightPctByDate = num2cell([records.weight_measured]./[records.weight_expected]); + weightPctByDate = num2cell(([records.weight_measured]-iw)./(expected-iw)); weightPctByDate = cellfun(@(x)sprintf('%.1f', x*100), weightPctByDate, 'uni', false); weightPctByDate(isnan([records.weight_measured])) = {[]}; dat = horzcat(... arrayfun(@(x)datestr(x), dates', 'uni', false), ... weightsByDate', ... - arrayfun(@(x)sprintf('%.1f', 0.8*x), [records.weight_expected]', 'uni', false), ... + arrayfun(@(x)sprintf('%.1f', 0.8*(x-iw)), [records.weight_expected]', 'uni', false), ... weightPctByDate'); waterDat = (... num2cell(horzcat([records.water_given]', [records.hydrogel_given]', ... @@ -591,11 +592,7 @@ function viewAllSubjects(obj) ai = obj.AlyxInstance; if ai.IsLoggedIn wr = ai.getData(ai.makeEndpoint('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); - + % build a figure to show it f = figure; % popup a new figure for this wrBox = uix.VBox('Parent', f); @@ -607,11 +604,12 @@ 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',x)), {wr.water_requirement_remaining}, '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',x),{wr.water_requirement_total}', 'uni', false), ... wrdat'), ... 'ColumnEditable', false(1,3)); end diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 6073d9a0..d6f86b99 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -907,8 +907,8 @@ function saveData(obj) contL = getOr(obj.Data.events, 'contrastLeftValues', NaN); contR = getOr(obj.Data.events, 'contrastRightValues', NaN); if ~any(isnan(contL))&&~any(isnan(contR)) - writeNPY(contL(:), fullfile(expPath, 'cwStimOn.contrastLeft.npy')); - writeNPY(contR(:), fullfile(expPath, 'cwStimOn.contrastRight.npy')); + writeNPY(contL(:)*100, fullfile(expPath, 'cwStimOn.contrastLeft.npy')); + writeNPY(contR(:)*100, fullfile(expPath, 'cwStimOn.contrastRight.npy')); else warning('No ''contrastLeft'' and/or ''contrastRight'' events recorded, cannot register to Alyx') end @@ -929,7 +929,10 @@ function saveData(obj) writeNPY(wheelValues./wheelTimes, fullfile(expPath, 'Wheel.velocity.npy')); % Register them to Alyx - obj.AlyxInstance.registerALF(expPath); + files = dir(expPath); + isNPY = cellfun(@(f)endsWith(f, '.npy'), {files.name}); + files = files(isNPY); + obj.AlyxInstance.registerFile(fullfile({files.folder}, {files.name})); catch ex warning(ex.identifier, 'Failed to register alf files: %s.', ex.message); end @@ -939,19 +942,20 @@ function saveData(obj) warning('No Alyx token set'); else try - [subject, expDate, seq] = dat.parseExpRef(obj.Data.expRef); + [subject, seq] = dat.parseExpRef(obj.Data.expRef); if strcmp(subject, 'default'); return; end % Register saved files - obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... - obj.AlyxInstance.SessionURL, 'Block', []); + obj.AlyxInstance.registerFile(savepaths{end}); % obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... % {subject, expDate, seq}, 'Block', []); % Save the session end time if ~isempty(obj.AlyxInstance.SessionURL) - obj.AlyxInstance.putData(obj.AlyxInstance.SessionURL,... - struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject)); + obj.AlyxInstance.postData(obj.AlyxInstance.SessionURL,... + struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject), 'put'); else - % Infer from date session and retrieve using expFilePath + % Retrieve session from endpoint +% subsessions = obj.AlyxInstance.getData(... +% sprintf('sessions?type=Experiment&subject=%s&number=%i', subject, seq)); end catch ex warning(ex.identifier, 'Failed to register files to Alyx: %s', ex.message); diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 30c613c6..f0ae52b1 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -453,18 +453,12 @@ function stop(obj) superSave(obj.Data.savePaths, struct('Timeline', obj.Data)); % write hardware info to a JSON file for compatibility with database - if exist('savejson', 'file') - % save local copy - savejson('hw', obj.Data.hw, fullfile(fileparts(obj.Data.savePaths{1}), 'TimelineHW.json')); - % save server copy - savejson('hw', obj.Data.hw, fullfile(fileparts(obj.Data.savePaths{2}), 'TimelineHW.json')); - else - warning('JSONlab not found - hardware information not saved to ALF') - end + hw = jsonencode(obj.Data.hw); %#ok + save(fullfile(fileparts(obj.Data.savePaths{2}), 'TimelineHW.json'), 'hw', '-ascii'); % save each recorded vector into the correct format in Timeline % timebase for Alyx and optionally into universal timebase if - % conversion is provided + % conversion is provided. TODO: Make timelineToALF a class method if ~isempty(which('alf.timelineToALF'))&&~isempty(which('writeNPY')) alf.timelineToALF(obj.Data, [],... fileparts(dat.expFilePath(obj.Data.expRef, 'timeline', 'master'))) @@ -602,7 +596,14 @@ function process(obj, ~, event) end %If plotting the channels live, plot the new data - if obj.LivePlot; obj.livePlot(event.Data); end + if obj.LivePlot + obj.livePlot(event.Data) + else % If LivePlot has been toggled to false, delete the figure + if ~isempty(obj.Axes) + close(obj.Axes.Parent) + obj.Axes = []; + end + end end function livePlot(obj, data) diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..4b73c981 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "alyx-matlab"] + path = alyx-matlab + url = https://github.com/cortex-lab/alyx-matlab/ +[submodule "signals"] + path = signals + url = https://github.com/dendritic/signals +[submodule "npy-matlab"] + path = npy-matlab + url = https://github.com/kwikteam/npy-matlab diff --git a/addRigboxPaths.m b/addRigboxPaths.m index 2fa54a8b..9c41c4a4 100644 --- a/addRigboxPaths.m +++ b/addRigboxPaths.m @@ -3,63 +3,121 @@ function addRigboxPaths(savePaths) % % Part of the Rigging toolbox % TODO: -% - Paths to 'cortexlab' and 'cb-tools' were incorrect % - Consider renaming above folder to something more informative % % 2014-01 CB % 2017-02 MW Updated to work with 2016b -if nargin < 1 - savePaths = true; -end +% Flag for perminantly saving paths +if nargin < 1; savePaths = true; end + +%%% MATLAB version and toolbox validation %%% +% MATLAB must be running on Windows +assert(ispc, 'Rigbox currently only works on Windows 7 or later') + +% Microsoft Visual C++ Redistributable for Visual Studio 2015 must be +% installed, check for runtime dll file in system32 folder +sys32 = dir('C:\Windows\System32'); +assert(any(strcmpi('VCRuntime140.dll',{sys32.name})), 'Rigbox:setup:libraryRequired',... + ['Requires Microsoft Visual C++ Redistributable for Visual Studio 2015. ',... + 'Click
here to install.'],... + 'https://www.microsoft.com/en-us/download/details.aspx?id=48145') -rigboxPath = fileparts(mfilename('fullpath')); -cbToolsPath = fullfile(rigboxPath, 'cb-tools'); % Assumes 'cb-tools' in same -% directory as Rigbox, was not the case +% Check MATLAB 2016b is running +assert(~verLessThan('matlab', '8.4'), 'Requires MATLAB 2016b or later') -% 2017-02-17 GUI Layout Toolbox should be installed as matlab toolbox +% Check essential toolboxes are installed (common to both master and +% stimulus computers) toolboxes = ver; +requiredMissing = setdiff(... + {'Data Acquisition Toolbox', ... + 'Signal Processing Toolbox', ... + 'Instrument Control Toolbox'}, ... + 'Statistics and Machine Learning Toolbox',... + {toolboxes.Name}); + +assert(isempty(requiredMissing),'Rigbox:setup:toolboxRequired',... + 'Please install the following toolboxes before proceeding: \n%s',... + strjoin(requiredMissing, '\n')) + +% Check that GUI Layout Toolbox is installed (required for the master +% computer only) isInstalled = strcmp('GUI Layout Toolbox', {toolboxes.Name}); -if any(isInstalled) - fprintf('GUI Layout Toolbox version %s is currently installed\n', toolboxes(isInstalled).Version) -else - warning('MC requires GUI Layout Toolbox v2.3 or higher to be installed') +if ~any(isInstalled) ||... + str2double(strrep(toolboxes(isInstalled).Version,'.', '')) < 230 + warning('Rigbox:setup:toolboxRequired',... + ['MC requires GUI Layout Toolbox v2.3 or higher to be installed. '... + 'Click here to install.'],... + 'https://uk.mathworks.com/matlabcentral/fileexchange/47982-gui-layout-toolbox') end -% Check MATLAB 2016b is running -assert(~verLessThan('matlab', '8.4'), 'Requires MATLAB 2014b or later'); - -cortexLabAddonsPath = fullfile(rigboxPath, 'rigbox-cortexlab'); % doesn't exist 2017-02-13 -if ~isdir(cortexLabAddonsPath) % handle two possible alternative paths - cortexLabAddonsPath = fullfile(rigboxPath, 'cortexlab'); % doesn't exist in Rigbox directory 2017-02-13 +% Check that the Psychophisics Toolbox is installed (required for the +% stimulus computer only) +isInstalled = strcmp('Psychtoolbox', {toolboxes.Name}); +if ~any(isInstalled) || str2double(toolboxes(isInstalled).Version(1)) < 3 + warning('Rigbox:setup:toolboxRequired',... + ['The stimulus computer requires Psychtoolbox v3.0 or higher to be installed. '... + 'Click here to install.'],... + 'https://github.com/Psychtoolbox-3/Psychtoolbox-3/releases') end -addpath(... - cortexLabAddonsPath,... % add the Rigging cortexlab add-ons - rigboxPath,... % add Rigbox itself - cbToolsPath,... % add cb-tools root dir - fullfile(cbToolsPath, 'burgbox')); % Burgbox -% guiLayoutPath,... % add GUI Layout toolbox -% fullfile(guiLayoutPath, 'layout'),... -% fullfile(guiLayoutPath, 'Patch'),... -% fullfile(guiLayoutPath, 'layoutHelp')... -% ); - -if savePaths - assert(savepath == 0, 'Failed to save changes to MATLAB path'); +% Check that the NI DAQ support package is installed (required for the +% stimulus computer only) +info = matlabshared.supportpkg.getInstalled; +if isempty(info) || ~any(contains({info.Name}, 'NI-DAQmx')) + warning('Rigbox:setup:toolboxRequired',... + ['The stimulus computer requires the National Instruments support package to be installed. '... + 'Click here to install.'],... + 'https://www.mathworks.com/hardware-support/nidaqmx.html') end -cbtoolsjavapath = fullfile(cbToolsPath, 'java'); +%%% Paths for adding +% Add the main Rigbox directory, containing the main packages for running +% the experiment server and mc programmes +root = fileparts(mfilename('fullpath')); +addpath(root); + +% The cb-tools directory contains numerious convenience functions which are +% utilized by the main code. Those within the 'burgbox' directory were +% written by Chris Burgess. +addpath(fullfile(root, 'cb-tools'), fullfile(root, 'cb-tools', 'burgbox')); + +% Add CortexLab paths. These are mostly extra classes that allow Rigbox to +% work with other software developed by CortexLab, including MPEP +addpath(fullfile(root, 'cortexlab')); + +% Add signals paths, this includes all the core code for running signals +% experiments. This submodule is maintained by Chris Burgess. +addpath(fullfile(root, 'signals', 'util'), fullfile(root, 'signals', 'mexnet')); +% Add the Java paths for signals +jcp = fullfile(fileparts(root, 'signals', 'java')); +if ~any(strcmp(javaclasspath, jcp)); javaaddpath(jcp); end + +% Add the paths for Alyx-matlab. This submodule allows one to interact +% with an instance of an Alyx database. For more information please visit: +% http://alyx.readthedocs.io/en/latest/ +addpath(fullfile(root, 'alyx-matlab'), fullfile(root, 'helpers')); + +% Add paths for the npy-matlab. This submodule is maintained by the +% Kwik Team (https://github.com/kwikteam). It allows for the saving of +% NumPy binary files. Used by Rigbox to save data as .npy files with the +% ALF (ALex File) naming convention. For more information please visit: +% https://docs.scipy.org/doc/numpy-dev/neps/npy-format.html +addpath(fullfile(root, 'npy-matlab')); + +% Add the Java paths for Java WebSockets used for communications between +% the stimulus computer and the master computer +cbtoolsjavapath = fullfile(root, 'cb-tools', 'java'); javaclasspathfile = fullfile(prefdir, 'javaclasspath.txt'); fid = fopen(javaclasspathfile, 'a+'); fseek(fid, 0, 'bof'); closeFile = onCleanup( @() fclose(fid) ); -javaclasspaths = first(textscan(fid,'%s', 'CommentStyle', '#',... - 'Delimiter','')); % this will crash on 2014b, but not in 2016b +javaclasspaths = first(textscan(fid,'%s', 'CommentStyle', '#', 'Delimiter','')); cbtoolsInJavaPath = any(strcmpi(javaclasspaths, cbtoolsjavapath)); +%%% Validate that paths saved correctly %%% if savePaths -% assert(savepath == 0, 'Failed to save changes to MATLAB path'); + assert(savepath == 0, 'Failed to save changes to MATLAB path'); if ~cbtoolsInJavaPath fseek(fid, 0, 'eof'); n = fprintf(fid, '\n#path to CB-tools java classes\n%s', cbtoolsjavapath); @@ -72,4 +130,34 @@ function addRigboxPaths(savePaths) 'Cannot use java classes without saving new classpath'); end +%%% Attempt to move dll file for signals %%% +fileName = fullfile(root, 'signals', 'msvcr120.dll'); +fileExists = any(strcmp('msvcr120.dll',{sys32.name})); +copied = false; +if isWindowsAdmin % If user has admin privileges, attempt to copy dll file + if fileExists % If there's already a dll file there prompt use to make backup + prompt = sprintf(['For signals to work propery, it is nessisary to copy ',... + 'the file \n', strrep(fileName, '\', '\\'), ' to ',... + 'C:\\Windows\\System32.\n',... + 'You may want to make a backup of your existing dll file before continuing.\n\n',... + 'Do you want to proceed? Y/N [Y]: ']); + str = input(prompt,'s'); if isempty(str); str = 'y'; end + if strcmpi(str, 'n'); return; end % Return without copying + end + copied = copyfile(fileName, 'C:\Windows\System32'); +end +% Check that the file was copied +if ~copied + warning('Rigbox:setup:libraryRequired', ['Please copy the file ',... + '%s to C:\\Windows\\System32.'], fileName) +end + +function out = isWindowsAdmin() +%ISWINDOWSADMIN True if this user is in admin role. +% 2011 Andrew Janke (https://github.com/apjanke) +if ~NET.isNETSupported; out = false; return; end +wi = System.Security.Principal.WindowsIdentity.GetCurrent(); +wp = System.Security.Principal.WindowsPrincipal(wi); +out = wp.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator); +end end \ No newline at end of file diff --git a/alyx-matlab b/alyx-matlab new file mode 160000 index 00000000..94334e93 --- /dev/null +++ b/alyx-matlab @@ -0,0 +1 @@ +Subproject commit 94334e938c52f225dc0dcd78ea670205c9cf603f diff --git a/cortexlab/+tl/bindMpepServer.m b/cortexlab/+tl/bindMpepServer.m index 9b4a5024..5e371841 100644 --- a/cortexlab/+tl/bindMpepServer.m +++ b/cortexlab/+tl/bindMpepServer.m @@ -21,6 +21,7 @@ % mpepSendPort = 1103; % send responses back to this remote port quitKey = KbName('esc'); manualStartKey = KbName('t'); +livePlotKey = KbName('p'); %% Start UDP communication listeners = struct(... @@ -119,7 +120,10 @@ function listen() KbQueueCreate(); KbQueueStart(); cleanup1 = onCleanup(@KbQueueRelease); - log('Polling for UDP messages. PRESS <%s> TO QUIT', KbName(quitKey)); + log(['Polling for UDP messages. PRESS <%s> TO QUIT, '... + '<%s> to manually start/stop timeline, and ',... + '<%s> to toggle live plotting'],... + KbName(quitKey), KbName(manualStartKey), KbName(livePlotKey)); running = true; tid = tic; while running @@ -128,6 +132,9 @@ function listen() if firstPress(quitKey) running = false; end + if firstPress(livePlotKey) + tlObj.LivePlot = ~tlObj.LivePlot; + end if firstPress(manualStartKey) && ~tlObj.IsRunning if isempty(tls.AlyxInstance) diff --git a/npy-matlab b/npy-matlab new file mode 160000 index 00000000..524bd143 --- /dev/null +++ b/npy-matlab @@ -0,0 +1 @@ +Subproject commit 524bd143c34cbd9d1bbc895ed05e082ce4249b62 diff --git a/readme.md b/readme.md index 0e01d560..d93aa94e 100644 --- a/readme.md +++ b/readme.md @@ -18,13 +18,12 @@ Rigbox has a number of essential and optional software dependencies, listed belo * Signal Processing Toolbox * Instrument Control Toolbox -Additionally, Rigbox works with a number of extra repositories: +Additionally, Rigbox works with a number of extra submodules (included): * [Signals](https://github.com/dendritic/signals) (for running bespoke experiment designs) * Statistics and Machine Learning Toolbox * [Microsoft Visual C++ Redistributable for Visual Studio 2015](https://www.microsoft.com/en-us/download/details.aspx?id=48145) * [Alyx-matlab](https://github.com/cortex-lab/alyx-matlab) (for registering data to, and retrieving from, an Alyx database - * [Missing HTTP v1](https://github.com/psexton/missing-http/releases/tag/missing-http-1.0.0) or later - * [JSONlab](https://uk.mathworks.com/matlabcentral/fileexchange/33381-jsonlab--a-toolbox-to-encode-decode-json-files) +* [NPY-matlab](https://github.com/kwikteam/npy-matlab) (for saving data in binary NPY format) ## Installing 1. To install Rigbox, first ensure that all the above dependencies are installed. diff --git a/signals b/signals new file mode 160000 index 00000000..c2cf2854 --- /dev/null +++ b/signals @@ -0,0 +1 @@ +Subproject commit c2cf2854491525905ed9f21e0d708b451bfce988 From 8284459ddcc01e8acc416b5df8075134297f1044 Mon Sep 17 00:00:00 2001 From: petersaj Date: Tue, 17 Apr 2018 13:19:50 +0100 Subject: [PATCH 109/507] Small fixes to addRigboxPaths mostly typos --- addRigboxPaths.m | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/addRigboxPaths.m b/addRigboxPaths.m index 9c41c4a4..9aa6e0ef 100644 --- a/addRigboxPaths.m +++ b/addRigboxPaths.m @@ -32,8 +32,8 @@ function addRigboxPaths(savePaths) requiredMissing = setdiff(... {'Data Acquisition Toolbox', ... 'Signal Processing Toolbox', ... - 'Instrument Control Toolbox'}, ... - 'Statistics and Machine Learning Toolbox',... + 'Instrument Control Toolbox', ... + 'Statistics and Machine Learning Toolbox'},... {toolboxes.Name}); assert(isempty(requiredMissing),'Rigbox:setup:toolboxRequired',... @@ -90,7 +90,7 @@ function addRigboxPaths(savePaths) % experiments. This submodule is maintained by Chris Burgess. addpath(fullfile(root, 'signals', 'util'), fullfile(root, 'signals', 'mexnet')); % Add the Java paths for signals -jcp = fullfile(fileparts(root, 'signals', 'java')); +jcp = fullfile(root, 'signals', 'java'); if ~any(strcmp(javaclasspath, jcp)); javaaddpath(jcp); end % Add the paths for Alyx-matlab. This submodule allows one to interact From 6e5b5cbfcbf22a0bf3f390b49f57d8331c9f55aa Mon Sep 17 00:00:00 2001 From: Andy Peters Date: Tue, 17 Apr 2018 13:37:13 +0100 Subject: [PATCH 110/507] moved alyx-matlab to alyx-as-class --- alyx-matlab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx-matlab b/alyx-matlab index 94334e93..901a90fa 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 94334e938c52f225dc0dcd78ea670205c9cf603f +Subproject commit 901a90fad903b15ed77574374e93d3f48b487681 From c6ca41ca7ff4e759a13ed5c5486c0f8ba76779c8 Mon Sep 17 00:00:00 2001 From: Andy Peters Date: Tue, 17 Apr 2018 13:42:58 +0100 Subject: [PATCH 111/507] fixed alyx-matlab helpers path in addRigboxPaths --- addRigboxPaths.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addRigboxPaths.m b/addRigboxPaths.m index 9aa6e0ef..f740afc5 100644 --- a/addRigboxPaths.m +++ b/addRigboxPaths.m @@ -96,7 +96,7 @@ function addRigboxPaths(savePaths) % Add the paths for Alyx-matlab. This submodule allows one to interact % with an instance of an Alyx database. For more information please visit: % http://alyx.readthedocs.io/en/latest/ -addpath(fullfile(root, 'alyx-matlab'), fullfile(root, 'helpers')); +addpath(fullfile(root, 'alyx-matlab'), fullfile(root, 'alyx-matlab', 'helpers')); % Add paths for the npy-matlab. This submodule is maintained by the % Kwik Team (https://github.com/kwikteam). It allows for the saving of From cb583ba0974340f7a5b74878b89593f049209323 Mon Sep 17 00:00:00 2001 From: petersaj Date: Tue, 17 Apr 2018 13:53:01 +0100 Subject: [PATCH 112/507] Forgot to add signals root directory in last commit --- addRigboxPaths.m | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/addRigboxPaths.m b/addRigboxPaths.m index f740afc5..d9f255e8 100644 --- a/addRigboxPaths.m +++ b/addRigboxPaths.m @@ -88,7 +88,9 @@ function addRigboxPaths(savePaths) % Add signals paths, this includes all the core code for running signals % experiments. This submodule is maintained by Chris Burgess. -addpath(fullfile(root, 'signals', 'util'), fullfile(root, 'signals', 'mexnet')); +addpath(fullfile(root, 'signals'),... + fullfile(root, 'signals', 'mexnet'),... + fullfile(root, 'signals', 'util')); % Add the Java paths for signals jcp = fullfile(root, 'signals', 'java'); if ~any(strcmp(javaclasspath, jcp)); javaaddpath(jcp); end From b32b6155ea50e7a9de6e92a10fa4390b64e666e0 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 17 Apr 2018 16:37:58 +0100 Subject: [PATCH 113/507] catStructs no longer necessary All data returned as structures now --- +eui/AlyxPanel.m | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 9aa5ee11..b64a34c4 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -323,7 +323,7 @@ function dispWaterReq(obj, src, ~) % Refresh the timer as the user isn't inactive stop(obj.LoginTimer); start(obj.LoginTimer) try - s = catStructs(ai.getData('water-restricted-subjects')); % struct with data about restricted subjects + 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',... @@ -334,7 +334,7 @@ function dispWaterReq(obj, src, ~) 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}; + record = wr.records(end); else record = struct(); end @@ -362,7 +362,7 @@ function dispWaterReq(obj, src, ~) obj.WaterRemaining = s(idx).water_requirement_remaining; end catch me - d = loadjson(me.message); + d = me.message; %FIXME: JSON no longer returned if isfield(d, 'detail') && strcmp(d.detail, 'Not found.') set(obj.WaterRequiredText, 'ForegroundColor', 'black',... 'String', sprintf('Subject %s not found in alyx', obj.Subject)); From 6a828646b56fd77652c89259c26774c8be7badc0 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 17 Apr 2018 19:05:39 +0100 Subject: [PATCH 114/507] Timeline uses new endpoint hw.Timeline now registers using the new registerFile method --- +hw/Timeline.m | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index f0ae52b1..51201172 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -467,11 +467,10 @@ function stop(obj) end % register Timeline.mat file to Alyx database - [subject, ~, ~] = dat.parseExpRef(obj.Data.expRef); + subject = dat.parseExpRef(obj.Data.expRef); if ~isempty(obj.AlyxInstance) && obj.AlyxInstance.IsLoggedIn && ~strcmp(subject,'default') try - obj.AlyxInstance.registerFile(obj.Data.savePaths{end}, 'mat',... - obj.AlyxInstance.SessionURL, 'Timeline', []); + obj.AlyxInstance.registerFile(obj.Data.savePaths{2}); catch ex warning(ex.identifier, 'couldn''t register files to Alyx: %s', ex.message); end From aed274c885a35753a97deeef617047a84f9f9124 Mon Sep 17 00:00:00 2001 From: Julie Date: Tue, 17 Apr 2018 19:17:55 +0100 Subject: [PATCH 115/507] Added specific branch for alyx-matlab submodule --- .gitmodules | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitmodules b/.gitmodules index 4b73c981..a143960b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,7 @@ [submodule "alyx-matlab"] path = alyx-matlab url = https://github.com/cortex-lab/alyx-matlab/ + branch = alyx-as-class [submodule "signals"] path = signals url = https://github.com/dendritic/signals From 1cacfd15462e00586a5794b0828b576c725859e5 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 17 Apr 2018 20:29:02 +0100 Subject: [PATCH 116/507] Added registration of all timeline related files using new register-file endpoint --- +hw/Timeline.m | 9 ++++++--- alyx-matlab | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 51201172..2467e99a 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -466,16 +466,19 @@ function stop(obj) warning('did not write files into alf format. Check that alyx-matlab and npy-matlab repositories are in path'); end - % register Timeline.mat file to Alyx database + %Register ALF components and hardware structures to Alyx + %database. TODO: Make this process more robust. subject = dat.parseExpRef(obj.Data.expRef); if ~isempty(obj.AlyxInstance) && obj.AlyxInstance.IsLoggedIn && ~strcmp(subject,'default') try - obj.AlyxInstance.registerFile(obj.Data.savePaths{2}); + files = dir(fileparts(obj.Data.savePaths{2})); + files = fullfile(files(1).folder, {files(endsWith({files.name},... + {'HW.json', '.raw.npy', '_Timeline.npy'})).name}); + obj.AlyxInstance.registerFile([obj.Data.savePaths{2} files]); catch ex warning(ex.identifier, 'couldn''t register files to Alyx: %s', ex.message); end end - %TODO: Register ALF components to alyx, incl TimelineHW.json % delete data from memory, tl is now officially no longer running obj.Data = []; diff --git a/alyx-matlab b/alyx-matlab index 901a90fa..3e96903d 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 901a90fad903b15ed77574374e93d3f48b487681 +Subproject commit 3e96903d3a9bdddc1688c45e08a96e91adb26e06 From 3d66542ebaf5132501b475511866303f0f3bb8eb Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 20 Apr 2018 13:18:22 +0100 Subject: [PATCH 117/507] Added better handling of headless mode when Alyx is down --- +eui/AlyxPanel.m | 12 +++++++++++- +eui/MControl.m | 6 ++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index b64a34c4..0a203c2c 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -214,6 +214,8 @@ function login(obj) % Logging out does not cause the token to expire, instead the % token is simply deleted from this object. + % Reset headless flag in case user wishes to retry connection + obj.AlyxInstance.Headless = false; % Are we logging in or out? if ~obj.AlyxInstance.IsLoggedIn % logging in % attempt login @@ -227,7 +229,8 @@ function login(obj) start(obj.LoginTimer) % Enable all buttons set(findall(obj.RootContainer, '-property', 'Enable'), 'Enable', 'on'); - set(obj.LoginText, 'String', ['You are logged in as ', obj.AlyxInstance.User]); % 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 @@ -248,6 +251,13 @@ function login(obj) mkdir(thisDir); 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 diff --git a/+eui/MControl.m b/+eui/MControl.m index c7ab5901..8635f7b4 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -543,10 +543,12 @@ function beginExp(obj) set([obj.BeginExpButton obj.RigOptionsButton], 'Enable', 'off'); % Grey out buttons rig = obj.RemoteRigs.Selected; % Find which rig is selected % Save the current instance of Alyx so that eui.ExpPanel can register water to the correct account - if ~obj.AlyxPanel.AlyxInstance.IsLoggedIn && ~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); + assert(obj.AlyxPanel.AlyxInstance.IsLoggedIn||obj.AlyxPanel.AlyxInstance.Headless); catch obj.log('Warning: Must be logged in to Alyx before running an experiment') return From fbc92037c5b3a880e4adb8f34e086621256fd5fb Mon Sep 17 00:00:00 2001 From: k1o0 Date: Sat, 21 Apr 2018 20:54:30 +0100 Subject: [PATCH 118/507] Added helper function to remove empty elements rmEmpty simply removes all empty elements of the input array and returns it --- cb-tools/burgbox/rmEmpty.m | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 cb-tools/burgbox/rmEmpty.m diff --git a/cb-tools/burgbox/rmEmpty.m b/cb-tools/burgbox/rmEmpty.m new file mode 100644 index 00000000..82220d5e --- /dev/null +++ b/cb-tools/burgbox/rmEmpty.m @@ -0,0 +1,15 @@ +function passed = rmEmpty(A) +%RMEMPTY Returns input array with empty elements removed +% Simply removes all empty elements of the input array and returns it +% +% See also FUN.EMPTYSEQ, EMPTYELEMS, FUN.FILTER +% 2018 MW created + + +if iscell(A) + empty = cellfun('isempty', A); +else + empty = arrayfun(@isempty, A); +end + +passed = A(~empty); \ No newline at end of file From 974125ffa948eb0fd19bcf26f42c865a8ad799fe Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 23 Apr 2018 13:56:00 +0100 Subject: [PATCH 119/507] Updated readme --- .gitmodules | 1 - readme.md | 19 ++++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index a143960b..4b73c981 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,6 @@ [submodule "alyx-matlab"] path = alyx-matlab url = https://github.com/cortex-lab/alyx-matlab/ - branch = alyx-as-class [submodule "signals"] path = signals url = https://github.com/dendritic/signals diff --git a/readme.md b/readme.md index d93aa94e..b6632e3c 100644 --- a/readme.md +++ b/readme.md @@ -26,12 +26,25 @@ Additionally, Rigbox works with a number of extra submodules (included): * [NPY-matlab](https://github.com/kwikteam/npy-matlab) (for saving data in binary NPY format) ## Installing -1. To install Rigbox, first ensure that all the above dependencies are installed. +1. To install Rigbox, clone the repository in git. It is *not* recommended to clone directly into the MATLAB folder +```git clone https://github.com/cortex-lab/Rigbox.git``` 2. Pull the latest Rigbox-lite branch. This branch is currently the 'cleanest' one, however in the future it will likely be merged with the master branch. +``` +cd Rigbox/ +git checkout rigbox-lite +``` +3. Run the following to clone the submodules: +```git submodules update --init``` 3. In MATLAB run 'addRigboxPaths.m' and restart the program. 4. Set the correct paths by following the instructions in Rigbox\+dat\paths.m on both computers. 5. On the stimulus server, load the hardware.mat file in Rigbox\Repositories\code\config\exampleRig and edit according to your specific hardware setup (link to detailed instructions above, under 'Getting started'). +To keep up to date, run the following: +``` +git pull +git submodules update --remote +``` + ## Running an experiment On the stimulus server, run: @@ -76,6 +89,7 @@ NB: Lower-level communication protocol code is found in the +io package ## cb-tools\burgbox Burgbox contains many simply helper functions that are used by the main packages. Within this directory are further packages: + * +bui --- Classes for managing graphics objects such as axes * +aud --- Functions for interacting with PsychoPortAudio * +file --- Functions for simplifying directory and file management, for instance returning the modified dates for specified folders or filtering an array of directories by those that exist @@ -89,5 +103,8 @@ Burgbox contains many simply helper functions that are used by the main packages ## cortexlab The cortexlab directory is intended for functions and classes that are rig or lab specific, for instance code that allows compatibility with other stimulus presentation packages used by cortexlab (i.e. MPEP) +## alyx-matlab/@Alyx +This class allows interation with an instance of the Alyx database. More information about Alyx can be found [here](http://alyx.readthedocs.io/en/latest/). Information about using the alyx-matlab class can be found in [alyx-matlab/Examples.m](https://github.com/cortex-lab/alyx-matlab/blob/alyx-as-class/Examples.m). + ## Authors The majority of the Rigbox code was written by [Chris Burgess](https://github.com/dendritic/) in 2013. It is now maintained and developed by a number of people at [CortexLab](https://www.ucl.ac.uk/cortexlab). From 69de4c04ea15cbf1cf96890b1935485a14c9c9d6 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 24 Apr 2018 13:14:02 +0100 Subject: [PATCH 120/507] Reverted changes to dat.newExp. Was in a half-way house due to merge. Now contains no alyx info --- +dat/newExp.m | 57 +-------------------------------------------------- 1 file changed, 1 insertion(+), 56 deletions(-) diff --git a/+dat/newExp.m b/+dat/newExp.m index f0e8ee39..007aa566 100644 --- a/+dat/newExp.m +++ b/+dat/newExp.m @@ -58,53 +58,6 @@ % now make the folder(s) to hold the new experiment assert(all(cellfun(@(p) mkdir(p), expPath)), 'Creating experiment directories failed'); -if ~strcmp(subject, 'default') % Ignore fake subject - % if the Alyx Instance is set, find or create BASE session - expDate = alyx.datestr(expDate); % date in Alyx format - % Get list of base sessions - sessions = alyx.getData(AlyxInstance,... - ['sessions?type=Base&subject=' subject]); - - %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), expDate(1:10)) - d = struct; - d.subject = subject; - d.procedures = {'Behavior training/tasks'}; - d.narrative = 'auto-generated session'; - d.start_time = expDate; - d.type = 'Base'; - % d.users = {AlyxInstance.username}; - - base_submit = alyx.postData(AlyxInstance, 'sessions', d); - assert(isfield(base_submit,'subject'),... - 'Submitted base session did not return appropriate values'); - - %Now retrieve the sessions again - sessions = alyx.getData(AlyxInstance,... - ['sessions?type=Base&subject=' subject]); - end - latest_base = sessions{end}; - - %Now create a new SUBSESSION, using the same experiment number - d = struct; - d.subject = subject; - d.procedures = {'Behavior training/tasks'}; - d.narrative = 'auto-generated session'; - d.start_time = expDate; - d.type = 'Experiment'; - d.parent_session = latest_base.url; - d.number = expSeq; - % d.users = {AlyxInstance.username}; - - subsession = alyx.postData(AlyxInstance, 'sessions', d); - assert(isfield(subsession,'subject'),... - 'Failed to create new sub-session in Alyx for %s', subject); - url = subsession.url; -else - url = []; -end - % if the parameters had an experiment definition function, save a copy in % the experiment's folder if isfield(expParams, 'defFunction') @@ -131,16 +84,8 @@ [expRef, '_parameters.json']); savejson('parameters', expParams, jsonPath); % Register our JSON parameter set to Alyx - if ~strcmp(subject, 'default') - alyx.registerFile(jsonPath, 'json', url, 'Parameters', [], AlyxInstance); - end catch ex - warning(ex.identifier, 'Failed to save paramters as JSON: %s.\n Registering mat file instead', ex.message) - % Register our parameter set to Alyx - if ~strcmp(subject, 'default') - alyx.registerFile(dat.expFilePath(expRef, 'parameters', 'master'), 'mat',... - url, 'Parameters', [], AlyxInstance); - end + warning(ex.identifier, 'Failed to save paramters as JSON: %s.', ex.message) end end end \ No newline at end of file From af0b25cd4017cac6d348fae168dc9ce5f5b8b70a Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 25 Apr 2018 11:47:28 +0100 Subject: [PATCH 121/507] Added Apache 2.0 licence --- LICENCE | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 LICENCE diff --git a/LICENCE b/LICENCE new file mode 100644 index 00000000..e06d2081 --- /dev/null +++ b/LICENCE @@ -0,0 +1,202 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + From 978d9a21d7e8caa40279ba30265b603134a025b8 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 25 Apr 2018 12:15:25 +0100 Subject: [PATCH 122/507] Changed to fork of signals for testing new changes --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index 4b73c981..c7ef4a47 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,7 +3,7 @@ url = https://github.com/cortex-lab/alyx-matlab/ [submodule "signals"] path = signals - url = https://github.com/dendritic/signals + url = https://github.com/cortex-lab/signals [submodule "npy-matlab"] path = npy-matlab url = https://github.com/kwikteam/npy-matlab From 3768be96b7276e86fddb62ff8ae242145ff071ac Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 25 Apr 2018 18:29:50 +0100 Subject: [PATCH 123/507] Fix for issues with audio device conflict. Audio device now opened in configuresChoiceWorld instead of hw.devices. Signals now able to output samples to named audio devices. --- +exp/Experiment.m | 14 +++++++------- +exp/SignalsExp.m | 7 ++----- +hw/devices.m | 14 +++++++------- cortexlab/+exp/configureChoiceExperiment.m | 7 +++++++ 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/+exp/Experiment.m b/+exp/Experiment.m index acdd7f9f..d78cdef5 100644 --- a/+exp/Experiment.m +++ b/+exp/Experiment.m @@ -556,6 +556,9 @@ function cleanup(obj) % destroy video texures created during intialisation deleteTextures(obj.StimWindow); + + % close audio + aud.close(obj.Audio); end function mainLoop(obj) @@ -777,17 +780,14 @@ function saveData(obj) warning('No Alyx token set'); else try - [subject, expDate, seq] = dat.parseExpRef(obj.Data.expRef); + subject = dat.parseExpRef(obj.Data.expRef); if strcmp(subject, 'default'); return; end % Register saved files - obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... - obj.AlyxInstance.SessionURL, 'Block', []); -% obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... -% {subject, expDate, seq}, 'Block', []); + obj.AlyxInstance.registerFile(savepaths{end}); % Save the session end time if ~isempty(obj.AlyxInstance.SessionURL) - obj.AlyxInstance.putData(obj.AlyxInstance.SessionURL,... - struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject)); + obj.AlyxInstance.postData(obj.AlyxInstance.SessionURL,... + struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject), 'put'); else % Infer from date session and retrieve using expFilePath end diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index d6f86b99..4a019e22 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -143,10 +143,7 @@ obj.Inputs = sig.Registry(clockFun); obj.Outputs = sig.Registry(clockFun); obj.Visual = StructRef; - nAudChannels = getOr(paramStruct, 'numAudChannels', rig.audioDevice.NrOutputChannels); - audSampleRate = getOr(paramStruct, 'audSampleRate', rig.audioDevice.DefaultSampleRate); % Hz - audDevIdx = getOr(paramStruct, 'audDevIdx', rig.audioDevice.DeviceIndex); % -1 means use system default - obj.Audio = audstream.Registry(audSampleRate, nAudChannels, audDevIdx); + obj.Audio = audstream.Registry(rig.audioDevices); obj.Events = sig.Registry(clockFun); %% configure signals net = sig.Net; @@ -942,7 +939,7 @@ function saveData(obj) warning('No Alyx token set'); else try - [subject, seq] = dat.parseExpRef(obj.Data.expRef); + subject = dat.parseExpRef(obj.Data.expRef); if strcmp(subject, 'default'); return; end % Register saved files obj.AlyxInstance.registerFile(savepaths{end}); diff --git a/+hw/devices.m b/+hw/devices.m index 4a1a4616..2721566a 100644 --- a/+hw/devices.m +++ b/+hw/devices.m @@ -59,13 +59,13 @@ InitializePsychSound; IsPsychSoundInitialize = true; end - idx = pick(rig, 'audioDevice', 'def', 0); - rig.audioDevice = PsychPortAudio('GetDevices', [], idx); - % setup playback audio device - no configurable settings for now - % 96kHz sampling rate, 2 channels, try to very low audio latency - rig.audio = aud.open(rig.audioDevice.DeviceIndex,... - rig.audioDevice.NrOutputChannels,... - rig.audioDevice.DefaultSampleRate, 1); + % Get list of audio devices + devs = getOr(rig, 'audioDevices', PsychPortAudio('GetDevices')); + % Sanitize the names + names = matlab.lang.makeValidName([{'default'} {devs(2:end).DeviceName}],... + 'ReplacementStyle', 'delete'); + for i = 1:length(names); devs(i).DeviceName = names{i}; end + rig.audioDevices = devs; end rig.paths = paths; diff --git a/cortexlab/+exp/configureChoiceExperiment.m b/cortexlab/+exp/configureChoiceExperiment.m index 66ea611c..446573b2 100644 --- a/cortexlab/+exp/configureChoiceExperiment.m +++ b/cortexlab/+exp/configureChoiceExperiment.m @@ -17,6 +17,13 @@ params.Struct = paramStruct; %% Generate audio samples at device sample rate +% setup playback audio device - no configurable settings for now +% 96kHz sampling rate, 2 channels, try to very low audio latency +dev = rig.audioDevices(strcmp('default', {rig.audioDevices.DeviceName})); +rig.audio = aud.open(dev.DeviceIndex,... +dev.NrOutputChannels,... +dev.DefaultSampleRate, 1); + %Sound samples are wrapped in a cell for storing to a parameter %(to ensure they're used as one global parameter) audSampleRate = aud.rate(rig.audio); From 3ca7719ad35f5d9ed93baaacc49d004fcec1f41d Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 25 Apr 2018 18:33:53 +0100 Subject: [PATCH 124/507] Updated signals submodule to allow putting of audio samples to a named device --- signals | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/signals b/signals index c2cf2854..c3a09df7 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit c2cf2854491525905ed9f21e0d708b451bfce988 +Subproject commit c3a09df7875ed4eff9d130cb69db6f446e0eb877 From 0afd6b912858257ecb52194473550e5cd47721a1 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 26 Apr 2018 10:42:36 +0100 Subject: [PATCH 125/507] Updated alyx-matlab to use regex to find relative path in registerFile --- alyx-matlab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx-matlab b/alyx-matlab index 3e96903d..577a6d02 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 3e96903d3a9bdddc1688c45e08a96e91adb26e06 +Subproject commit 577a6d02fb30e8e063a7609950e2b224e5ea0b39 From 442842eb1c03431f1e22a31999aaefed5beafcda Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 26 Apr 2018 12:56:43 +0100 Subject: [PATCH 126/507] Fix for errors when inferring signals params when audio device info is used in the expDef --- +exp/inferParameters.m | 16 +++++++++++----- +hw/devices.m | 3 +-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/+exp/inferParameters.m b/+exp/inferParameters.m index 51c57a01..6175fd3f 100644 --- a/+exp/inferParameters.m +++ b/+exp/inferParameters.m @@ -19,14 +19,12 @@ e.pars = net.subscriptableOrigin('pars'); e.pars.CacheSubscripts = true; e.visual = net.subscriptableOrigin('visual'); -e.audio = net.subscriptableOrigin('audio'); -e.audio.SampleRate = 44100; -e.audio.NChannels = 2; +e.audio.Devices = @dummyDev; e.inputs = net.subscriptableOrigin('inputs'); e.outputs = net.subscriptableOrigin('outputs'); try - expdeffun(e.t, e.events, e.pars, e.visual, e.inputs , e.outputs); + expdeffun(e.t, e.events, e.pars, e.visual, e.inputs , e.outputs, e.audio); % paramNames will be the strings corresponding to the fields of e.pars % that the user tried to reference in her expdeffun. paramNames = e.pars.Subscripts.keys'; @@ -60,5 +58,13 @@ net.delete(); - + function dev = dummyDev(~) + % Returns a dummy audio device structure, regardless of input + % Returns a standard structure with values for generating tone + % samples. This function gets around the problem of querying the + % rig's audio devices when inferring parameters. + dev = struct('DeviceIndex', -1,... + 'DefaultSampleRate', 44100,... + 'NrOutputChannels', 2); + end end \ No newline at end of file diff --git a/+hw/devices.m b/+hw/devices.m index 2721566a..24e532cf 100644 --- a/+hw/devices.m +++ b/+hw/devices.m @@ -86,5 +86,4 @@ function configure(deviceName, usedaq) end end -end - +end \ No newline at end of file From 0face35e6d413475a2aeb3bcedac58c28757fb6d Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 26 Apr 2018 14:13:00 +0100 Subject: [PATCH 127/507] updated signals repo --- signals | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/signals b/signals index c3a09df7..c879c89d 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit c3a09df7875ed4eff9d130cb69db6f446e0eb877 +Subproject commit c879c89d9b267370b2c6cb061e8c8afaf48f4474 From 590c17bb707a06b008468a1881e2ae90b61018ec Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 4 May 2018 13:44:19 +0100 Subject: [PATCH 128/507] Added rig hardware save in JSON for each experiment. Also the sessions property in Timeline is now availiable to output objects without seperate method. Also removed expInfo remository and replaced it with 'main' --- +dat/delParamProfile.m | 4 ++-- +dat/expExists.m | 2 +- +dat/expFilePath.m | 25 ++++--------------------- +dat/expPath.m | 4 ++-- +dat/listExps.m | 6 +++--- +dat/listSubjects.m | 8 ++++---- +dat/loadParamProfiles.m | 2 +- +dat/logPath.m | 4 ++-- +dat/newExp.m | 4 ++-- +dat/paths.m | 29 ++++++++--------------------- +dat/reposPath.m | 8 ++++---- +dat/saveParamProfile.m | 4 ++-- +dat/subjectExists.m | 2 +- +eui/AlyxPanel.m | 6 +++--- +hw/PositionSensor.m | 5 ++++- +hw/TLOutputChrono.m | 2 +- +hw/Timeline.m | 17 +++++------------ +hw/devices.m | 2 +- +srv/expServer.m | 5 +++++ +srv/prepareExp.m | 13 ------------- alyx-matlab | 2 +- cb-tools/burgbox/mergeStructs.m | 6 ++++++ npy-matlab | 2 +- 23 files changed, 63 insertions(+), 99 deletions(-) diff --git a/+dat/delParamProfile.m b/+dat/delParamProfile.m index d67b4d24..ee46eeb6 100644 --- a/+dat/delParamProfile.m +++ b/+dat/delParamProfile.m @@ -8,14 +8,14 @@ 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); %remove the params with the field named by profile profiles = rmfield(profiles, profileName); %wrap in a struct for saving -set.(expType) = profiles; +set.(expType) = profiles; %#ok %save the updated set of profiles to each repos %where files exist already, append diff --git a/+dat/expExists.m b/+dat/expExists.m index 88ea312e..82924523 100644 --- a/+dat/expExists.m +++ b/+dat/expExists.m @@ -17,7 +17,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..b9053047 100644 --- a/+dat/expFilePath.m +++ b/+dat/expFilePath.m @@ -44,65 +44,48 @@ function [repos, suff, dateLevel] = typeInfo(type) % 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'; switch lower(type) case 'block' % MAT-file with info about each set of trials - repos = 'expInfo'; suff = '_Block.mat'; case 'hw-info' % MAT-file with info about the hardware used for an experiment - repos = 'expInfo'; suff = '_hardwareInfo.mat'; case '2p-raw' % TIFF with 2-photon raw fluorescence movies - repos = 'twoPhoton'; suff = '_2P.tif'; case 'calcium-preview' - repos = 'twoPhoton'; suff = '_2P_CalciumPreview.tif'; case 'calcium-reg' - repos = 'twoPhoton'; suff = '_2P_CalciumReg'; case 'calcium-regframe' - repos = 'twoPhoton'; suff = '_2P_CalciumRegFrame.tif'; case 'timeline' % MAT-file with acquired timing information - repos = 'expInfo'; suff = '_Timeline.mat'; case 'calcium-roi' - repos = 'twoPhoton'; suff = '_ROI.mat'; case 'calcium-fc' % minimally filtered fractional change frames - repos = 'twoPhoton'; suff = '_2P_CalciumFC'; case 'calcium-ffc' % ROI filtered fractional change frames - repos = 'twoPhoton'; suff = '_2P_CalciumFFC'; case 'calcium-widefield-svd' - repos = 'widefield'; suff = '_SVD'; case 'eyetracking' - repos = 'eyeTracking'; suff = '_eye'; case 'parameters' % MAT-file with parameters used for experiment - repos = 'expInfo'; suff = '_parameters.mat'; case 'lasermanip' - repos = 'expInfo'; suff = '_laserManip.mat'; case 'img-info' - repos = 'twoPhoton'; suff = '_imgInfo.mat'; case 'tmaze' - repos = 'expInfo'; suff = '_TMaze.mat'; case 'expdeffun' - repos = 'expInfo'; suff = '_expDef.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 diff --git a/+dat/expPath.m b/+dat/expPath.m index b9b1abf6..abcc8294 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 diff --git a/+dat/listExps.m b/+dat/listExps.m index c29506a3..b44aa8dc 100644 --- a/+dat/listExps.m +++ b/+dat/listExps.m @@ -7,15 +7,15 @@ % 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 = dat.reposPath('main', 'master'); 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); + subjectPath = fullfile(mainPath, subject); subjectDirs = file.list(subjectPath, 'dirs'); dateRegExp = '^(?\d\d\d\d)\-?(?\d\d)\-?(?\d\d)$'; dateMatch = regexp(subjectDirs, dateRegExp, 'names'); diff --git a/+dat/listSubjects.m b/+dat/listSubjects.m index cca333d7..4147b013 100644 --- a/+dat/listSubjects.m +++ b/+dat/listSubjects.m @@ -1,7 +1,7 @@ function subjects = listSubjects(varargin) %DAT.LISTSUBJECTS Lists recorded subjects % subjects = DAT.LISTSUBJECTS([alyxInstance]) Lists the experimental subjects present -% in experiment info repository ('expInfo'). +% in experiment info repository ('main'). % % Optional input argument of an alyx instance will enable generating this % list from alyx rather than from the directory structure on zserver @@ -33,11 +33,11 @@ subjects = [{'default'}, thisUserSubs, otherUserSubs]'; else - % 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 = dat.reposPath('main', 'master'); - dirs = file.list(expInfoPath, 'dirs'); + dirs = file.list(mainPath, 'dirs'); subjects = dirs(~cellfun(@(d)startsWith(d, '@'), dirs)); % exclude misc directories end end \ No newline at end of file diff --git a/+dat/loadParamProfiles.m b/+dat/loadParamProfiles.m index ee0b4c61..2c82c14a 100644 --- a/+dat/loadParamProfiles.m +++ b/+dat/loadParamProfiles.m @@ -8,7 +8,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 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 007aa566..0ff092b8 100644 --- a/+dat/newExp.m +++ b/+dat/newExp.m @@ -49,8 +49,8 @@ expSeq = 1; end -% expInfo repository is the reference location for which experiments exist -[expPath, expRef] = dat.expPath(subject, floor(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)); diff --git a/+dat/paths.m b/+dat/paths.m index e95284dc..1dee5d35 100644 --- a/+dat/paths.m +++ b/+dat/paths.m @@ -14,10 +14,6 @@ end server1Name = '\\zubjects.cortexlab.net'; -% server2Name = '\\zserver2.cortexlab.net'; -% server3Name = '\\zserver3.cortexlab.net'; % 2017-02-18 MW - Currently -% unused by Rigbox -server4Name = '\\zserver4.cortexlab.net'; basketName = '\\basket.cortexlab.net'; % for working analyses lugaroName = '\\lugaro.cortexlab.net'; % for tape backup @@ -27,23 +23,11 @@ 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, 'Subjects'); -% Repository for info about experiments, i.e. stimulus, behavioural, -% Timeline etc -p.expInfoRepository = p.mainRepository; -% Repository for storing two photon movies -p.twoPhotonRepository = p.mainRepository; -% for calcium widefield imaging -p.widefieldRepository = fullfile(server1Name, 'data', 'GCAMP'); -% Repository for storing eye tracking movies -p.eyeTrackingRepository = p.mainRepository; +% Under the new system of having data grouped by mouse +% rather than data type, all experimental data are saved here. +p.mainRepository = fullfile(server1Name, 'Subjects'); -% 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'); @@ -64,7 +48,6 @@ p.tapeArchiveRepository = fullfile(lugaroName, 'bigdrive', 'toarchive'); - %% load rig-specific overrides from config file, if any customPathsFile = fullfile(p.rigConfig, 'paths.mat'); if file.exists(customPathsFile) @@ -73,9 +56,13 @@ % 'centralRepository' is deprecated, remove field, if any customPaths = rmfield(customPaths, 'centralRepository'); end + if isfield(customPaths, 'expInfoRepository') + % 'expInfo' is deprecated, change to 'main' + p.mainRepository = customPaths.expInfoRepository; + customPaths = rmfield(customPaths, 'expInfoRepository'); + 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..e2991ebc 100644 --- a/+dat/reposPath.m +++ b/+dat/reposPath.m @@ -13,11 +13,11 @@ % that repository, and "master" will return the path to the master % location. % -% 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 string cell array % -% 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 % % Part of Rigbox diff --git a/+dat/saveParamProfile.m b/+dat/saveParamProfile.m index 9ca7b011..e76ffdf9 100644 --- a/+dat/saveParamProfile.m +++ b/+dat/saveParamProfile.m @@ -9,7 +9,7 @@ function saveParamProfile(expType, profileName, params) %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); @@ -17,7 +17,7 @@ function saveParamProfile(expType, profileName, params) profiles.(profileName) = params; %wrap in a struct for saving set = struct; -set.(expType) = profiles; +set.(expType) = profiles; %#ok %save the updated set of profiles to each repos %where files exist already, append 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/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 0a203c2c..e8f5fd28 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -242,12 +242,12 @@ function login(obj) 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 diff --git a/+hw/PositionSensor.m b/+hw/PositionSensor.m index a7bd3c4f..350bbfe2 100644 --- a/+hw/PositionSensor.m +++ b/+hw/PositionSensor.m @@ -35,7 +35,10 @@ end function value = get.LastPosition(obj) - value = obj.DataBuffer(obj.SampleCount); + value = []; + if obj.SampleCount + value = obj.DataBuffer(obj.SampleCount); + end end function zero(obj, log) diff --git a/+hw/TLOutputChrono.m b/+hw/TLOutputChrono.m index 373cad10..1bf9c52d 100644 --- a/+hw/TLOutputChrono.m +++ b/+hw/TLOutputChrono.m @@ -68,7 +68,7 @@ function init(obj, timeline) % Add on-demand digital channel obj.Session.addDigitalChannel(obj.DaqDeviceID, obj.DaqChannelID, 'OutputOnly'); warning('on', 'daq:Session:onDemandOnlyChannelsAdded'); - tls = timeline.getSessions('main'); + tls = timeline.Sessions('main'); %%Send a test pulse low, then high to clocking channel & check we read it back idx = cellfun(@(s2)strcmp('chrono',s2), {timeline.Inputs.name}); diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 2467e99a..dca17a60 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -94,7 +94,6 @@ properties (Transient, Access = protected) Listener % holds the listener for 'DataAvailable', see DataAvailable and Timeline.process() - Sessions = containers.Map % map of daq sessions and their channels, created at tl.start() LastTimestamp % the last timestamp returned from the daq during the DataAvailable event. Used to check sampling continuity, see tl.process() Ref % the expRef string. See tl.start() AlyxInstance % a struct contraining the Alyx token, user and url for ile registration. See tl.start() @@ -103,6 +102,10 @@ DataFID % The data file ID for writing aquired data directly to disk end + properties (Transient, SetAccess = protected, GetAccess = {?hw.Timeline, ?hw.TLOutput}) + Sessions = containers.Map % map of daq sessions and their channels, created at tl.start() + end + methods function obj = Timeline(hw) % TIMELINE Constructor method @@ -495,18 +498,8 @@ function stop(obj) % Report successful stop fprintf('Timeline for ''%s'' stopped and saved successfully.\n', obj.Ref); end - - function s = getSessions(obj, name) - % GETSESSIONS() Returns the Sessions property - % returns the Sessions property. Some things (e.g. output - % classes) need this. - % - % See Also HW.TLOUTPUT - s = obj.Sessions(name); - end - end - + methods (Access = private) function init(obj) % Create DAQ session and add channels diff --git a/+hw/devices.m b/+hw/devices.m index 24e532cf..2b3b914e 100644 --- a/+hw/devices.m +++ b/+hw/devices.m @@ -56,7 +56,7 @@ if init % intialise psychportaudio if isempty(IsPsychSoundInitialize) || ~IsPsychSoundInitialize - InitializePsychSound; + InitializePsychSound IsPsychSoundInitialize = true; end % Get list of audio devices diff --git a/+srv/expServer.m b/+srv/expServer.m index f43a1749..36aa8529 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -253,6 +253,11 @@ function runExp(expRef, preDelay, postDelay, Alyx) rig.stimWindow.BackgroundColour = bgColour; rig.stimWindow.flip(); % clear the screen after + % save a copy of the hardware in JSON + jsonData = obj2json(rig); %#ok + name = dat.expFilePath(expRef, 'hw-info', 'master'); + save([name(1:end-3) 'json'], 'jsonData', '-ascii'); + if rig.timeline.UseTimeline %stop the timeline system rig.timeline.stop(); diff --git a/+srv/prepareExp.m b/+srv/prepareExp.m index 6bb2a2f3..cff683e0 100644 --- a/+srv/prepareExp.m +++ b/+srv/prepareExp.m @@ -1,5 +1,4 @@ function experiment = prepareExp(params, rig, preDelay, postDelay, comm) - % parameters should have a create experiment function that takes three % arguments: % 1st, the parameters structure for configuring the experiment @@ -30,16 +29,4 @@ exp.EventHandler('experimentInit', startServices),... exp.EventHandler('experimentCleanup', stopServices)); end - -% % add a log entry for the experiment -% %TODO: in future logging will be handled by the client so that e.g. -% %comments can be entered by the supervisor and added -% % expInfo.ref = block.expRef; -% % expInfo.proportionCorrect = psycho.proportionCorrect(block); -% % expInfo.rewardType = 'water'; -% % expInfo.rewardTotal = sum([block.rewardDeliveredSizes]); % in microlitres -% % expInfo.rewardUnits = '�l'; % in microlitres -% % data.addLogEntry(subjectRef, block.startDateTime, 'experiment-info', expInfo, ''); -% end - end \ No newline at end of file diff --git a/alyx-matlab b/alyx-matlab index 577a6d02..378f95af 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 577a6d02fb30e8e063a7609950e2b224e5ea0b39 +Subproject commit 378f95afeba573da867d8ff55851152b82468640 diff --git a/cb-tools/burgbox/mergeStructs.m b/cb-tools/burgbox/mergeStructs.m index ae9b4767..a7ee11d9 100644 --- a/cb-tools/burgbox/mergeStructs.m +++ b/cb-tools/burgbox/mergeStructs.m @@ -2,6 +2,12 @@ %MERGESTRUCTS Concatenates different structures into one structure array % s = MERGESTRUCTS(struct1, struct2,...) % +% If there are any repeated fields, the first instance of that field +% takes precedence. Therefore the order of the input structs affects the +% resulting merged struct. +% +% See also CATSTRUCTS +% % Part of Burgbox % 2013-11 CB created diff --git a/npy-matlab b/npy-matlab index 524bd143..a99e00f7 160000 --- a/npy-matlab +++ b/npy-matlab @@ -1 +1 @@ -Subproject commit 524bd143c34cbd9d1bbc895ed05e082ce4249b62 +Subproject commit a99e00f78c72a7ec5f9c3074242ffaf242de9448 From bc38aa131febae49bcbdbdd13d813c7696beb751 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 4 May 2018 13:45:14 +0100 Subject: [PATCH 129/507] Added function to turn MATLAB objects to JSON ones --- cb-tools/obj2json.m | 84 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 cb-tools/obj2json.m diff --git a/cb-tools/obj2json.m b/cb-tools/obj2json.m new file mode 100644 index 00000000..34b0bae0 --- /dev/null +++ b/cb-tools/obj2json.m @@ -0,0 +1,84 @@ +function s = obj2json(rig) +% OBJ2JSON Converts input into JSON +s = obj2struct(rig); +if verLessThan('matlab','9.1') + s = savejson('', s); +elseif verLessThan('matlab','9.3') + s = jsonencode(s); +else + s = jsonencode(s, 'ConvertInfAndNaN', true); +end +end + +function s = obj2struct(obj) +% OBJ2STRUCT Converts input object into a struct +% Returns the input but with any non-fundamental object converted to a +% structure. If the input does not contain an object, the resulting +% output will remain unchanged. +% +% NB: Does not convert National Instruments object or objects within +% non-scalar structures. Cannot currently deal with Java, COM or certain +% graphics objects. Function handles are converted to strings. +% +% 2018-05-03 MW created + +if isobject(obj) + if length(obj) > 1 + % If dealing with heterogeneous array of objects, recurse through array + s = arrayfun(@obj2struct, obj, 'uni', 0); + elseif isa(obj, 'containers.Map') + % Convert to scalar struct + keySet = keys(obj); + valueSet = values(obj); + for j = 1:length(keySet) + m.(keySet{j}) = valueSet{j}; + end + s = obj2struct(m); + else % Normal object + names = fieldnames(obj); % Get list of public properties + for i = 1:length(names) + if isobject(obj.(names{i})) % Property contains an object + if startsWith(class(obj.(names{i})),'daq.ni.') + % Do not attempt to save ni daq sessions of channels + s.(names{i}) = []; + else % Recurse + s.(names{i}) = obj2struct(obj.(names{i})); + end + elseif iscell(obj.(names{i})) + % If property contains cell array, run through each element in case + % any contain an object + s.(names{i}) = cellfun(@obj2struct, obj.(names{i}), 'uni', 0); + elseif isstruct(obj.(names{i})) && isscalar(obj.(names{i})) + % If property contains struct, run through each field in case any + % contain an object + s.(names{i}) = structfun(@obj2struct, obj.(names{i}), 'uni', 0); + elseif isa(obj.(names{i}), 'function_handle') + % Convert function to string + s.(names{i}) = func2str(obj.(names{i})); + elseif isa(obj.(names{i}), 'containers.Map') + % Convert to scalar struct + keySet = keys(obj.(names{i})); + valueSet = values(obj.(names{i})); + for j = 1:length(keySet) + m.(keySet{j}) = valueSet{j}; + end + s.(names{i}) = obj2struct(m); + else % Property is fundamental object + s.(names{i}) = obj.(names{i}); + end + end + s.ClassContructor = class(obj); % Supply class name for loading object + end +elseif iscell(obj) + % If dealing with cell array, recurse through elements + s = cellfun(@obj2struct, obj, 'uni', 0); +elseif isstruct(obj) && isscalar(obj) + % If dealing with structure, recurse through fields + s = structfun(@obj2struct, obj, 'uni', 0); +elseif isa(obj, 'function_handle') + % Convert function to string + s = func2str(obj); +else % Fundamental object, return unchanged + s = obj; +end +end \ No newline at end of file From 747629c870e9b2b0315b280e22a7bc5c816f9cfc Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 4 May 2018 13:46:57 +0100 Subject: [PATCH 130/507] Updated alyx-matlab to not use expInfo repo --- alyx-matlab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx-matlab b/alyx-matlab index 378f95af..c87c470d 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 378f95afeba573da867d8ff55851152b82468640 +Subproject commit c87c470de5b417885188e16855cbdfdcf105f13d From a27c33aeb488f58dabd8a4a7897e61802a51ac28 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 4 May 2018 20:07:21 +0100 Subject: [PATCH 131/507] Added git hash to the rig structure in hw.devices --- +hw/devices.m | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/+hw/devices.m b/+hw/devices.m index 2b3b914e..f0a8274e 100644 --- a/+hw/devices.m +++ b/+hw/devices.m @@ -34,6 +34,13 @@ end rig.useDaq = pick(rig, 'useDaq', 'def', true); +%% If Git is installed, determine hash of latest commit to code +[status, hash] = system(sprintf('git -C "%s" rev-parse HEAD',... + fileparts(which('addRigboxPaths')))); +if status == 0 + rig.GitHash = hash; +end + %% Configure common devices, if present configure('mouseInput'); configure('lickDetector'); From 1a92b22df00cc161cfa564cae07d9de1e7757d69 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 8 May 2018 12:18:13 +0100 Subject: [PATCH 132/507] Water no longer posted to Alyx if experiment is aborted --- +eui/ExpPanel.m | 5 +++-- +srv/StimulusControl.m | 4 ++-- +srv/expServer.m | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/+eui/ExpPanel.m b/+eui/ExpPanel.m index 7878f6da..9ff3310f 100644 --- a/+eui/ExpPanel.m +++ b/+eui/ExpPanel.m @@ -206,7 +206,7 @@ function expStarted(obj, rig, evt) end end - function expStopped(obj, rig, ~) + function expStopped(obj, rig, evt) % EXPSTOPPED Callback for the ExpStopped event. % expStopped(obj, rig, event) Updates the ExpRunning flag, the % panel title and status label to show that the experiment has @@ -222,8 +222,9 @@ function expStopped(obj, rig, ~) obj.Root.TitleColor = [1 0.3 0.22]; % red title area %post water to Alyx ai = rig.AlyxInstance; + aborted = evt.Data; % aborted experiment flag subject = obj.SubjectRef; - if ~isempty(ai)&&~strcmp(subject,'default') + if ~isempty(ai)&&~strcmp(subject,'default')&&~aborted switch class(obj) case 'eui.ChoiceExpPanel' if ~isfield(obj.Block.trial,'feedbackType'); return; end % No completed trials diff --git a/+srv/StimulusControl.m b/+srv/StimulusControl.m index 9e6f8114..7e1fde06 100644 --- a/+srv/StimulusControl.m +++ b/+srv/StimulusControl.m @@ -198,8 +198,8 @@ function onWSReceived(obj, ~, eventArgs) notify(obj, 'ExpStarting', srv.ExpEvent('starting', ref)); case 'completed' %experiment stopped without any exceptions - ref = data{2}; - notify(obj, 'ExpStopped', srv.ExpEvent('completed', ref)); + ref = data{2}; aborted = data{3}; + notify(obj, 'ExpStopped', srv.ExpEvent('completed', ref, aborted)); case 'expException' %experiment stopped with an exception ref = data{2}; err = data{3}; diff --git a/+srv/expServer.m b/+srv/expServer.m index 36aa8529..8fee4a84 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -203,7 +203,7 @@ function handleMessage(id, data, host) experiment.AlyxInstance = AlyxInstance; end experiment.quit(immediately); - send(communicator, id, []); + send(communicator, id, immediately); else log('Quit message received but no experiment is running\n'); end From aa55f766108131116a4b331c8267b6a2c15207c6 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 8 May 2018 12:46:45 +0100 Subject: [PATCH 133/507] Updated Alyx package to deal with server timeouts gracefully --- alyx-matlab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx-matlab b/alyx-matlab index c87c470d..ba6be6a2 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit c87c470de5b417885188e16855cbdfdcf105f13d +Subproject commit ba6be6a22d91207e8cb776b82426be3a81df476b From ec3a705fb4ff68b1be9d60d7fe90479890ed06db Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 8 May 2018 17:44:40 +0100 Subject: [PATCH 134/507] Fixed bug introduced by abort feature: now expServer correctly reports abort status to mc --- +eui/ExpPanel.m | 2 +- +srv/expServer.m | 11 ++++++----- alyx-matlab | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/+eui/ExpPanel.m b/+eui/ExpPanel.m index 9ff3310f..de272d64 100644 --- a/+eui/ExpPanel.m +++ b/+eui/ExpPanel.m @@ -393,7 +393,7 @@ function build(obj, parent) 'String', 'End'),... uicontrol('Parent', buttonpanel,... 'Style', 'pushbutton',... - 'String', 'Abort')]; + 'String', 'Abort (Doesn''t post water to Alyx)')]; set(obj.StopButtons, 'Enable', 'off', 'Visible', 'off'); uicontrol('Parent', buttonpanel,... 'Style', 'pushbutton',... diff --git a/+srv/expServer.m b/+srv/expServer.m index 8fee4a84..5ba62f5c 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -55,7 +55,7 @@ function expServer(useTimelineOverride, bgColour) @() PsychPortAudio('Verbosity', oldPpaVerbosity)... })); -HideCursor(); +% HideCursor(); if nargin < 2 bgColour = 127*[1 1 1]; % mid gray by default @@ -177,9 +177,9 @@ function handleMessage(id, data, host) communicator.send(id, []); try communicator.send('status', {'starting', expRef}); - runExp(expRef, preDelay, postDelay, Alyx); + aborted = runExp(expRef, preDelay, postDelay, Alyx); log('Experiment ''%s'' completed', expRef); - communicator.send('status', {'completed', expRef}); + communicator.send('status', {'completed', expRef, aborted}); catch runEx communicator.send('status', {'expException', expRef, runEx.message}); log('Exception during experiment ''%s'' because ''%s''', expRef, runEx.message); @@ -203,7 +203,7 @@ function handleMessage(id, data, host) experiment.AlyxInstance = AlyxInstance; end experiment.quit(immediately); - send(communicator, id, immediately); + send(communicator, id, []); else log('Quit message received but no experiment is running\n'); end @@ -217,7 +217,7 @@ function handleMessage(id, data, host) end end - function runExp(expRef, preDelay, postDelay, Alyx) + function aborted = runExp(expRef, preDelay, postDelay, Alyx) % disable ptb keyboard listening KbQueueRelease(); @@ -248,6 +248,7 @@ function runExp(expRef, preDelay, postDelay, Alyx) experiment.AlyxInstance = Alyx; % add Alyx Instance experiment.run(expRef); % run the experiment communicator.EventMode = false; % back to pull message mode + aborted = strcmp(experiment.Data.endStatus, 'aborted'); % clear the active experiment var experiment = []; rig.stimWindow.BackgroundColour = bgColour; diff --git a/alyx-matlab b/alyx-matlab index ba6be6a2..296cc0a8 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit ba6be6a22d91207e8cb776b82426be3a81df476b +Subproject commit 296cc0a8240d42e183bf62c10c42e729541ae653 From d07017a27712c1b5c095f87d43eab4b08dffdfc9 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 9 May 2018 13:09:16 +0100 Subject: [PATCH 135/507] Removed return charecter from Git hash --- +hw/devices.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+hw/devices.m b/+hw/devices.m index f0a8274e..2734fe92 100644 --- a/+hw/devices.m +++ b/+hw/devices.m @@ -38,7 +38,7 @@ [status, hash] = system(sprintf('git -C "%s" rev-parse HEAD',... fileparts(which('addRigboxPaths')))); if status == 0 - rig.GitHash = hash; + rig.GitHash = strtrim(hash); end %% Configure common devices, if present From cc2ddaaffe0a00c75c101e7d573e313c0cb68854 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 9 May 2018 15:38:28 +0100 Subject: [PATCH 136/507] Fixed the saving of JSON files in newExp expServer and Timeline: now json is written to file using fprintf --- +hw/Timeline.m | 5 +++-- +srv/expServer.m | 5 +++-- alyx-matlab | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index dca17a60..71dddeb5 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -456,8 +456,9 @@ function stop(obj) superSave(obj.Data.savePaths, struct('Timeline', obj.Data)); % write hardware info to a JSON file for compatibility with database - hw = jsonencode(obj.Data.hw); %#ok - save(fullfile(fileparts(obj.Data.savePaths{2}), 'TimelineHW.json'), 'hw', '-ascii'); + fid = fopen(fullfile(fileparts(obj.Data.savePaths{2}), 'TimelineHW.json')); + fprintf(fid, '%s', jsonencode(obj.Data.hw)); + fclose(fid); % save each recorded vector into the correct format in Timeline % timebase for Alyx and optionally into universal timebase if diff --git a/+srv/expServer.m b/+srv/expServer.m index 5ba62f5c..7697c919 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -255,9 +255,10 @@ function handleMessage(id, data, host) rig.stimWindow.flip(); % clear the screen after % save a copy of the hardware in JSON - jsonData = obj2json(rig); %#ok name = dat.expFilePath(expRef, 'hw-info', 'master'); - save([name(1:end-3) 'json'], 'jsonData', '-ascii'); + fid = fopen([name(1:end-3) 'json']); + fprintf(fid, '%s', obj2json(rig)); + fclose(fid); if rig.timeline.UseTimeline %stop the timeline system diff --git a/alyx-matlab b/alyx-matlab index 296cc0a8..d612fe08 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 296cc0a8240d42e183bf62c10c42e729541ae653 +Subproject commit d612fe083670f4680283bae2827dbe24c65df573 From 9bd2494f264d20c28b33ac26f2dff9979a2b03fb Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 9 May 2018 16:27:02 +0100 Subject: [PATCH 137/507] Added write flag for saving JSON to file. Also took obj2struct out of obj2json function so it can be used seperately by Timeline for saving outputs to a structure --- +hw/Timeline.m | 7 ++--- +srv/expServer.m | 2 +- alyx-matlab | 2 +- cb-tools/obj2json.m | 73 ------------------------------------------- cb-tools/obj2struct.m | 72 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 80 deletions(-) create mode 100644 cb-tools/obj2struct.m diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 71dddeb5..2e8a3b16 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -444,19 +444,16 @@ function stop(obj) obj.Data.currSysTimeTimelineOffset = CurrSysTimeTimelineOffset; % saving hardware metadata for each output - warning('off', 'MATLAB:structOnObject'); % sorry, don't care for outIdx = 1:numel(obj.Outputs) - s = struct(obj.Outputs(outIdx)); - s.Class = class(obj.Outputs(outIdx)); + s = obj2struct(obj.Outputs(outIdx)); obj.Data.hw.Outputs{outIdx} = s; end - warning('on', 'MATLAB:structOnObject'); % save tl to all paths superSave(obj.Data.savePaths, struct('Timeline', obj.Data)); % write hardware info to a JSON file for compatibility with database - fid = fopen(fullfile(fileparts(obj.Data.savePaths{2}), 'TimelineHW.json')); + fid = fopen(fullfile(fileparts(obj.Data.savePaths{2}), 'TimelineHW.json'), 'w'); fprintf(fid, '%s', jsonencode(obj.Data.hw)); fclose(fid); diff --git a/+srv/expServer.m b/+srv/expServer.m index 7697c919..e56b4dd9 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -256,7 +256,7 @@ function handleMessage(id, data, host) % save a copy of the hardware in JSON name = dat.expFilePath(expRef, 'hw-info', 'master'); - fid = fopen([name(1:end-3) 'json']); + fid = fopen([name(1:end-3) 'json'], 'w'); fprintf(fid, '%s', obj2json(rig)); fclose(fid); diff --git a/alyx-matlab b/alyx-matlab index d612fe08..81470615 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit d612fe083670f4680283bae2827dbe24c65df573 +Subproject commit 8147061553071352e992a09e68ec10f5d85cbd69 diff --git a/cb-tools/obj2json.m b/cb-tools/obj2json.m index 34b0bae0..100a2ab9 100644 --- a/cb-tools/obj2json.m +++ b/cb-tools/obj2json.m @@ -8,77 +8,4 @@ else s = jsonencode(s, 'ConvertInfAndNaN', true); end -end - -function s = obj2struct(obj) -% OBJ2STRUCT Converts input object into a struct -% Returns the input but with any non-fundamental object converted to a -% structure. If the input does not contain an object, the resulting -% output will remain unchanged. -% -% NB: Does not convert National Instruments object or objects within -% non-scalar structures. Cannot currently deal with Java, COM or certain -% graphics objects. Function handles are converted to strings. -% -% 2018-05-03 MW created - -if isobject(obj) - if length(obj) > 1 - % If dealing with heterogeneous array of objects, recurse through array - s = arrayfun(@obj2struct, obj, 'uni', 0); - elseif isa(obj, 'containers.Map') - % Convert to scalar struct - keySet = keys(obj); - valueSet = values(obj); - for j = 1:length(keySet) - m.(keySet{j}) = valueSet{j}; - end - s = obj2struct(m); - else % Normal object - names = fieldnames(obj); % Get list of public properties - for i = 1:length(names) - if isobject(obj.(names{i})) % Property contains an object - if startsWith(class(obj.(names{i})),'daq.ni.') - % Do not attempt to save ni daq sessions of channels - s.(names{i}) = []; - else % Recurse - s.(names{i}) = obj2struct(obj.(names{i})); - end - elseif iscell(obj.(names{i})) - % If property contains cell array, run through each element in case - % any contain an object - s.(names{i}) = cellfun(@obj2struct, obj.(names{i}), 'uni', 0); - elseif isstruct(obj.(names{i})) && isscalar(obj.(names{i})) - % If property contains struct, run through each field in case any - % contain an object - s.(names{i}) = structfun(@obj2struct, obj.(names{i}), 'uni', 0); - elseif isa(obj.(names{i}), 'function_handle') - % Convert function to string - s.(names{i}) = func2str(obj.(names{i})); - elseif isa(obj.(names{i}), 'containers.Map') - % Convert to scalar struct - keySet = keys(obj.(names{i})); - valueSet = values(obj.(names{i})); - for j = 1:length(keySet) - m.(keySet{j}) = valueSet{j}; - end - s.(names{i}) = obj2struct(m); - else % Property is fundamental object - s.(names{i}) = obj.(names{i}); - end - end - s.ClassContructor = class(obj); % Supply class name for loading object - end -elseif iscell(obj) - % If dealing with cell array, recurse through elements - s = cellfun(@obj2struct, obj, 'uni', 0); -elseif isstruct(obj) && isscalar(obj) - % If dealing with structure, recurse through fields - s = structfun(@obj2struct, obj, 'uni', 0); -elseif isa(obj, 'function_handle') - % Convert function to string - s = func2str(obj); -else % Fundamental object, return unchanged - s = obj; -end end \ No newline at end of file diff --git a/cb-tools/obj2struct.m b/cb-tools/obj2struct.m new file mode 100644 index 00000000..60e91eb6 --- /dev/null +++ b/cb-tools/obj2struct.m @@ -0,0 +1,72 @@ +function s = obj2struct(obj) +% OBJ2STRUCT Converts input object into a struct +% Returns the input but with any non-fundamental object converted to a +% structure. If the input does not contain an object, the resulting +% output will remain unchanged. +% +% NB: Does not convert National Instruments object or objects within +% non-scalar structures. Cannot currently deal with Java, COM or certain +% graphics objects. Function handles are converted to strings. +% +% 2018-05-03 MW created + +if isobject(obj) + if length(obj) > 1 + % If dealing with heterogeneous array of objects, recurse through array + s = arrayfun(@obj2struct, obj, 'uni', 0); + elseif isa(obj, 'containers.Map') + % Convert to scalar struct + keySet = keys(obj); + valueSet = values(obj); + for j = 1:length(keySet) + m.(keySet{j}) = valueSet{j}; + end + s = obj2struct(m); + else % Normal object + names = fieldnames(obj); % Get list of public properties + for i = 1:length(names) + if isobject(obj.(names{i})) % Property contains an object + if startsWith(class(obj.(names{i})),'daq.ni.') + % Do not attempt to save ni daq sessions of channels + s.(names{i}) = []; + else % Recurse + s.(names{i}) = obj2struct(obj.(names{i})); + end + elseif iscell(obj.(names{i})) + % If property contains cell array, run through each element in case + % any contain an object + s.(names{i}) = cellfun(@obj2struct, obj.(names{i}), 'uni', 0); + elseif isstruct(obj.(names{i})) && isscalar(obj.(names{i})) + % If property contains struct, run through each field in case any + % contain an object + s.(names{i}) = structfun(@obj2struct, obj.(names{i}), 'uni', 0); + elseif isa(obj.(names{i}), 'function_handle') + % Convert function to string + s.(names{i}) = func2str(obj.(names{i})); + elseif isa(obj.(names{i}), 'containers.Map') + % Convert to scalar struct + keySet = keys(obj.(names{i})); + valueSet = values(obj.(names{i})); + for j = 1:length(keySet) + m.(keySet{j}) = valueSet{j}; + end + s.(names{i}) = obj2struct(m); + else % Property is fundamental object + s.(names{i}) = obj.(names{i}); + end + end + s.ClassContructor = class(obj); % Supply class name for loading object + end +elseif iscell(obj) + % If dealing with cell array, recurse through elements + s = cellfun(@obj2struct, obj, 'uni', 0); +elseif isstruct(obj) && isscalar(obj) + % If dealing with structure, recurse through fields + s = structfun(@obj2struct, obj, 'uni', 0); +elseif isa(obj, 'function_handle') + % Convert function to string + s = func2str(obj); +else % Fundamental object, return unchanged + s = obj; +end +end \ No newline at end of file From e99159be13377d9a889409efa5202252c70bc954 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 9 May 2018 19:22:17 +0100 Subject: [PATCH 138/507] Added tooltip strings to end and abort buttons for clarity and removed some redundant code --- +eui/ExpPanel.m | 6 ++++-- +eui/ParamEditor.m | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/+eui/ExpPanel.m b/+eui/ExpPanel.m index de272d64..e07ceb6b 100644 --- a/+eui/ExpPanel.m +++ b/+eui/ExpPanel.m @@ -390,10 +390,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 (Doesn''t post water to Alyx)')]; + '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/ParamEditor.m b/+eui/ParamEditor.m index a513ad6a..e29749f9 100644 --- a/+eui/ParamEditor.m +++ b/+eui/ParamEditor.m @@ -107,7 +107,7 @@ function build(obj, parent) % Build parameters panel obj.SetValuesButton = uicontrol('Parent', conditionButtonBox,... 'Style', 'pushbutton',... 'String', 'Set values',... - 'TooltipString', sprintf('Set selected values to specified value, range or function'),... + 'TooltipString', 'Set selected values to specified value, range or function',... 'Enable', 'off',... 'Callback', @(~, ~) obj.setSelectedValues()); From 8975458df92f1b1252ef8c4125aae3bd4d425e99 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 10 May 2018 11:54:03 +0100 Subject: [PATCH 139/507] HideCurser now back --- +srv/expServer.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+srv/expServer.m b/+srv/expServer.m index e56b4dd9..7eea2ccc 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -55,7 +55,7 @@ function expServer(useTimelineOverride, bgColour) @() PsychPortAudio('Verbosity', oldPpaVerbosity)... })); -% HideCursor(); +HideCursor(); if nargin < 2 bgColour = 127*[1 1 1]; % mid gray by default From 55e925d6fcd389e946a25a0541242144aa441946 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 10 May 2018 23:03:24 +0100 Subject: [PATCH 140/507] Alyx object paths now set by Rigbox dat.paths function. Fix for missing star in mc weight plot --- +dat/paths.m | 2 ++ +eui/AlyxPanel.m | 3 ++- alyx-matlab | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/+dat/paths.m b/+dat/paths.m index 1dee5d35..72a1f2ea 100644 --- a/+dat/paths.m +++ b/+dat/paths.m @@ -23,6 +23,8 @@ p.rigbox = fileparts(which('addRigboxPaths')); % Repository for local copy of everything generated on this rig p.localRepository = 'C:\LocalExpData'; +p.localAlyxQueue = 'C:\localAlyxQueue'; +p.databaseURL = 'https://alyx.cortexlab.net'; % Under the new system of having data grouped by mouse % rather than data type, all experimental data are saved here. diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index e8f5fd28..719c58d2 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -537,7 +537,8 @@ function viewSubjectHistory(obj, ax) 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'); - if numel(dates) > 1; xlim(ax, [min(dates) max(dates)]); end + % Change the plot x axis limits + if numel(dates) > 1; xlim(ax, [min(dates) max(now)]); end if nargin == 1 set(ax, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false)) else diff --git a/alyx-matlab b/alyx-matlab index 81470615..f418ea8e 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 8147061553071352e992a09e68ec10f5d85cbd69 +Subproject commit f418ea8ed43ca7da4e26704c05e1f3d6ef83692e From 31d9ea7bfcfe672743b6297740921a5c1449231a Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 11 May 2018 22:09:29 +0100 Subject: [PATCH 141/507] Ensure Alyx always headless when passed to expServer --- +srv/expServer.m | 2 ++ 1 file changed, 2 insertions(+) diff --git a/+srv/expServer.m b/+srv/expServer.m index 7eea2ccc..bfa4b45e 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -194,6 +194,7 @@ function handleMessage(id, data, host) if ~isempty(experiment) immediately = args{1}; AlyxInstance = args{2}; + AlyxInstance.Headless = true; if immediately log('Aborting experiment'); else @@ -209,6 +210,7 @@ function handleMessage(id, data, host) end case 'updateAlyxInstance' %recieved new Alyx Instance from Stimulus Control AlyxInstance = args{1}; %get struct + AlyxInstance.Headless = true; if ~isempty(AlyxInstance) experiment.AlyxInstance = AlyxInstance; %set property for current experiment end From 10a49eaf551400e6a5b15720546550103ac4a881 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 11 May 2018 22:24:26 +0100 Subject: [PATCH 142/507] updated submodule to deal with paths not being specified --- alyx-matlab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx-matlab b/alyx-matlab index f418ea8e..1c21cd7a 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit f418ea8ed43ca7da4e26704c05e1f3d6ef83692e +Subproject commit 1c21cd7a91f47ca880e8c813a4efd9c26759f771 From 55b74d265219f05fed87e3d364ed4247e9f52899 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 14 May 2018 15:14:38 +0100 Subject: [PATCH 143/507] Fixed XTick labels in the log and now xlim depends on whether there is a new reading or not --- +eui/AlyxPanel.m | 9 +++++---- +eui/MControl.m | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 719c58d2..00bd41d5 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -511,15 +511,15 @@ function viewSubjectHistory(obj, ax) % 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 = obj.AlyxInstance.getData(endpnt); - iw = wr.implant_weight; + iw = iff(isempty(wr.implant_weight), 0, wr.implant_weight); records = catStructs(wr.records, nan); - expected = [records.weight_expected]; - expected(expected==0) = nan; % no weighings found if isempty(wr.records) obj.log('No weight data found for subject %s', obj.Subject); return end + expected = [records.weight_expected]; + expected(expected==0) = nan; dates = cellfun(@(x)datenum(x), {records.date}); % build the figure to show it @@ -538,10 +538,11 @@ function viewSubjectHistory(obj, ax) 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 - if numel(dates) > 1; xlim(ax, [min(dates) max(now)]); end + if numel(dates) > 1; xlim(ax, [min(dates) max(dates)]); end if nargin == 1 set(ax, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false)) else + xticks(ax, 'auto') ax.XTickLabel = arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false); end ylabel(ax, 'weight (g)'); diff --git a/+eui/MControl.m b/+eui/MControl.m index 8635f7b4..c3f3baa1 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -617,6 +617,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 From f842c94190b2e9376f59c68c3036ed01a98ee21f Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 15 May 2018 11:51:59 +0100 Subject: [PATCH 144/507] Fix in old choiceworld for rigs with more than 2 audio output channels --- cortexlab/+exp/configureChoiceExperiment.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexlab/+exp/configureChoiceExperiment.m b/cortexlab/+exp/configureChoiceExperiment.m index 446573b2..0f4dc57a 100644 --- a/cortexlab/+exp/configureChoiceExperiment.m +++ b/cortexlab/+exp/configureChoiceExperiment.m @@ -34,7 +34,7 @@ toneMaxAmp = params.Struct.onsetToneMaxAmp; rampLen = 0.01; %secs - length of amplitude ramp up/down toneSamples = toneMaxAmp*aud.pureTone(toneFreq, toneLen, audSampleRate, rampLen); -toneSamples = repmat(toneSamples, 2, 1); % replicate across two channels/stereo +toneSamples = repmat(toneSamples, dev.NrOutputChannels, 1); % replicate across channels/stereo params.set('onsetToneSamples', {toneSamples},... sprintf('The data samples for the onset tone, sampled at %iHz', audSampleRate), 'normalised'); From 621d6e1a20d6b506fbc2d1e34dbf906a30bd0190 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 16 May 2018 16:29:41 +0100 Subject: [PATCH 145/507] Updates paths file: now all config and expDefs found on zserver --- +dat/paths.m | 5 +++-- alyx-matlab | 2 +- signals | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/+dat/paths.m b/+dat/paths.m index 72a1f2ea..63c5769f 100644 --- a/+dat/paths.m +++ b/+dat/paths.m @@ -14,6 +14,7 @@ end server1Name = '\\zubjects.cortexlab.net'; +server2Name = '\\zserver.cortexlab.net'; basketName = '\\basket.cortexlab.net'; % for working analyses lugaroName = '\\lugaro.cortexlab.net'; % for tape backup @@ -33,11 +34,11 @@ % 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'); +p.globalConfig = fullfile(server2Name, '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'); +p.expDefinitions = fullfile(server2Name, 'Code', 'Rigging', 'ExpDefinitions'); % repository for working analyses that are not meant to be stored % permanently diff --git a/alyx-matlab b/alyx-matlab index 1c21cd7a..f418ea8e 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 1c21cd7a91f47ca880e8c813a4efd9c26759f771 +Subproject commit f418ea8ed43ca7da4e26704c05e1f3d6ef83692e diff --git a/signals b/signals index c879c89d..e200ede5 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit c879c89d9b267370b2c6cb061e8c8afaf48f4474 +Subproject commit e200ede5817d0c72949de71e49329d6ff2ac4bd1 From 841a72223e035319e32154739f10cb8bb34f26d3 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 17 May 2018 11:43:25 +0100 Subject: [PATCH 146/507] Update to Alyx: doesn't crash is dat.paths doesn't provice the relevant paths --- alyx-matlab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx-matlab b/alyx-matlab index 1c21cd7a..b66e4c6f 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 1c21cd7a91f47ca880e8c813a4efd9c26759f771 +Subproject commit b66e4c6f9918a0332489010f049c9e0aa8a4e48a From 1f21c81f954e87b38ff1d82eb9999354b59b20d1 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 17 May 2018 18:10:31 +0100 Subject: [PATCH 147/507] Contrast values no longer saved in % when exported to ALF --- +exp/SignalsExp.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 4a019e22..8b998414 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -904,8 +904,8 @@ function saveData(obj) contL = getOr(obj.Data.events, 'contrastLeftValues', NaN); contR = getOr(obj.Data.events, 'contrastRightValues', NaN); if ~any(isnan(contL))&&~any(isnan(contR)) - writeNPY(contL(:)*100, fullfile(expPath, 'cwStimOn.contrastLeft.npy')); - writeNPY(contR(:)*100, fullfile(expPath, 'cwStimOn.contrastRight.npy')); + writeNPY(contL(:), fullfile(expPath, 'cwStimOn.contrastLeft.npy')); + writeNPY(contR(:), fullfile(expPath, 'cwStimOn.contrastRight.npy')); else warning('No ''contrastLeft'' and/or ''contrastRight'' events recorded, cannot register to Alyx') end From dbdd7a5be34ae2fc761d134ba00bb1c6a3388a38 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 18 May 2018 11:15:01 +0100 Subject: [PATCH 148/507] Fixed 80% expected weight values in viewSubjectHistory table --- +eui/AlyxPanel.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 00bd41d5..a06c9353 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -584,7 +584,7 @@ function viewSubjectHistory(obj, ax) dat = horzcat(... arrayfun(@(x)datestr(x), dates', 'uni', false), ... weightsByDate', ... - arrayfun(@(x)sprintf('%.1f', 0.8*(x-iw)), [records.weight_expected]', 'uni', false), ... + arrayfun(@(x)sprintf('%.1f', 0.8*(x-iw)+iw), [records.weight_expected]', 'uni', false), ... weightPctByDate'); waterDat = (... num2cell(horzcat([records.water_given]', [records.hydrogel_given]', ... From eabb88437d5008318a8649e402a4e02c2f5efc03 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 18 May 2018 18:01:48 +0100 Subject: [PATCH 149/507] Rig hardware infor now registered to Alyx --- +srv/expServer.m | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/+srv/expServer.m b/+srv/expServer.m index bfa4b45e..c0706087 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -261,6 +261,13 @@ function handleMessage(id, data, host) fid = fopen([name(1:end-3) 'json'], 'w'); fprintf(fid, '%s', obj2json(rig)); fclose(fid); + if ~strcmp(dat.parseExpRef(expRef), 'default') + try + Alyx.registerFile([name(1:end-3) 'json']); + catch ex + warning(ex.identifier, 'Failed to register hardware info: %s', ex.message); + end + end if rig.timeline.UseTimeline %stop the timeline system From 6ebf03f4b1fd6a081a06066e7a0b6626b526ca13 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 25 May 2018 17:33:12 +0100 Subject: [PATCH 150/507] AlyxPanel weight button can now records current scales reading --- +eui/AlyxPanel.m | 33 ++++++++++++++++++++++++++++++--- +eui/MControl.m | 5 ++++- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 00bd41d5..06ef5885 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -43,11 +43,13 @@ 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 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 @@ -134,7 +136,7 @@ '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',... @@ -206,6 +208,11 @@ function delete(obj) delete(obj.LoginTimer) % ... delete it... obj.LoginTimer = []; % ... and remove it end + if ~isempty(obj.WeightTimer) % If there is a timer object + stop(obj.WeightTimer) % Stop the timer... + delete(obj.WeightTimer) % ... delete it... + obj.WeightTimer = []; % ... and remove it + end end function login(obj) @@ -225,7 +232,7 @@ function login(obj) % minutes of 'inactivity' (defined as not calling % dispWaterReq) obj.LoginTimer = timer('StartDelay', 30*60, 'TimerFcn',... - @(~,~)obj.login, 'BusyMode', 'queue'); + @(~,~)obj.login, 'BusyMode', 'queue', 'Name', 'Login Timer'); start(obj.LoginTimer) % Enable all buttons set(findall(obj.RootContainer, '-property', 'Enable'), 'Enable', 'on'); @@ -584,7 +591,7 @@ function viewSubjectHistory(obj, ax) dat = horzcat(... arrayfun(@(x)datestr(x), dates', 'uni', false), ... weightsByDate', ... - arrayfun(@(x)sprintf('%.1f', 0.8*(x-iw)), [records.weight_expected]', 'uni', false), ... + arrayfun(@(x)sprintf('%.1f', 0.8*(x-iw)+iw), [records.weight_expected]', 'uni', false), ... weightPctByDate'); waterDat = (... num2cell(horzcat([records.water_given]', [records.hydrogel_given]', ... @@ -627,6 +634,26 @@ function viewAllSubjects(obj) 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 + function log(obj, varargin) % Function for displaying timestamped information about % occurrences. If the LoggingDisplay property is unset, the diff --git a/+eui/MControl.m b/+eui/MControl.m index c3f3baa1..9e5a30ca 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -90,8 +90,11 @@ if isfield(rig, 'scale') && ~isempty(rig.scale) obj.WeighingScale = fieldOrDefault(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'); From 9020166a5e2f025751d016315c3553ca6c55dbd5 Mon Sep 17 00:00:00 2001 From: nsteinme Date: Sun, 27 May 2018 13:46:35 +0100 Subject: [PATCH 151/507] change by miles? --- +eui/ExpPanel.m | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/+eui/ExpPanel.m b/+eui/ExpPanel.m index 9ff3310f..7878f6da 100644 --- a/+eui/ExpPanel.m +++ b/+eui/ExpPanel.m @@ -206,7 +206,7 @@ function expStarted(obj, rig, evt) end end - function expStopped(obj, rig, evt) + function expStopped(obj, rig, ~) % EXPSTOPPED Callback for the ExpStopped event. % expStopped(obj, rig, event) Updates the ExpRunning flag, the % panel title and status label to show that the experiment has @@ -222,9 +222,8 @@ function expStopped(obj, rig, evt) obj.Root.TitleColor = [1 0.3 0.22]; % red title area %post water to Alyx ai = rig.AlyxInstance; - aborted = evt.Data; % aborted experiment flag subject = obj.SubjectRef; - if ~isempty(ai)&&~strcmp(subject,'default')&&~aborted + if ~isempty(ai)&&~strcmp(subject,'default') switch class(obj) case 'eui.ChoiceExpPanel' if ~isfield(obj.Block.trial,'feedbackType'); return; end % No completed trials From a8ee0e49408578af6b38b36e5e283d7ca8304f12 Mon Sep 17 00:00:00 2001 From: nsteinme Date: Sun, 27 May 2018 13:47:10 +0100 Subject: [PATCH 152/507] update vis.checkers, exp.inferParameters --- +exp/inferParameters.m | 19 ++++- +srv/StimulusControl.m | 4 +- alyx-matlab | 2 +- cortexlab/+vis/checker6.m | 2 +- cortexlab/+vis/checkerLeft.m | 126 ++++++++++++++++++++++++++++++++++ cortexlab/+vis/checkerRight.m | 126 ++++++++++++++++++++++++++++++++++ 6 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 cortexlab/+vis/checkerLeft.m create mode 100644 cortexlab/+vis/checkerRight.m diff --git a/+exp/inferParameters.m b/+exp/inferParameters.m index 6175fd3f..9bb61a08 100644 --- a/+exp/inferParameters.m +++ b/+exp/inferParameters.m @@ -5,11 +5,20 @@ % create some signals just to pass to the definition function and track % which parameter names are used +% if ischar(expdef) && file.exists(expdef) +% expdeffun = fileFunction(expdef); +% else +% expdeffun = expdef; +% expdef = which(func2str(expdef)); +% end if ischar(expdef) && file.exists(expdef) expdeffun = fileFunction(expdef); + [funcDir, mfile] = fileparts(expdef); addpath(funcDir); + funArgs = nargin(str2func(mfile)); else expdeffun = expdef; expdef = which(func2str(expdef)); + funArgs = nargin(expdeffun); end net = sig.Net; @@ -24,7 +33,15 @@ e.outputs = net.subscriptableOrigin('outputs'); try - expdeffun(e.t, e.events, e.pars, e.visual, e.inputs , e.outputs, e.audio); + + rig = 0; + if funArgs == 7 + expdeffun(e.t, e.events, e.pars, e.visual, e.inputs, e.outputs, e.audio); + else + expdeffun(e.t, e.events, e.pars, e.visual, e.inputs, e.outputs, e.audio, rig); + end + +% expdeffun(e.t, e.events, e.pars, e.visual, e.inputs , e.outputs, e.audio); % paramNames will be the strings corresponding to the fields of e.pars % that the user tried to reference in her expdeffun. paramNames = e.pars.Subscripts.keys'; diff --git a/+srv/StimulusControl.m b/+srv/StimulusControl.m index 7e1fde06..9e6f8114 100644 --- a/+srv/StimulusControl.m +++ b/+srv/StimulusControl.m @@ -198,8 +198,8 @@ function onWSReceived(obj, ~, eventArgs) notify(obj, 'ExpStarting', srv.ExpEvent('starting', ref)); case 'completed' %experiment stopped without any exceptions - ref = data{2}; aborted = data{3}; - notify(obj, 'ExpStopped', srv.ExpEvent('completed', ref, aborted)); + ref = data{2}; + notify(obj, 'ExpStopped', srv.ExpEvent('completed', ref)); case 'expException' %experiment stopped with an exception ref = data{2}; err = data{3}; diff --git a/alyx-matlab b/alyx-matlab index b66e4c6f..46a26d60 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit b66e4c6f9918a0332489010f049c9e0aa8a4e48a +Subproject commit 46a26d603df85dcde04404ede31ea9f51110cc33 diff --git a/cortexlab/+vis/checker6.m b/cortexlab/+vis/checker6.m index 4901b88c..1733d8f7 100644 --- a/cortexlab/+vis/checker6.m +++ b/cortexlab/+vis/checker6.m @@ -1,4 +1,4 @@ -function elem = checker3(t) +function elem = checker6(t) %vis.checker A grid of rectangles % Detailed explanation goes here diff --git a/cortexlab/+vis/checkerLeft.m b/cortexlab/+vis/checkerLeft.m new file mode 100644 index 00000000..cf1122d7 --- /dev/null +++ b/cortexlab/+vis/checkerLeft.m @@ -0,0 +1,126 @@ +function elem = checkerLeft(t) +%vis.checker A grid of rectangles +% Detailed explanation goes here + +elem = t.Node.Net.subscriptableOrigin('checker'); + +%% make initial layers to be used as templates +maskTemplate = vis.emptyLayer(); +maskTemplate.isPeriodic = false; +maskTemplate.interpolation = 'nearest'; +maskTemplate.show = true; +maskTemplate.colourMask = [false false false true]; + +maskTemplate.textureId = 'checkerMaskPixel'; +[maskTemplate.rgba, maskTemplate.rgbaSize] = vis.rgba(0, 0); +maskTemplate.blending = '1-source'; % allows us to lay down our zero alpha value + +stencilTemplate = maskTemplate; +stencilTemplate.textureId = 'checkerStencilPixel'; +[stencilTemplate.rgba, stencilTemplate.rgbaSize] = vis.rgba(1, 1); +stencilTemplate.blending = 'none'; + +% pattern layer uses the alpha values laid down by mask layers +patternLayer = vis.emptyLayer(); +patternLayer.textureId = sprintf('~checker%i', randi(2^32)); +patternLayer.isPeriodic = false; +patternLayer.interpolation = 'nearest'; +patternLayer.blending = 'destination'; % use the alpha mask gets laid down before this + +%% construct signals used to assemble layers +% N rows by cols signal is derived from the size of the pattern array but +% we skip repeats so that pattern changes don't update the mask layers +% unless the size has acutally changed +nRowsByCols = elem.pattern.flatten().map(@size).skipRepeats(); +aziRange = elem.azimuthRange.flatten(); +altRange = elem.altitudeRange.flatten(); +sizeFrac = elem.rectSizeFrac.flatten(); +% signal containing the masking layers +gridMaskLayers = mapn(nRowsByCols, aziRange, altRange, sizeFrac, ... + maskTemplate, stencilTemplate, @gridMask); +% signal contain the checker layer +checkerLayer = scan(elem.pattern.flatten(), @updatePattern,... + elem.colour.flatten(), @updateColour,... + elem.azimuthRange.flatten(), @updateAzi,... + elem.altitudeRange.flatten(), @updateAlt,... + elem.show.flatten(), @updateShow,... + patternLayer); % initial value +%% set default attribute values +elem.layers = [gridMaskLayers checkerLayer]; +elem.azimuthRange = [-135 0]; +elem.altitudeRange = [-37.5 37.5]; +elem.rectSizeFrac = [1 1]; % horizontal and vertical size of each rectangle +elem.pattern = [ + 1 -1 1 -1 + -1 0 0 0 + 1 0 0 0 + -1 1 -1 1]; + elem.show = true; +end + +%% helper functions +function layer = updatePattern(layer, pattern) +% map pattern from -1 -> 1 range to 0->255, cast to 8 bit integers, then +% convert to RGBA texture format. +[layer.rgba, layer.rgbaSize] = vis.rgbaFromUint8(uint8(127.5*(1 + pattern)), 1); +end + +function layer = updateColour(layer, colour) +layer.maxColour = [colour 1]; +end + +function layer = updateAzi(layer, aziRange) +layer.size(1) = abs(diff(aziRange)); +layer.texOffset(1) = mean(aziRange); +end + +function layer = updateAlt(layer, altRange) +layer.size(2) = abs(diff(altRange)); +layer.texOffset(2) = mean(altRange); +end + +function layer = updateShow(layer, show) +layer.show = show; +end + +function layers = gridMask(nRowsByCols, aziRange, altRange, sizeFrac, mask, stencil) +gridDims = [abs(diff(aziRange)) abs(diff(altRange))]; +cellSize = gridDims./flip(nRowsByCols); +nCols = nRowsByCols(2) + 1; +nRows = nRowsByCols(1) + 1; +midAzi = mean(aziRange); +midAlt = mean(altRange); +%% base layer to imprint area the checker can draw on (by applying an alpha mask) +stencil.texOffset = [midAzi midAlt]; +stencil.size = gridDims; +if any(sizeFrac < 1) + %% layers for lines making up mask grid - masks out margins around each square + % make layers for vertical lines + if nCols > 1 + azi = linspace(aziRange(1), aziRange(2), nCols); + else + azi = midAzi; + end + collayers = repmat(mask, 1, nCols); + for vi = 1:nCols + collayers(vi).texOffset = [azi(vi) midAlt]; + end + [collayers.size] = deal([(1 - sizeFrac(1))*cellSize(1) gridDims(2)]); + % make layers for horizontal lines + if nRows > 1 + alt = linspace(altRange(1), altRange(2), nRows); + else + alt = midAlt; + end + rowlayers = repmat(mask, 1, nRows); + for hi = 1:nRows + rowlayers(hi).texOffset = [midAzi alt(hi)]; + end + [rowlayers.size] = deal([gridDims(1) (1 - sizeFrac(2))*cellSize(2)]); + %% combine the layers and return + layers = [stencil collayers rowlayers]; +else % no mask grid needed as each cell is full size + layers = stencil; +end + +end \ No newline at end of file diff --git a/cortexlab/+vis/checkerRight.m b/cortexlab/+vis/checkerRight.m new file mode 100644 index 00000000..78b03545 --- /dev/null +++ b/cortexlab/+vis/checkerRight.m @@ -0,0 +1,126 @@ +function elem = checkerRight(t) +%vis.checker A grid of rectangles +% Detailed explanation goes here + +elem = t.Node.Net.subscriptableOrigin('checker'); + +%% make initial layers to be used as templates +maskTemplate = vis.emptyLayer(); +maskTemplate.isPeriodic = false; +maskTemplate.interpolation = 'nearest'; +maskTemplate.show = true; +maskTemplate.colourMask = [false false false true]; + +maskTemplate.textureId = 'checkerMaskPixel'; +[maskTemplate.rgba, maskTemplate.rgbaSize] = vis.rgba(0, 0); +maskTemplate.blending = '1-source'; % allows us to lay down our zero alpha value + +stencilTemplate = maskTemplate; +stencilTemplate.textureId = 'checkerStencilPixel'; +[stencilTemplate.rgba, stencilTemplate.rgbaSize] = vis.rgba(1, 1); +stencilTemplate.blending = 'none'; + +% pattern layer uses the alpha values laid down by mask layers +patternLayer = vis.emptyLayer(); +patternLayer.textureId = sprintf('~checker%i', randi(2^32)); +patternLayer.isPeriodic = false; +patternLayer.interpolation = 'nearest'; +patternLayer.blending = 'destination'; % use the alpha mask gets laid down before this + +%% construct signals used to assemble layers +% N rows by cols signal is derived from the size of the pattern array but +% we skip repeats so that pattern changes don't update the mask layers +% unless the size has acutally changed +nRowsByCols = elem.pattern.flatten().map(@size).skipRepeats(); +aziRange = elem.azimuthRange.flatten(); +altRange = elem.altitudeRange.flatten(); +sizeFrac = elem.rectSizeFrac.flatten(); +% signal containing the masking layers +gridMaskLayers = mapn(nRowsByCols, aziRange, altRange, sizeFrac, ... + maskTemplate, stencilTemplate, @gridMask); +% signal contain the checker layer +checkerLayer = scan(elem.pattern.flatten(), @updatePattern,... + elem.colour.flatten(), @updateColour,... + elem.azimuthRange.flatten(), @updateAzi,... + elem.altitudeRange.flatten(), @updateAlt,... + elem.show.flatten(), @updateShow,... + patternLayer); % initial value +%% set default attribute values +elem.layers = [gridMaskLayers checkerLayer]; +elem.azimuthRange = [0 135]; +elem.altitudeRange = [-37.5 37.5]; +elem.rectSizeFrac = [1 1]; % horizontal and vertical size of each rectangle +elem.pattern = [ + 1 -1 1 -1 + -1 0 0 0 + 1 0 0 0 + -1 1 -1 1]; + elem.show = true; +end + +%% helper functions +function layer = updatePattern(layer, pattern) +% map pattern from -1 -> 1 range to 0->255, cast to 8 bit integers, then +% convert to RGBA texture format. +[layer.rgba, layer.rgbaSize] = vis.rgbaFromUint8(uint8(127.5*(1 + pattern)), 1); +end + +function layer = updateColour(layer, colour) +layer.maxColour = [colour 1]; +end + +function layer = updateAzi(layer, aziRange) +layer.size(1) = abs(diff(aziRange)); +layer.texOffset(1) = mean(aziRange); +end + +function layer = updateAlt(layer, altRange) +layer.size(2) = abs(diff(altRange)); +layer.texOffset(2) = mean(altRange); +end + +function layer = updateShow(layer, show) +layer.show = show; +end + +function layers = gridMask(nRowsByCols, aziRange, altRange, sizeFrac, mask, stencil) +gridDims = [abs(diff(aziRange)) abs(diff(altRange))]; +cellSize = gridDims./flip(nRowsByCols); +nCols = nRowsByCols(2) + 1; +nRows = nRowsByCols(1) + 1; +midAzi = mean(aziRange); +midAlt = mean(altRange); +%% base layer to imprint area the checker can draw on (by applying an alpha mask) +stencil.texOffset = [midAzi midAlt]; +stencil.size = gridDims; +if any(sizeFrac < 1) + %% layers for lines making up mask grid - masks out margins around each square + % make layers for vertical lines + if nCols > 1 + azi = linspace(aziRange(1), aziRange(2), nCols); + else + azi = midAzi; + end + collayers = repmat(mask, 1, nCols); + for vi = 1:nCols + collayers(vi).texOffset = [azi(vi) midAlt]; + end + [collayers.size] = deal([(1 - sizeFrac(1))*cellSize(1) gridDims(2)]); + % make layers for horizontal lines + if nRows > 1 + alt = linspace(altRange(1), altRange(2), nRows); + else + alt = midAlt; + end + rowlayers = repmat(mask, 1, nRows); + for hi = 1:nRows + rowlayers(hi).texOffset = [midAzi alt(hi)]; + end + [rowlayers.size] = deal([gridDims(1) (1 - sizeFrac(2))*cellSize(2)]); + %% combine the layers and return + layers = [stencil collayers rowlayers]; +else % no mask grid needed as each cell is full size + layers = stencil; +end + +end \ No newline at end of file From 16d6293b4b4d2958f1f30e6cf71eeff409b666c2 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 30 May 2018 16:22:37 +0100 Subject: [PATCH 153/507] Added string compatibility in param editor --- +eui/ParamEditor.m | 15 +++++++++------ +exp/Parameters.m | 6 +++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/+eui/ParamEditor.m b/+eui/ParamEditor.m index e29749f9..f1826858 100644 --- a/+eui/ParamEditor.m +++ b/+eui/ParamEditor.m @@ -394,10 +394,12 @@ function updateGlobal(obj, param, src) end function data = controlValue2Param(obj, currParam, data, allowTypeChange) + % Convert the values displayed in the UI ('control values') to + % parameter values. String representations of numrical arrays and + % functions are converted back to their 'native' classes. if nargin < 4 allowTypeChange = false; end - % convert from control value to parameter value switch class(currParam) case 'function_handle' data = str2func(data); @@ -432,13 +434,17 @@ function updateGlobal(obj, param, src) end function data = paramValue2Control(obj, data) - % convert from parameter value to control value + % convert from parameter value to control value, i.e. a value class + % that can be easily displayed and edited by the user. Everything + % except logicals are converted to charecter arrays. switch class(data) case 'function_handle' % convert a function handle to it's string name data = func2str(data); case 'logical' data = data ~= 0; % If logical do nothing, basically. + case 'string' + data = char(data); % Strings not allowed in condition table data otherwise if isnumeric(data) % format numeric types as string number list @@ -448,7 +454,7 @@ function updateGlobal(obj, param, src) data = strJoin(data, ', '); end end - % all other data types stay as they are, including e.g. strings + % all other data types stay as they are end function fillConditionTable(obj) @@ -482,9 +488,6 @@ function makeTrialSpecific(obj, paramName, ctrls) title = obj.Parameters.title(name); description = obj.Parameters.description(name); -% if isnumeric(value) % Why? All this would do is convert logical values to char; everything else dealt with by paramValue2Control. MW 2017-02-15 -% value = num2str(value); -% end if islogical(value) % If parameter is logical, make checkbox ctrl = uicontrol('Parent', parent,... 'Style', 'checkbox',... diff --git a/+exp/Parameters.m b/+exp/Parameters.m index 03c544f6..4fa2d9ce 100644 --- a/+exp/Parameters.m +++ b/+exp/Parameters.m @@ -71,9 +71,9 @@ function set(obj, name, value, description, units) n = numel(obj.pNames); obj.IsTrialSpecific = struct; isTrialSpecificDefault = @(n) ... - ~any(strcmp(n, {'defFunction', 'expPanelFun'})) &&... - (strcmp(n, {'numRepeats'})... - || size(obj.pStruct.(n), 2) > 1); + strcmp(n, 'numRepeats') ||... % numRepeats always trail specific + (ischar(obj.pStruct.(n)) && size(obj.pStruct.(n), 1) > 1) ||... % Number of rows > 1 for chars + (~ischar(obj.pStruct.(n)) && size(obj.pStruct.(n), 2) > 1); % Number of columns > 1 for all others for i = 1:n name = obj.pNames{i}; obj.IsTrialSpecific.(name) = isTrialSpecificDefault(name); From 5d926cdafdfcb54cd74c77e152d158d3d837a90c Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 28 Jun 2018 11:43:40 +0100 Subject: [PATCH 154/507] Updated DiscWorld - fix'd error where flips occured when window wasn't ready --- alyx-matlab | 2 +- cortexlab/+exp/DiscWorld.m | 2 ++ signals | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/alyx-matlab b/alyx-matlab index 46a26d60..b66e4c6f 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 46a26d603df85dcde04404ede31ea9f51110cc33 +Subproject commit b66e4c6f9918a0332489010f049c9e0aa8a4e48a diff --git a/cortexlab/+exp/DiscWorld.m b/cortexlab/+exp/DiscWorld.m index 0279df71..ac164a86 100644 --- a/cortexlab/+exp/DiscWorld.m +++ b/cortexlab/+exp/DiscWorld.m @@ -71,6 +71,8 @@ function prepareStim(obj) % are wavelengths (per grating cycle) in pixels. pxWavelength = wavelen*pxPerRad; + ensureWindowReady(obj); %ensure graphics window is ready for ops + % delete any previous textures deleteTextures(obj.StimWindow); diff --git a/signals b/signals index e200ede5..6fbd1855 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit e200ede5817d0c72949de71e49329d6ff2ac4bd1 +Subproject commit 6fbd18551b63da12cf9b4fbeab5bccfaad70eba6 From 16298b5447deb23f71072c8af5d6607ae61dc67d Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 28 Jun 2018 16:17:39 +0100 Subject: [PATCH 155/507] Updated Alyx and removed some unessesary code from AlyxPanel delete method --- +eui/AlyxPanel.m | 5 ----- alyx-matlab | 2 +- signals | 2 +- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 06ef5885..a881ff2a 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -208,11 +208,6 @@ function delete(obj) delete(obj.LoginTimer) % ... delete it... obj.LoginTimer = []; % ... and remove it end - if ~isempty(obj.WeightTimer) % If there is a timer object - stop(obj.WeightTimer) % Stop the timer... - delete(obj.WeightTimer) % ... delete it... - obj.WeightTimer = []; % ... and remove it - end end function login(obj) diff --git a/alyx-matlab b/alyx-matlab index 46a26d60..a154f729 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 46a26d603df85dcde04404ede31ea9f51110cc33 +Subproject commit a154f729dd15ab50a5450202bbeafb77c478f356 diff --git a/signals b/signals index e200ede5..6fbd1855 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit e200ede5817d0c72949de71e49329d6ff2ac4bd1 +Subproject commit 6fbd18551b63da12cf9b4fbeab5bccfaad70eba6 From 6a9764dfd77a7685a5640d56570259f2b76f8bf6 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 3 Aug 2018 16:08:20 +0100 Subject: [PATCH 156/507] experiment may now be halted by experiment definition --- +exp/SignalsExp.m | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 8b998414..5f9a4538 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -187,7 +187,8 @@ obj.Events.expStart.map(true).into(advanceTrial) %expStart signals advance obj.Events.endTrial.into(advanceTrial) %endTrial signals advance advanceTrial.map(true).keepWhen(hasNext).into(obj.Events.newTrial) %newTrial if more - lastTrialOver.onValue(@(~)quit(obj));]; + lastTrialOver.into(obj.Events.expStop) %newTrial if more + onValue(obj.Events.expStop, @(~)quit(obj));]; % obj.Events.trialNum.onValue(fun.partial(@fprintf, 'trial %i started\n'))]; % initialise the parameter signals globalPars.post(rmfield(globalStruct, 'defFunction')); @@ -402,7 +403,9 @@ function log(obj, field, value) end function quit(obj, immediately) - obj.Events.expStop.post(true); + if isempty(obj.Events.expStop.Node.CurrValue) + obj.Events.expStop.post(true); + end %stop delay timers. todo: need to use a less global tag tmrs = timerfind('Tag', 'sig.delay'); if ~isempty(tmrs) From bc25fa2f8985abd037dd280aba6fd80e25524d41 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 7 Aug 2018 17:19:19 +0100 Subject: [PATCH 157/507] Inputs and parameters now included in update list sent to mc --- +exp/SignalsExp.m | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 5f9a4538..a173717f 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -577,7 +577,11 @@ function init(obj) fieldnames(obj.Events), struct2cell(obj.Events)); outlist = mapToCell(@(n,v)queuefun(['outputs.' n],v),... fieldnames(obj.Outputs), struct2cell(obj.Outputs)); - obj.Listeners = vertcat(obj.Listeners, evtlist(:), outlist(:)); + inlist = mapToCell(@(n,v)queuefun(['inputs.' n],v),... + fieldnames(obj.Inputs), struct2cell(obj.Inputs)); + parslist = queuefun('pars', obj.ParamsLog); + obj.Listeners = vertcat(obj.Listeners, ... + evtlist(:), outlist(:), inlist(:), parslist(:)); end function cleanup(obj) @@ -712,7 +716,12 @@ function mainLoop(obj) obj.Data.stimWindowRenderTimes(obj.StimWindowUpdateCount) = renderTime; obj.StimWindowInvalid = false; end + tic sendSignalUpdates(obj); + q = toc; + if q>0.005 + fprintf(1, 'send updates took %.1fms\n', 1000*toc); + end drawnow; % allow other callbacks to execute end ensureWindowReady(obj); % complete any outstanding refresh From 5b8f8995ab29413cd128a0139b4871075e0f0cfd Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 8 Aug 2018 17:33:54 +0100 Subject: [PATCH 158/507] Noise burst samples now replicated across all channels, based on the audioDevices struct in hardware.mat file --- cortexlab/+exp/configureChoiceExperiment.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexlab/+exp/configureChoiceExperiment.m b/cortexlab/+exp/configureChoiceExperiment.m index 0f4dc57a..c37225f9 100644 --- a/cortexlab/+exp/configureChoiceExperiment.m +++ b/cortexlab/+exp/configureChoiceExperiment.m @@ -41,7 +41,7 @@ %% Generate noise burst for negative feedback % white noise, duplicated across two channels noiseSamples = repmat(... - randn(1, params.Struct.negFeedbackSoundDuration*audSampleRate), 2, 1); + randn(1, params.Struct.negFeedbackSoundDuration*audSampleRate), dev.NrOutputChannels, 1); params.set('negFeedbackSoundSamples', {noiseSamples},... sprintf('The samples for the negative feedback sound, sampled at %iHz', audSampleRate),... 'normalised'); From 6f9d2c8f12899df05ade478db1cc1e2554dc7864 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 9 Aug 2018 19:04:48 +0100 Subject: [PATCH 159/507] Only a single set of parameters are sent to mc per trial, rather than a cumulative structure. This should reduce the size of data to be serialized --- +exp/SignalsExp.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index a173717f..6ba8e377 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -14,7 +14,7 @@ %activated and an optional delay between the event and activation. %They should be objects of class EventHandler. EventHandlers = exp.EventHandler.empty - + %Timekeeper used by the experiment. Clocks return the current time. See %the Clock class definition for more information. Clock = hw.ptb.Clock @@ -579,7 +579,7 @@ function init(obj) fieldnames(obj.Outputs), struct2cell(obj.Outputs)); inlist = mapToCell(@(n,v)queuefun(['inputs.' n],v),... fieldnames(obj.Inputs), struct2cell(obj.Inputs)); - parslist = queuefun('pars', obj.ParamsLog); + parslist = queuefun('pars', obj.Params); obj.Listeners = vertcat(obj.Listeners, ... evtlist(:), outlist(:), inlist(:), parslist(:)); end From 36cae70b17bb452510125b0eb61b367dac268593 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 29 Aug 2018 14:08:28 +0100 Subject: [PATCH 160/507] Added wheelAnalysis submodule and moved behavioural ALF extraction to +alf/block2ALF.m in alyx-matlab. ALF names are now up to date --- +exp/SignalsExp.m | 76 ++---------------------------------------- +exp/inferParameters.m | 10 +++--- .gitmodules | 5 +++ addRigboxPaths.m | 5 +++ alyx-matlab | 2 +- wheelAnalysis | 1 + 6 files changed, 19 insertions(+), 80 deletions(-) create mode 160000 wheelAnalysis diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 6ba8e377..aa7fe87e 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -868,80 +868,8 @@ function saveData(obj) && ~strcmp(subject, 'default') && isfield(obj.Data, 'events') ... && ~strcmp(obj.Data.endStatus,'aborted') try - expPath = dat.expPath(obj.Data.expRef, 'main', 'master'); - % Write feedback - - feedback = getOr(obj.Data.events, 'feedbackValues', NaN); - feedback = double(feedback); - feedback(feedback == 0) = -1; - if ~isnan(feedback) - writeNPY(feedback(:), fullfile(expPath, 'cwFeedback.type.npy')); - alf.writeEventseries(expPath, 'cwFeedback',... - obj.Data.events.feedbackTimes, [], []); - writeNPY([obj.Data.outputs.rewardValues]', fullfile(expPath, 'cwFeedback.rewardVolume.npy')); - else - warning('No ''feedback'' events recorded, cannot register to Alyx') - end - - % Write go cue - interactiveOn = getOr(obj.Data.events, 'interactiveOnTimes', NaN); - if ~isnan(interactiveOn) - alf.writeEventseries(expPath, 'cwGoCue', interactiveOn, [], []); - else - warning('No ''interactiveOn'' events recorded, cannot register to Alyx') - end - - % Write response - response = getOr(obj.Data.events, 'responseValues', NaN); - if min(response) == -1 - response(response == 0) = 3; - response(response == 1) = 2; - response(response == -1) = 1; - end - if ~isnan(response) - writeNPY(response(:), fullfile(expPath, 'cwResponse.choice.npy')); - alf.writeEventseries(expPath, 'cwResponse',... - obj.Data.events.responseTimes, [], []); - else - warning('No ''feedback'' events recorded, cannot register to Alyx') - end - - % Write stim on times - stimOnTimes = getOr(obj.Data.events, 'stimulusOnTimes', NaN); - if ~isnan(stimOnTimes) - alf.writeEventseries(expPath, 'cwStimOn', stimOnTimes, [], []); - else - warning('No ''stimulusOn'' events recorded, cannot register to Alyx') - end - contL = getOr(obj.Data.events, 'contrastLeftValues', NaN); - contR = getOr(obj.Data.events, 'contrastRightValues', NaN); - if ~any(isnan(contL))&&~any(isnan(contR)) - writeNPY(contL(:), fullfile(expPath, 'cwStimOn.contrastLeft.npy')); - writeNPY(contR(:), fullfile(expPath, 'cwStimOn.contrastRight.npy')); - else - warning('No ''contrastLeft'' and/or ''contrastRight'' events recorded, cannot register to Alyx') - end - - % Write trial intervals - alf.writeInterval(expPath, 'cwTrials',... - obj.Data.events.newTrialTimes(:), obj.Data.events.endTrialTimes(:), [], []); - repNum = obj.Data.events.repeatNumValues(:); - writeNPY(repNum == 1, fullfile(expPath, 'cwTrials.inclTrials.npy')); - writeNPY(repNum, fullfile(expPath, 'cwTrials.repNum.npy')); - - % Write wheel times, position and velocity - wheelValues = obj.Data.inputs.wheelValues(:); - wheelValues = wheelValues*(3.1*2*pi/(4*1024)); - wheelTimes = obj.Data.inputs.wheelTimes(:); - alf.writeTimeseries(expPath, 'Wheel', wheelTimes, [], []); - writeNPY(wheelValues, fullfile(expPath, 'Wheel.position.npy')); - writeNPY(wheelValues./wheelTimes, fullfile(expPath, 'Wheel.velocity.npy')); - - % Register them to Alyx - files = dir(expPath); - isNPY = cellfun(@(f)endsWith(f, '.npy'), {files.name}); - files = files(isNPY); - obj.AlyxInstance.registerFile(fullfile({files.folder}, {files.name})); + fullpath = alf.block2ALF(obj.Data); + obj.AlyxInstance.registerFile(fullpath); catch ex warning(ex.identifier, 'Failed to register alf files: %s.', ex.message); end diff --git a/+exp/inferParameters.m b/+exp/inferParameters.m index 9bb61a08..9e4c7beb 100644 --- a/+exp/inferParameters.m +++ b/+exp/inferParameters.m @@ -34,12 +34,12 @@ try - rig = 0; - if funArgs == 7 +% rig = 0; +% if funArgs == 7 expdeffun(e.t, e.events, e.pars, e.visual, e.inputs, e.outputs, e.audio); - else - expdeffun(e.t, e.events, e.pars, e.visual, e.inputs, e.outputs, e.audio, rig); - end +% else +% expdeffun(e.t, e.events, e.pars, e.visual, e.inputs, e.outputs, e.audio, rig); +% end % expdeffun(e.t, e.events, e.pars, e.visual, e.inputs , e.outputs, e.audio); % paramNames will be the strings corresponding to the fields of e.pars diff --git a/.gitmodules b/.gitmodules index c7ef4a47..b6d3b966 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,8 @@ [submodule "npy-matlab"] path = npy-matlab url = https://github.com/kwikteam/npy-matlab +[submodule "wheelAnalysis"] + + path = wheelAnalysis + + url = https://github.com/cortex-lab/wheelAnalysis.git diff --git a/addRigboxPaths.m b/addRigboxPaths.m index d9f255e8..cd5d85ee 100644 --- a/addRigboxPaths.m +++ b/addRigboxPaths.m @@ -86,6 +86,11 @@ function addRigboxPaths(savePaths) % work with other software developed by CortexLab, including MPEP addpath(fullfile(root, 'cortexlab')); +% Add wheelAnalysis paths. This is a package for computing wheel velocity, +% classifying movements, etc. +addpath(fullfile(root, 'wheelAnalysis'), ... + fullfile(root, 'wheelAnalysis', 'helpers')); + % Add signals paths, this includes all the core code for running signals % experiments. This submodule is maintained by Chris Burgess. addpath(fullfile(root, 'signals'),... diff --git a/alyx-matlab b/alyx-matlab index a154f729..26a75528 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit a154f729dd15ab50a5450202bbeafb77c478f356 +Subproject commit 26a75528187c82de2faf4ee1a78596fd6bc2c231 diff --git a/wheelAnalysis b/wheelAnalysis new file mode 160000 index 00000000..16979786 --- /dev/null +++ b/wheelAnalysis @@ -0,0 +1 @@ +Subproject commit 169797868da3fe93e1581cb4d581cdc4f4d9cd34 From a54b7a2c4fe3afd9c4b8c02f350132180b467b17 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 30 Aug 2018 17:12:32 +0100 Subject: [PATCH 161/507] Bug fix for Alyx Panel logging with multiple instances of mc. Also SqueakExpPanel no longer displays pars or inputs --- +dat/paths.m | 3 ++- +eui/AlyxPanel.m | 2 +- +eui/ParamEditor.m | 12 +++++++----- +eui/SqueakExpPanel.m | 1 + 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/+dat/paths.m b/+dat/paths.m index 63c5769f..289d84d5 100644 --- a/+dat/paths.m +++ b/+dat/paths.m @@ -25,7 +25,8 @@ % Repository for local copy of everything generated on this rig p.localRepository = 'C:\LocalExpData'; p.localAlyxQueue = 'C:\localAlyxQueue'; -p.databaseURL = 'https://alyx.cortexlab.net'; +%p.databaseURL = 'https://alyx.cortexlab.net'; +p.databaseURL = 'https://dev.alyx.internationalbrainlab.org/'; % Under the new system of having data grouped by mouse % rather than data type, all experimental data are saved here. diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index a881ff2a..80b72ce3 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -660,7 +660,7 @@ 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'); + 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); diff --git a/+eui/ParamEditor.m b/+eui/ParamEditor.m index f1826858..75aca882 100644 --- a/+eui/ParamEditor.m +++ b/+eui/ParamEditor.m @@ -489,11 +489,13 @@ function makeTrialSpecific(obj, paramName, ctrls) description = obj.Parameters.description(name); if islogical(value) % If parameter is logical, make checkbox - ctrl = uicontrol('Parent', parent,... - 'Style', 'checkbox',... - 'TooltipString', description,... - 'Value', value,... % Added 2017-02-15 MW set checkbox to what ever the parameter value is - 'Callback', @(src, e) obj.updateGlobal(name, src)); + for i = 1:length(value) + ctrl(end+1) = uicontrol('Parent', parent,... + 'Style', 'checkbox',... + 'TooltipString', description,... + 'Value', value(i),... % Added 2017-02-15 MW set checkbox to what ever the parameter value is + 'Callback', @(src, e) obj.updateGlobal(name, src)); + end elseif ischar(value) ctrl = uicontrol('Parent', parent,... 'BackgroundColor', [1 1 1],... diff --git a/+eui/SqueakExpPanel.m b/+eui/SqueakExpPanel.m index 39320746..29487985 100644 --- a/+eui/SqueakExpPanel.m +++ b/+eui/SqueakExpPanel.m @@ -83,6 +83,7 @@ function processUpdates(obj) for ui = 1:length(updates) signame = updates(ui).name; switch signame + case {'inputs.wheel', 'pars'} otherwise if ~isKey(obj.LabelsMap, signame) obj.LabelsMap(signame) = obj.addInfoField(signame, ''); From 7c49c0373e4b1e4ec057999bf9097ec6588cfc6e Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 30 Aug 2018 18:01:34 +0100 Subject: [PATCH 162/507] Updated alyx-matlab: set method for base url --- +dat/paths.m | 4 ++-- alyx-matlab | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/+dat/paths.m b/+dat/paths.m index 289d84d5..c126e5a8 100644 --- a/+dat/paths.m +++ b/+dat/paths.m @@ -25,8 +25,8 @@ % Repository for local copy of everything generated on this rig p.localRepository = 'C:\LocalExpData'; p.localAlyxQueue = 'C:\localAlyxQueue'; -%p.databaseURL = 'https://alyx.cortexlab.net'; -p.databaseURL = 'https://dev.alyx.internationalbrainlab.org/'; +p.databaseURL = 'https://alyx.cortexlab.net'; +% p.databaseURL = 'https://dev.alyx.internationalbrainlab.org/'; % Under the new system of having data grouped by mouse % rather than data type, all experimental data are saved here. diff --git a/alyx-matlab b/alyx-matlab index 26a75528..521a8b57 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 26a75528187c82de2faf4ee1a78596fd6bc2c231 +Subproject commit 521a8b57a9a872eb9c27e91a636bf94fb142b230 From b75322cfca06069a9ccdf8240049a53a2964b649 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 20 Sep 2018 10:38:11 +0100 Subject: [PATCH 163/507] Update to block2ALF: fix'd stimOn and feedback times --- alyx-matlab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx-matlab b/alyx-matlab index 521a8b57..fa739add 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 521a8b57a9a872eb9c27e91a636bf94fb142b230 +Subproject commit fa739add45c654df4f9cc28d55c7825a97c8297d From 524a997f584ee24af5523488047624bde7008012 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 26 Sep 2018 10:57:06 +0100 Subject: [PATCH 164/507] Moved Alyx instantiation to constructor to avoid unexpected behaviour --- +eui/AlyxPanel.m | 3 ++- alyx-matlab | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 80b72ce3..c107b6f2 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -32,7 +32,7 @@ % 2017-03 NS created % 2017-10 MW made into class properties (SetAccess = private) - AlyxInstance = Alyx('',''); % An Alyx object to interfacing with the database + 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 @@ -69,6 +69,7 @@ % % See also Alyx + obj.AlyxInstance = Alyx('',''); if ~nargin % No parant object: create new figure f = figure('Name', 'alyx GUI',... 'MenuBar', 'none',... diff --git a/alyx-matlab b/alyx-matlab index fa739add..480155b7 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit fa739add45c654df4f9cc28d55c7825a97c8297d +Subproject commit 480155b70bb8a4762400887f8ef6f05f84986099 From a44ecd9ba710df1f3d0a30d21a7094b61652e73c Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 27 Sep 2018 12:26:05 +0100 Subject: [PATCH 165/507] Added psychofit toolbox to psy package --- +psy/erf_psycho.m | 15 +++++++ +psy/erf_psycho_2gammas.m | 29 ++++++++++++++ +psy/mle_fit_psycho.m | 84 +++++++++++++++++++++++++++++++++++++++ +psy/neg_likelihood.m | 52 ++++++++++++++++++++++++ +psy/weibull.m | 34 ++++++++++++++++ +psy/weibull50.m | 40 +++++++++++++++++++ 6 files changed, 254 insertions(+) create mode 100644 +psy/erf_psycho.m create mode 100644 +psy/erf_psycho_2gammas.m create mode 100644 +psy/mle_fit_psycho.m create mode 100644 +psy/neg_likelihood.m create mode 100644 +psy/weibull.m create mode 100644 +psy/weibull50.m diff --git a/+psy/erf_psycho.m b/+psy/erf_psycho.m new file mode 100644 index 00000000..315fcc37 --- /dev/null +++ b/+psy/erf_psycho.m @@ -0,0 +1,15 @@ +function f = erf_psycho(pars,xx) +%ERF_PSYCHO erf function from 0 to 1, with lapse rate +% +% f = erf_psycho([threshold slope gamma],xx) +% +% computes: +% f = gamma + (1-2*gamma)* (erf( (xx-threshold)/slope ) + 1)/2 +% +% MC 2000 + +threshold = pars(1); +slope = pars(2); +gamma = pars(3); + +f = gamma + (1-2*gamma)* (erf( (xx-threshold)/slope ) + 1)/2; diff --git a/+psy/erf_psycho_2gammas.m b/+psy/erf_psycho_2gammas.m new file mode 100644 index 00000000..53ab7796 --- /dev/null +++ b/+psy/erf_psycho_2gammas.m @@ -0,0 +1,29 @@ +function f = erf_psycho_2gammas(pars,xx) +%ERF_PSYCHO_2GAMMAS erf function from 0 to 1, wiht two lapse rates +% +% f = erf_psycho_2gammas([threshold slope gamma1 gamma2],xx) +% +% Example: +% xx = -50:50; +% ff = erf_psycho_2gammas([-10 10 0.2 0.0],xx); +% figure; plot(xx, ff); set(gca,'ylim',[0 1]); +% +% MC 2000 +% 2013-06 MC and MD + +threshold = pars(1); +slope = pars(2); +gamma1 = pars(3); +gamma2 = pars(4); + + +f = nan(size(xx)); + +% ii = (xx1. +% +% If the data go from 50% to 100%, you better use an appropriate P_model... +% For example: weibull50 +% +% [pars L ] = ... returns the likelihood +% +% EXAMPLE (with data from 0 to 1) +% cc = [-8 -6 -4 -2 0 2 4 6 8 ]; % contrasts +% nn = [10 10 10 10 10 10 10 10 10 ]; % number of trials at each contrast +% pp = [ 5 8 20 41 54 59 79 92 96 ]/100; % proportion "rightward" +% +% pars = mle_fit_psycho([cc;nn;pp], 'erf_psycho'); +% +% figure; clf +% plot(cc, pp, 'bo', 'markerfacec','b'); hold on +% plot(-8:0.1:8, erf_psycho(pars,-8:0.1:8), 'b'); + +% 1999-11 FH wrote it +% 2000-01 MC cleaned it up +% 2000-04 MC took care of the 50% case +% 2009-12 MC replaced fmins with fminsearch +% 2010-02 MC, AZ added nfits +% 2013-02 MC+MD fixed bug with dealing with NaNs + +if nargin < 6 + nfits = 5; +end + +if size(data,1)~=3 + error('Error in mle_fit_psycho: Size of ''data'' must be [3,x]!'); +end + +% find the good values in pp (conditions that were effectively run) +ii = isfinite(data(3,:)); + +if nargin < 2 + P_model = 'weibull'; +end + +if nargin < 3 + xx = data(1,ii); + parstart = [ mean(xx), 3, 0.05 ]; + parmin = [min(xx) 0 0]; + parmax = [max(xx) 10 0.40]; +end + +likelihoods = zeros(nfits,1); +pars = cell(nfits,1); + +for ifit = 1:nfits + + pars{ifit} = fminsearch(... + @(pars) psy.neg_likelihood(pars, data(:,ii), P_model, parmin, parmax),... + parstart , optimset('Display','off') ); %AZ2010-03-31: suppress msgs + + parstart = parmin + rand(size(parmin)).* (parmax-parmin); + + likelihoods(ifit) = - psy.neg_likelihood(pars{ifit}, data(:,ii), P_model, parmin, parmax); + +end + +% the values to be output +[L,iBestFit] = max(likelihoods); +pars = pars{iBestFit}; + + diff --git a/+psy/neg_likelihood.m b/+psy/neg_likelihood.m new file mode 100644 index 00000000..d59f588a --- /dev/null +++ b/+psy/neg_likelihood.m @@ -0,0 +1,52 @@ +function l = neg_likelihood(pars, data, P_model, parmin, parmax) +% NEG_LIKELIHOOD Negative likelihood of a psychometric function +% +% L = neg_likelihood(pars, [xx,nn,pp], P_model) is +% - sum(nn.*(pp.*log10(P_model)+(1-pp).*log10(1-P_model))) +% +% P_model defaults to 'weibull' +% +% L = neg_likelihood(pars, [xx,nn,pp], P_model, parmin, parmax) lets you +% choose the boundaries for the parameters. +% +% parameters are pars = [threshold, slope, gamma] +% +% 1999-11 FH wrote it +% 2000-01 MC cleaned it up +% 2000-07 MC made it indep of Weibull and added parmin and parmax + +if nargin<3 + P_model= 'weibull'; +end + +if nargin<4 + parmin = [0.005 0 0]; +end + +if nargin<5 + parmax = [0.5 10 0.25]; +end + +xx = data(1,:); +nn = data(2,:); +pp = data(3,:); + +% here is where you effectively put the constraints. +if any(parsparmax) + l = 10000000; + return +end + +probs = eval(['psy.', P_model,'(pars,xx)']); + +if max(probs)>1 || min(probs)<0 + error('At least one of the probabilities is not between 0 and 1'); +end + +probs(probs==0)=eps; +probs(probs==1)=1-eps; + +l = - sum(nn.*(pp.*log(probs)+(1-pp).*log(1-probs))); +% this equation comes from the appendix of Watson, A.B. (1979). Probability +% summation over time. Vision Res 19, 515-522. + diff --git a/+psy/weibull.m b/+psy/weibull.m new file mode 100644 index 00000000..1200c047 --- /dev/null +++ b/+psy/weibull.m @@ -0,0 +1,34 @@ +function f = weibull(pars,xx) +%WEIBULL Weibull function from 0 to 1, with lapse rate +% +% f = weibull([alpha,beta,gamma],xx) is +% (1 - gamma) - (1 - 2*gamma) * exp(-(xx/alpha)^beta) +% +% This function goes from -(1-gamma) to (1-gamma). If you need a function +% that goes from 0.5 to (1-gamma), use weibull50 +% +% 1999-11 FH wrote it +% 2000-01 MC cleaned it up + +xx = xx(:)'; + +if nargin~=2 + error('Error in function Weibull: Wrong number of input arguments'); +end +if size(xx,1)~=1 + error('Error in function Weibull: variable xx must be a vector'); +end +if size(pars)~=[1,3] + error('Error in function Weibull: Wrong number of input arguments in pars!') +end + +alpha = pars(1); +beta = pars(2); +gamma = pars(3); + +if length(alpha)~=1 || length(beta)~=1 || length(gamma)~=1 + error('Variables ''alpha'',''beta'' and ''gamma'' must be scalar!'); +end + +f = (1 - gamma) - (1 - 2*gamma) * exp( -((xx./alpha).^beta)); + diff --git a/+psy/weibull50.m b/+psy/weibull50.m new file mode 100644 index 00000000..56dfe66d --- /dev/null +++ b/+psy/weibull50.m @@ -0,0 +1,40 @@ +function f = weibull50(pars,xx) +%WEIBULL50 Weibull function from 0.5 to 1, with lapse rate +% +% f = weibull50([alpha,beta,gamma],xx) is +% (1 - gamma) - (1/2 - gamma) * exp(-(xx/alpha)^beta) +% +% alpha is the threshold +% beta is the slope +% gamma is the % of errors users make anyhow +% +% See also: Weibull +% +% 2000-04 MC + +xx = xx(:)'; + +if nargin~=2 + error('Error in function Weibull: Wrong number of input arguments'); +end +if size(xx,1)~=1 + error('Error in function Weibull: variable xx must be a vector'); +end +if size(pars)~=[1,3] + error('Error in function Weibull: Wrong number of input arguments in pars!') +end + +alpha = pars(1); +beta = pars(2); +gamma = pars(3); + +if alpha<0 + error('alpha must be positive'); +end + +if length(alpha)~=1 || length(beta)~=1 || length(gamma)~=1 + error('Variables ''alpha'',''beta'' and ''gamma'' must be scalar!'); +end + +f = (1 - gamma) - (0.5 - gamma) * exp( -((xx./alpha).^beta)); + From edc0549afbd0802e8a27bbbd5c87552025a29f4f Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 2 Oct 2018 19:25:04 +0100 Subject: [PATCH 166/507] Fix'd typo in readme --- readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index b6632e3c..2e7e3036 100644 --- a/readme.md +++ b/readme.md @@ -34,7 +34,7 @@ cd Rigbox/ git checkout rigbox-lite ``` 3. Run the following to clone the submodules: -```git submodules update --init``` +```git submodule update --init``` 3. In MATLAB run 'addRigboxPaths.m' and restart the program. 4. Set the correct paths by following the instructions in Rigbox\+dat\paths.m on both computers. 5. On the stimulus server, load the hardware.mat file in Rigbox\Repositories\code\config\exampleRig and edit according to your specific hardware setup (link to detailed instructions above, under 'Getting started'). @@ -42,7 +42,7 @@ git checkout rigbox-lite To keep up to date, run the following: ``` git pull -git submodules update --remote +git submodule update --remote ``` ## Running an experiment From 4595b5d85cffd14390231c5cfc20f4f57b4d8f78 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 3 Oct 2018 17:43:26 +0100 Subject: [PATCH 167/507] Colourmap fix for discrimiation visualization --- cortexlab/+psy/plot2AUFC.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexlab/+psy/plot2AUFC.m b/cortexlab/+psy/plot2AUFC.m index be2e4724..820e51fb 100644 --- a/cortexlab/+psy/plot2AUFC.m +++ b/cortexlab/+psy/plot2AUFC.m @@ -53,7 +53,7 @@ function plot2AUFC(ax, block) psychoMCmap = reshape(permute(psychoM, [2 1 3]), numRespTypes*nCR, nCL)'; psychoMCmap(isnan(psychoMCmap))=-1; imagesc(ax, psychoMCmap) - colormap(colormap_pinkgreyscale) + colormap(psy.colormap_pinkgreyscale) set(ax, 'XTick', 1:nCR*numRespTypes, 'XTickLabel', cValsRight(repmat(1:nCR, [1 numRespTypes]))); set(ax, 'YTick', 1:nCL, 'YTickLabel', cValsLeft(1:nCL)); From 7b30f3d06523ef509cfaf60ce597f871d0861ebe Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 12 Oct 2018 17:03:34 +0100 Subject: [PATCH 168/507] Param profiles sorted ignoring case --- +dat/loadParamProfiles.m | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/+dat/loadParamProfiles.m b/+dat/loadParamProfiles.m index 2c82c14a..b63a70cb 100644 --- a/+dat/loadParamProfiles.m +++ b/+dat/loadParamProfiles.m @@ -18,7 +18,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 From 276cd8e989a502dd59c419773083cec01a498eed Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 12 Oct 2018 17:07:07 +0100 Subject: [PATCH 169/507] Signals updates set once every 100ms --- +exp/SignalsExp.m | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index aa7fe87e..e83adb98 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -653,6 +653,7 @@ function mainLoop(obj) %set looping flag obj.IsLooping = true; + t = obj.Clock.now; % begin the loop while obj.IsLooping %% create a list of handlers that have become due @@ -716,12 +717,15 @@ function mainLoop(obj) obj.Data.stimWindowRenderTimes(obj.StimWindowUpdateCount) = renderTime; obj.StimWindowInvalid = false; end - tic - sendSignalUpdates(obj); - q = toc; - if q>0.005 - fprintf(1, 'send updates took %.1fms\n', 1000*toc); + if (obj.Clock.now - t) > 0.1 + sendSignalUpdates(obj); + t = obj.Clock.now; end + +% q = toc; +% if q>0.005 +% fprintf(1, 'send updates took %.1fms\n', 1000*toc); +% end drawnow; % allow other callbacks to execute end ensureWindowReady(obj); % complete any outstanding refresh From ddfedd06ba61d8da19f2aeba1a3aae533e3b2b5a Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 17 Oct 2018 16:19:32 +0100 Subject: [PATCH 170/507] Water remaining rounded up, given rounded down in AlyxPanel --- +eui/AlyxPanel.m | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index c107b6f2..dd2d2d62 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -367,12 +367,14 @@ function dispWaterReq(obj, src, ~) colour = 'black'; % Mouse above 80% or no weight measured today weight_pct = '> 80%'; end + % Round up water remaining to the near 0.01 + remainder = ceil(s(idx).water_requirement_remaining*100)/100; % 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, s(idx).water_requirement_remaining, s(idx).water_requirement_total, weight, weight_pct, sum([water gel]))); + obj.Subject, remainder, s(idx).water_requirement_total, weight, weight_pct, floor(sum([water gel])*100)/100)); % Set WaterRemaining attribute for changeWaterText callback - obj.WaterRemaining = s(idx).water_requirement_remaining; + obj.WaterRemaining = remainder; end catch me d = me.message; %FIXME: JSON no longer returned From b89c25359ebf016b0204c960fb883589301c2e8e Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 17 Oct 2018 18:31:53 +0100 Subject: [PATCH 171/507] Applied rounding everywhere in AlyxPanel --- +eui/AlyxPanel.m | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index dd2d2d62..77a2e6cc 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -368,11 +368,13 @@ function dispWaterReq(obj, src, ~) weight_pct = '> 80%'; end % Round up water remaining to the near 0.01 - remainder = ceil(s(idx).water_requirement_remaining*100)/100; + remainder = obj.round(s(idx).water_requirement_remaining, '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, s(idx).water_requirement_total, weight, weight_pct, floor(sum([water gel])*100)/100)); + sprintf(['Subject %s requires %.2f of %.2f today\n\t '... + 'Weight today: %.2f (%s) Water today: %.2f'], obj.Subject, ... + remainder, obj.round(s(idx).water_requirement_total, 'up'), weight, ... + weight_pct, obj.round(sum([water gel]), 'down'))); % Set WaterRemaining attribute for changeWaterText callback obj.WaterRemaining = remainder; end @@ -564,11 +566,11 @@ function viewSubjectHistory(obj, ax) ylabel(ax, 'weight as pct (%)'); axWater = axes('Parent',plotBox); - plot(axWater, dates, [records.water_given]+[records.hydrogel_given], '.-'); + plot(axWater, dates, obj.round([records.water_given]+[records.hydrogel_given], 'up'), '.-'); hold(axWater, 'on'); - plot(axWater, dates, [records.hydrogel_given], '.-'); - plot(axWater, dates, [records.water_given], '.-'); - plot(axWater, dates, [records.water_expected], 'r', 'LineWidth', 2.0); + plot(axWater, dates, obj.round([records.hydrogel_given], 'down'), '.-'); + plot(axWater, dates, obj.round([records.water_given], 'down'), '.-'); + plot(axWater, dates, obj.round([records.water_expected], 'up'), 'r', 'LineWidth', 2.0); box(axWater, 'off'); xlim(axWater, [min(dates) max(dates)]); set(axWater, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(axWater, 'XTick'), 'uni', false)) @@ -622,11 +624,11 @@ function viewAllSubjects(obj) colorgen = @(colorNum,text) ['',text,'']; wrdat = cellfun(@(x)colorgen(1-double(x>0)*[0 0.3 0.3],... - sprintf('%.2f',x)), {wr.water_requirement_remaining}, 'uni', false); + sprintf('%.2f',obj.round(x, 'up'))), {wr.water_requirement_remaining}, 'uni', false); set(wrTable, 'ColumnName', {'Name', 'Water Required', 'Remaining Requirement'}, ... 'Data', horzcat({wr.nickname}', ... - cellfun(@(x)sprintf('%.2f',x),{wr.water_requirement_total}', 'uni', false), ... + cellfun(@(x)sprintf('%.2f',obj.round(x, 'up')),{wr.water_requirement_total}', 'uni', false), ... wrdat'), ... 'ColumnEditable', false(1,3)); end @@ -673,4 +675,18 @@ function log(obj, varargin) end end + methods (Static) + function A = round(a, direction, sigFigures) + if nargin < 3; sigFigures = 2; end + c = 1*10^sigFigures; + switch direction + case 'up' + A = ceil(a*c)/c; + case 'down' + A = ceil(a*c)/c; + otherwise + A = round(a*c)/c; + end + end + end end \ No newline at end of file From 0b6c2856e2d284498098871d417443731498de3a Mon Sep 17 00:00:00 2001 From: jaib1 Date: Wed, 17 Oct 2018 19:38:22 +0100 Subject: [PATCH 172/507] extra commenting --- alyx-matlab | 2 +- signals | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/alyx-matlab b/alyx-matlab index 480155b7..3e88d1e9 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 480155b70bb8a4762400887f8ef6f05f84986099 +Subproject commit 3e88d1e9c61a9b04eb99c49f269a64db843cb017 diff --git a/signals b/signals index 6fbd1855..c44fab9c 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 6fbd18551b63da12cf9b4fbeab5bccfaad70eba6 +Subproject commit c44fab9c057a4828d64a6514d04ce9a3fb0d2f47 From 0323e0317e419946bf086f6497fdd8771482abb9 Mon Sep 17 00:00:00 2001 From: Jai Bhagat Date: Thu, 18 Oct 2018 17:53:25 +0100 Subject: [PATCH 173/507] Updated installation instructions + packages information; fixed typos to-do: add more information on submodules? --- readme.md | 150 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 91 insertions(+), 59 deletions(-) diff --git a/readme.md b/readme.md index 2e7e3036..fa163b12 100644 --- a/readme.md +++ b/readme.md @@ -1,51 +1,69 @@ ---------- # Rigbox -Rigbox is a (mostly) object-oriented MATLAB software package for designing and controlling behavioural experiments. Principally, the steering wheel setup we developed to probe mouse behaviour. It requires two computers, one for stimulus presentation ('the stimulus server') and another for controlling and monitoring the experiment ('mc'). +Rigbox is a (mostly) object-oriented MATLAB software package for designing and controlling behavioural experiments (principally, the [steering wheel setup](https://www.ucl.ac.uk/cortexlab/tools/wheel) which [we](https://www.ucl.ac.uk/cortexlab) developed to probe mouse behaviour. Rigbox requires two machines, one for stimulus presentation ('the stimulus server') and another for controlling and monitoring the experiment ('mc'). ## Getting Started -The following is a brief description of how to install Rigbox on your experimental rig. However detailed, step-by-step information can be found [here](https://www.ucl.ac.uk/cortexlab/tools/wheel). +The following is a brief description of how to install Rigbox on your experimental rig. Additional detailed, step-by-step information can be found [here](https://www.ucl.ac.uk/cortexlab/tools/wheel). ## Prerequisites -Rigbox has a number of essential and optional software dependencies, listed below: -* Windows 7 or later -* [MATLAB](https://uk.mathworks.com/downloads/web_downloads/?s_iid=hp_ff_t_downloads) 2016a or later - * [Psychophsics Toolbox](https://github.com/Psychtoolbox-3/Psychtoolbox-3/releases) v3 or later - * [NI-DAQmx support package](https://uk.mathworks.com/hardware-support/nidaqmx.html) - * [GUI Layout Toolbox](https://uk.mathworks.com/matlabcentral/fileexchange/47982-gui-layout-toolbox) v2 or later - * Data Acquisition Toolbox - * Signal Processing Toolbox - * Instrument Control Toolbox + +Rigbox has the following software dependencies: +* Windows Operating System (7 or later) +* MATLAB (2016a or later) +* The following MathWorks MATLAB toolboxes: + * Data Acquisition Toolbox + * Signal Processing Toolbox + * Instrument Control Toolbox + * Statistics and Machine Learning Toolbox +* The following community MATLAB toolboxes: + * [GUI Layout Toolbox](https://uk.mathworks.com/matlabcentral/fileexchange/47982-gui-layout-toolbox) (v2 or later) + * [Psychophsics Toolbox](http://psychtoolbox.org/download.html) (v3 or later) + * [NI-DAQmx support package](https://uk.mathworks.com/hardware-support/nidaqmx.html) + +(* *Note*: You can download all required MathWorks MATLAB toolboxes directly within MATLAB via the "Add-Ons" button in the top Toolstrip with the "Home" tab selected.) +![MATLAB Home Toolstrip](http://i67.tinypic.com/k0zue.png) +​ +Afterwards, you can use the MATLAB "ver" command to bring up the list of installed MathWorks toolboxes. Additionally, Rigbox works with a number of extra submodules (included): -* [Signals](https://github.com/dendritic/signals) (for running bespoke experiment designs) - * Statistics and Machine Learning Toolbox - * [Microsoft Visual C++ Redistributable for Visual Studio 2015](https://www.microsoft.com/en-us/download/details.aspx?id=48145) -* [Alyx-matlab](https://github.com/cortex-lab/alyx-matlab) (for registering data to, and retrieving from, an Alyx database -* [NPY-matlab](https://github.com/kwikteam/npy-matlab) (for saving data in binary NPY format) - -## Installing -1. To install Rigbox, clone the repository in git. It is *not* recommended to clone directly into the MATLAB folder -```git clone https://github.com/cortex-lab/Rigbox.git``` -2. Pull the latest Rigbox-lite branch. This branch is currently the 'cleanest' one, however in the future it will likely be merged with the master branch. +* [signals](https://github.com/cortex-lab/signals) (for running bespoke experiment designs) +* [alyx-matlab](https://github.com/cortex-lab/alyx-matlab) (for registering data to, and retrieving from, an Alyx database) +* [npy-matlab](https://github.com/kwikteam/npy-matlab) (for saving data in binary NPY format) +* [wheelAnalysis](https://github.com/cortex-lab/wheelAnalysis) (for analyzing data from the steering wheel task) + +## Installation via git + +0. It is highly recommended to install Rigbox via git. If not already downloaded and installed, install [git](https://git-scm.com/download/win) (and the included minGW software environment and Git Bash MinTTY terminal emulator). After installing, launch the Git Bash terminal. +1. To install Rigbox, use the following commands in the Git Bash terminal to clone the repository from github to your local machine. (* *Note*: It is *not* recommended to clone directly into the MATLAB folder) +``` +cd ~ +git clone https://github.com/cortex-lab/Rigbox.git +``` +2. Pull the latest Rigbox-lite branch. This branch is currently the cleanest one, though in the future it will likely be merged with the master branch. ``` cd Rigbox/ git checkout rigbox-lite ``` -3. Run the following to clone the submodules: -```git submodule update --init``` -3. In MATLAB run 'addRigboxPaths.m' and restart the program. -4. Set the correct paths by following the instructions in Rigbox\+dat\paths.m on both computers. -5. On the stimulus server, load the hardware.mat file in Rigbox\Repositories\code\config\exampleRig and edit according to your specific hardware setup (link to detailed instructions above, under 'Getting started'). +3. Clone the submodules: +``` +git submodule update --init +``` +4. Open MATLAB, make sure Rigbox and all subdirectories are in your path, run: +> addRigboxPaths + +and restart MATLAB. + +5. Set the correct paths by following the instructions in the 'Rigbox\+dat\paths.m' file on both machines. +6. On the stimulus server, load 'Rigbox\Repositories\code\config\exampleRig\hardware.mat' and edit according to your specific hardware setup (link to detailed instructions above, under 'Getting started'). -To keep up to date, run the following: +To keep the submodules up to date, run the following in the Git Bash terminal (within the Rigbox directory): ``` git pull git submodule update --remote ``` - -## Running an experiment +## Running an experiment in MATLAB On the stimulus server, run: > srv.expServer @@ -53,58 +71,72 @@ On the stimulus server, run: On the mc computer, run: > mc -This opens a GUI that will allow you to choose a subject, edit some of the experimental parameters and press 'Start' to begin the basic steering wheel task on the stimulus server. +This opens a GUI that will allow you to choose a subject, edit some of the experimental parameters and press 'Start' to begin the basic steering wheel task on the stimulus server. + +## Code organization -# Code organization Below is a list of the principle directories and their general purpose. -## +dat -The data package contains all the code pertaining to the organization and logging of data. It contains functions that generate and parse unique experiment reference ids, that return the file paths where subject data and rig configuration information is stored. Other functions include those that manage experimental log entries and parameter profiles. This package is akin to a lab notebook. -## +eui -This package contains the code pertaining to the Rigbox user interface. It contains code for constructing the mc GUI (MControl.m), and for plotting live experiment data or generating tables for viewing experiment parameters and subject logs. This package is exclusively used by the mc computer. +### +dat + +The "data" package contains code pertaining to the organization and logging of data. It contains functions that generate and parse unique experiment reference ids, and return file paths where subject data and rig configuration information is stored. Other functions include those that manage experimental log entries and parameter profiles. A nice metaphor for this package is a lab notebook. -## +exp -The experiment package is for the initialization and running of behavioural experiments. These files define a framework for event- and state-based experiments. Actions such as visual stimulus presentation or reward delivery can be controlled by experiment phases, and experiment phases are managed by an event-handling system (e.g. ResponseEventInfo). +### +eui -The package also triggers auxiliary services (e.g. starting remote acquisition software), and loads parameters for presentation each trail. The principle two base classes that control these experiments are Experiment and its Signals counterpart, SignalsExp. +The "user interface" package contains code pertaining to the Rigbox user interface. It contains code for constructing the mc GUI (MControl.m), and for plotting live experiment data or generating tables for viewing experiment parameters and subject logs. -This package is almost exclusively used by the stimulus server +This package is exclusively used by the mc computer. -## +hw -The hardware package is for configuring, and interfacing with, hardware such as screens, DAQ devices, weighing scales and lick detectors. Withing this is the +ptb package which contains classes for interacting with PsychToolbox. +### +exp -The devices file loads and initializes all the hardware for a specific experimental rig. There are also classes for unifying system and hardware clocks. +The "experiments" package is for the initialization and running of behavioural experiments. It contains code that define a framework for event- and state-based experiments. Actions such as visual stimulus presentation or reward delivery can be controlled by experiment phases, and experiment phases are managed by an event-handling system (e.g. ResponseEventInfo). -## +psy -This package contains simple functions for processing and plotting psychometric data +The package also triggers auxiliary services (e.g. starting remote acquisition software), and loads parameters for presentation for each trail. The principle two base classes that control these experiments are 'Experiment' and its "signals package" counterpart, 'SignalsExp'. -## +srv -This package contains the expServer function as well as classes that manage communications between rig computers. +This package is almost exclusively used by the stimulus server. -The Service base class allows the stimulus server to start and stop auxiliary acquisition systems at the beginning and end of experiments +### +hw -The StimulusControl class is used by the mc computer to manage the stimulus server +The "hardware" package is for configuring, and interfacing with, hardware (such as screens, DAQ devices, weighing scales and lick detectors). Within this is the "+ptb" package which contains classes for interacting with PsychToolbox. -NB: Lower-level communication protocol code is found in the +io package +'devices.m' loads and initializes all the hardware for a specific experimental rig. There are also classes for unifying system and hardware clocks. -## cb-tools\burgbox -Burgbox contains many simply helper functions that are used by the main packages. Within this directory are further packages: +### +psy + +The "psychometrics" package contains simple functions for processing and plotting psychometric data. + +### +srv + +The "stim server" package contains the expServer function as well as classes that manage communications between rig computers. + +The 'Service' base class allows the stimulus server to start and stop auxiliary acquisition systems at the beginning and end of experiments. + +The 'StimulusControl' class is used by the mc computer to manage the stimulus server. + +* *Note*: Lower-level communication protocol code is found in the "cortexlab/+io" package. + +### cb-tools/burgbox + +Burgbox contains many simple helper functions that are used by the main packages. Within this directory are additional packages: * +bui --- Classes for managing graphics objects such as axes * +aud --- Functions for interacting with PsychoPortAudio * +file --- Functions for simplifying directory and file management, for instance returning the modified dates for specified folders or filtering an array of directories by those that exist * +fun --- Convenience functions for working with function handles in MATLAB, e.g. functions similar cellfun that are agnostic of input type, or ones that cache function outputs -* +img --- Classes that deal with image and frame data (DEPRICATED) +* +img --- Classes that deal with image and frame data (DEPRECATED) * +io --- Lower-level communications classes for managing UDP and TCP/IP Web sockets -* +plt --- A few small plotting functions (DEPRICATED) +* +plt --- A few small plotting functions (DEPRECATED) * +vis --- Functions for returning various windowed visual stimuli (i.g. gabor gratings) -* +ws --- An early Web socket package using SuperWebSocket (DEPRICATED) +* +ws --- An early Web socket package using SuperWebSocket (DEPRECATED) + +### cortexlab -## cortexlab -The cortexlab directory is intended for functions and classes that are rig or lab specific, for instance code that allows compatibility with other stimulus presentation packages used by cortexlab (i.e. MPEP) +The cortexlab directory is intended for functions and classes that are rig or cortexlab specific, for instance code that allows compatibility with other stimulus presentation packages used by cortexlab (e.g. MPEP) -## alyx-matlab/@Alyx -This class allows interation with an instance of the Alyx database. More information about Alyx can be found [here](http://alyx.readthedocs.io/en/latest/). Information about using the alyx-matlab class can be found in [alyx-matlab/Examples.m](https://github.com/cortex-lab/alyx-matlab/blob/alyx-as-class/Examples.m). +### submodules + +Additional information on the [alyx-matlab](https://github.com/cortex-lab/alyx-matlab), [npy-matlab](https://github.com/kwikteam/npy-matlab), [signals](https://github.com/cortex-lab/signals) and [wheelAnalysis](https://github.com/cortex-lab/wheelAnalysis) submodules can be found in their respective github repositories. ## Authors -The majority of the Rigbox code was written by [Chris Burgess](https://github.com/dendritic/) in 2013. It is now maintained and developed by a number of people at [CortexLab](https://www.ucl.ac.uk/cortexlab). + +The majority of the Rigbox code was written by [Chris Burgess](https://github.com/dendritic/) in 2013. It is now maintained and developed by a number of people at [CortexLab](https://www.ucl.ac.uk/cortexlab). From 00226a713ec77529fd7e1406cc64f1f62de34769 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 18 Oct 2018 22:47:52 +0100 Subject: [PATCH 174/507] mc & expServer pull latest code upon starting --- +dat/paths.m | 3 ++- +srv/expServer.m | 4 +++- cortexlab/+git/update.m | 37 +++++++++++++++++++++++++++++++++++++ mc.m | 2 ++ signals | 2 +- 5 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 cortexlab/+git/update.m diff --git a/+dat/paths.m b/+dat/paths.m index c126e5a8..37f96514 100644 --- a/+dat/paths.m +++ b/+dat/paths.m @@ -27,6 +27,7 @@ p.localAlyxQueue = 'C:\localAlyxQueue'; p.databaseURL = 'https://alyx.cortexlab.net'; % p.databaseURL = 'https://dev.alyx.internationalbrainlab.org/'; +p.gitExe = 'C:\Program Files\Git\cmd\git.exe'; % Under the new system of having data grouped by mouse % rather than data type, all experimental data are saved here. @@ -69,4 +70,4 @@ p = mergeStructs(customPaths, p); end -end \ No newline at end of file +end diff --git a/+srv/expServer.m b/+srv/expServer.m index c0706087..6e3cc761 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -19,6 +19,8 @@ function expServer(useTimelineOverride, bgColour) rewardId = 1; %% Initialisation +% Ensure code is up-to-date +git.update; % random seed random number generator rng('shuffle'); % communicator for receiving commands from clients @@ -362,4 +364,4 @@ function setClock(user, clock) log('Use of timeline disabled'); end end -end \ No newline at end of file +end diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m new file mode 100644 index 00000000..da3cdfce --- /dev/null +++ b/cortexlab/+git/update.m @@ -0,0 +1,37 @@ +function update(essential) +% UPDATE Update Rigbox code +% TODO +% +if nargin == 0; essential = true; end +gitexepath = getOr(dat.paths, 'gitExe', 'C:\Program Files\Git\cmd\git.exe'); %TODO generalize +gitexepath = ['"' gitexepath '"']; +root = fileparts(which('addRigboxPaths')); +origDir = pwd; +cd(root) + +cmdstr = strjoin({gitexepath, 'pull'}); +[status, cmdout] = system(cmdstr); +if status ~= 0 + if essential + cd(origDir) + error('gitUpdate:pull:pullFailed', 'Failed to pull latest changes:, %s', cmdout) + else + warning('gitUpdate:pull:pullFailed', 'Failed to pull latest changes:, %s', cmdout) + end +end +% TODO: check if submodules are empty and use init flag +cmdstr = strjoin({gitexepath, 'submodule update --remote --merge'}); +status = system(cmdstr); +if status ~= 0 + if essential + cd(origDir) + error('gitUpdate:submodule:updateFailed', ... + 'Failed to pull latest changes for submodules:, %s', cmdout) + else + warning('gitUpdate:submodule:updateFailed', ... + 'Failed to pull latest changes for submodules:, %s', cmdout) + end +end + +cd(origDir) +end diff --git a/mc.m b/mc.m index d71b21a3..d3052b08 100644 --- a/mc.m +++ b/mc.m @@ -5,6 +5,8 @@ % 2013-06 CB created +% Ensure code is up-to-date +git.update; f = figure('Name', 'MC',... 'MenuBar', 'none',... 'Toolbar', 'none',... diff --git a/signals b/signals index 6fbd1855..bfb4acf8 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 6fbd18551b63da12cf9b4fbeab5bccfaad70eba6 +Subproject commit bfb4acf8cf20e66e77917307297305fab2e087a0 From ef01a06b7a868e7c5525e12e8dfce8986c1679f1 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 19 Oct 2018 16:51:41 +0100 Subject: [PATCH 175/507] Support for new Alyx fields in sessions and water-administrations endpoints; removed AlyxInstance from property defaults --- +eui/AlyxPanel.m | 17 ++++++++--------- +eui/ExpPanel.m | 2 +- +exp/Experiment.m | 11 +++++++++-- +exp/SignalsExp.m | 15 ++++++++++++++- +srv/StimulusControl.m | 3 ++- .gitmodules | 4 ++-- alyx-matlab | 2 +- 7 files changed, 37 insertions(+), 17 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index c107b6f2..6d0783f0 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -146,9 +146,9 @@ % for future dates uicontrol('Parent', waterbox,... 'Style', 'pushbutton', ... - 'String', 'Give gel in future', ... + 'String', 'Give water in future', ... 'Enable', 'off',... - 'Callback', @(~,~)obj.giveFutureGel); + 'Callback', @(~,~)obj.giveFutureWater); % Check box to indicate whether water was gel or liquid obj.IsHydrogel = uicontrol('Parent', waterbox,... 'Style', 'checkbox', ... @@ -287,24 +287,23 @@ function giveWater(obj) % state of the 'is hydrogel' check box thisDate = now; amount = str2double(get(obj.WaterEntry, 'String')); - isHydrogel = logical(get(obj.IsHydrogel, 'Value')); + type = iff(get(obj.IsHydrogel, 'Value')==1, 'Hydrogel', 'Water'); if obj.AlyxInstance.IsLoggedIn && amount~=0 && ~isnan(amount) - wa = obj.AlyxInstance.postWater(obj.Subject, amount, thisDate, isHydrogel); + 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 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]); + answer = inputdlg(prompt,'Future Amounts', [1 50]); if isempty(answer)||~obj.AlyxInstance.IsLoggedIn return % user pressed 'Close' or 'x' end @@ -312,7 +311,7 @@ function giveFutureGel(obj) weekendDates = thisDate + (1:length(amount)); for d = 1:length(weekendDates) if amount(d) > 0 - obj.AlyxInstance.postWater(obj.Subject, amount(d), weekendDates(d), 1); + obj.AlyxInstance.postWater(obj.Subject, amount(d), weekendDates(d), 'Water'); obj.log(['Hydrogel administration of %.2f for %s posted successfully to alyx for '... datestr(weekendDates(d))], amount(d), obj.Subject); end diff --git a/+eui/ExpPanel.m b/+eui/ExpPanel.m index f0ffcaf9..5ceab5dc 100644 --- a/+eui/ExpPanel.m +++ b/+eui/ExpPanel.m @@ -244,7 +244,7 @@ function expStopped(obj, rig, ~) end if ~any(amount); return; end % Return if no water was given try - ai.postWater(subject, amount*0.001, now, false); + ai.postWater(subject, amount*0.001, now, 'Water', ai.SessionURL); catch warning('Failed to post the water %s recieved during the experiment to Alyx', amount*0.001, subject); end diff --git a/+exp/Experiment.m b/+exp/Experiment.m index d78cdef5..b236aa93 100644 --- a/+exp/Experiment.m +++ b/+exp/Experiment.m @@ -786,8 +786,15 @@ function saveData(obj) obj.AlyxInstance.registerFile(savepaths{end}); % Save the session end time if ~isempty(obj.AlyxInstance.SessionURL) - obj.AlyxInstance.postData(obj.AlyxInstance.SessionURL,... - struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject), 'put'); + numTrials = obj.Data.numCompletedTrials; + if isfield(obj.Data, 'trial')&&isfield(obj.Data.trial, 'feedbackType') + numCorrect = sum([obj.Data.trial.feedbackType] == 1); + else + numCorrect = 0; + end + sessionData = struct('end_time', obj.AlyxInstance.datestr(now), ... + 'subject', subject, 'numberOfTrials', numTrials, 'numberOfCorrectTrials', numCorrect); + obj.AlyxInstance.postData(obj.AlyxInstance.SessionURL, sessionData, 'put'); else % Infer from date session and retrieve using expFilePath end diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index e83adb98..789ab556 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -891,8 +891,21 @@ function saveData(obj) % {subject, expDate, seq}, 'Block', []); % Save the session end time if ~isempty(obj.AlyxInstance.SessionURL) + numCorrect = []; + if isfield(obj.Data, 'events') + numTrials = length(obj.Data.events.endTrialValues); + if isfield(obj.Data.events, 'feedbackValues') + numCorrect = sum(obj.Data.events.feedbackValues == 1); + end + else + numTrials = 0; + numCorrect = 0; + end + sessionData = struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject); + if ~isempty(numTrials); sessionData.numberOfTrials = numTrials; end + if ~isempty(numCorrect); sessionData.numberOfCorrectTrials = numCorrect; end obj.AlyxInstance.postData(obj.AlyxInstance.SessionURL,... - struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject), 'put'); + sessionData, 'put'); else % Retrieve session from endpoint % subsessions = obj.AlyxInstance.getData(... diff --git a/+srv/StimulusControl.m b/+srv/StimulusControl.m index 9e6f8114..8147b603 100644 --- a/+srv/StimulusControl.m +++ b/+srv/StimulusControl.m @@ -43,7 +43,7 @@ end properties (Transient, Hidden) - AlyxInstance = Alyx('','') % Property to store rig specific Alyx token + AlyxInstance % Property to store rig specific Alyx instance end properties (Constant) @@ -67,6 +67,7 @@ end s = srv.StimulusControl; s.Name = name; + s.AlyxInstance = Alyx('',''); if isempty(regexp(uri, '^ws://', 'once')) uri = ['ws://' uri]; %default protocol prefix end diff --git a/.gitmodules b/.gitmodules index b6d3b966..8e0649d5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,14 +1,14 @@ [submodule "alyx-matlab"] path = alyx-matlab url = https://github.com/cortex-lab/alyx-matlab/ + branch = dev [submodule "signals"] path = signals url = https://github.com/cortex-lab/signals + branch = dev [submodule "npy-matlab"] path = npy-matlab url = https://github.com/kwikteam/npy-matlab [submodule "wheelAnalysis"] - path = wheelAnalysis - url = https://github.com/cortex-lab/wheelAnalysis.git diff --git a/alyx-matlab b/alyx-matlab index 480155b7..004d6c96 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 480155b70bb8a4762400887f8ef6f05f84986099 +Subproject commit 004d6c9606d4a3d69f7f7fd638246a9ceb79b476 From 17db7f135310f266d3e1aa273ab4d8043627ac07 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 19 Oct 2018 16:53:35 +0100 Subject: [PATCH 176/507] Auto updating of code when mc and expServer start up; removed addpath from inferParameters --- +dat/paths.m | 1 + +exp/inferParameters.m | 11 +---------- +srv/expServer.m | 13 ++++++++----- cortexlab/+git/update.m | 41 +++++++++++++++++++++++++++++++++++++++++ mc.m | 2 ++ 5 files changed, 53 insertions(+), 15 deletions(-) create mode 100644 cortexlab/+git/update.m diff --git a/+dat/paths.m b/+dat/paths.m index c126e5a8..f1d06f83 100644 --- a/+dat/paths.m +++ b/+dat/paths.m @@ -27,6 +27,7 @@ p.localAlyxQueue = 'C:\localAlyxQueue'; p.databaseURL = 'https://alyx.cortexlab.net'; % p.databaseURL = 'https://dev.alyx.internationalbrainlab.org/'; +p.gitExe = 'C:\Program Files\Git\cmd\git.exe'; % Under the new system of having data grouped by mouse % rather than data type, all experimental data are saved here. diff --git a/+exp/inferParameters.m b/+exp/inferParameters.m index 9e4c7beb..945845d1 100644 --- a/+exp/inferParameters.m +++ b/+exp/inferParameters.m @@ -13,12 +13,9 @@ % end if ischar(expdef) && file.exists(expdef) expdeffun = fileFunction(expdef); - [funcDir, mfile] = fileparts(expdef); addpath(funcDir); - funArgs = nargin(str2func(mfile)); else expdeffun = expdef; expdef = which(func2str(expdef)); - funArgs = nargin(expdeffun); end net = sig.Net; @@ -34,14 +31,8 @@ try -% rig = 0; -% if funArgs == 7 - expdeffun(e.t, e.events, e.pars, e.visual, e.inputs, e.outputs, e.audio); -% else -% expdeffun(e.t, e.events, e.pars, e.visual, e.inputs, e.outputs, e.audio, rig); -% end + expdeffun(e.t, e.events, e.pars, e.visual, e.inputs, e.outputs, e.audio); -% expdeffun(e.t, e.events, e.pars, e.visual, e.inputs , e.outputs, e.audio); % paramNames will be the strings corresponding to the fields of e.pars % that the user tried to reference in her expdeffun. paramNames = e.pars.Subscripts.keys'; diff --git a/+srv/expServer.m b/+srv/expServer.m index c0706087..2af3eaf4 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -19,6 +19,8 @@ function expServer(useTimelineOverride, bgColour) rewardId = 1; %% Initialisation +% Pull latest changes from remote +git.update(true); % random seed random number generator rng('shuffle'); % communicator for receiving commands from clients @@ -170,14 +172,15 @@ function handleMessage(id, data, host) end case 'run' % exp run request - [expRef, preDelay, postDelay, Alyx] = args{:}; - Alyx.Headless = true; % Supress all dialog prompts + [expRef, preDelay, postDelay, AlyxInstance] = args{:}; + if isempty(AlyxInstance); AlyxInstance = Alyx('',''); end + AlyxInstance.Headless = true; % Supress all dialog prompts if dat.expExists(expRef) log('Starting experiment ''%s''', expRef); communicator.send(id, []); try communicator.send('status', {'starting', expRef}); - aborted = runExp(expRef, preDelay, postDelay, Alyx); + aborted = runExp(expRef, preDelay, postDelay, AlyxInstance); log('Experiment ''%s'' completed', expRef); communicator.send('status', {'completed', expRef, aborted}); catch runEx @@ -193,7 +196,7 @@ function handleMessage(id, data, host) case 'quit' if ~isempty(experiment) immediately = args{1}; - AlyxInstance = args{2}; + AlyxInstance = iff(isempty(args{2}), Alyx('',''), args{2}); AlyxInstance.Headless = true; if immediately log('Aborting experiment'); @@ -209,7 +212,7 @@ function handleMessage(id, data, host) log('Quit message received but no experiment is running\n'); end case 'updateAlyxInstance' %recieved new Alyx Instance from Stimulus Control - AlyxInstance = args{1}; %get struct + AlyxInstance = iff(isempty(args{1}), Alyx('',''), args{1}); AlyxInstance.Headless = true; if ~isempty(AlyxInstance) experiment.AlyxInstance = AlyxInstance; %set property for current experiment diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m new file mode 100644 index 00000000..99d42ec9 --- /dev/null +++ b/cortexlab/+git/update.m @@ -0,0 +1,41 @@ +function update(fatalOnError) +% GIT.UPDATE Pull latest Rigbox code +% +% See also +if nargin == 0; fatalOnError = true; end +gitexepath = getOr(dat.paths, 'gitExe', 'C:\Program Files\Git\cmd\git.exe'); %TODO generalize +gitexepath = ['"', gitexepath, '"']; +root = fileparts(which('addRigboxPaths')); +origDir = pwd; +cd(root) + +cmdstr = strjoin({gitexepath, 'fetch'}); +[~, cmdout] = system(cmdstr); +if isempty(cmdout); return; end + +cmdstr = strjoin({gitexepath, 'merge'}); +[status, cmdout] = system(cmdstr); +if status ~= 0 + if fatalOnError + cd(origDir) + error('gitUpdate:pull:pullFailed', 'Failed to pull latest changes:, %s', cmdout) + else + warning('gitUpdate:pull:pullFailed', 'Failed to pull latest changes:, %s', cmdout) + end +end +% TODO: check if submodules are empty and use init flag +cmdstr = strjoin({gitexepath, 'submodule update --remote --merge'}); +status = system(cmdstr); +if status ~= 0 + if fatalOnError + cd(origDir) + error('gitUpdate:submodule:updateFailed', ... + 'Failed to pull latest changes for submodules:, %s', cmdout) + else + warning('gitUpdate:submodule:updateFailed', ... + 'Failed to pull latest changes for submodules:, %s', cmdout) + end +end + +cd(origDir) +end \ No newline at end of file diff --git a/mc.m b/mc.m index d71b21a3..071f4656 100644 --- a/mc.m +++ b/mc.m @@ -5,6 +5,8 @@ % 2013-06 CB created +% Pull latest changes from remote +git.update(true); f = figure('Name', 'MC',... 'MenuBar', 'none',... 'Toolbar', 'none',... From 971c002996aad885711aa76dc5c6c2c0d53b3691 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 19 Oct 2018 18:11:34 +0100 Subject: [PATCH 177/507] Hydrogel bool now false by default --- +eui/AlyxPanel.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 6d0783f0..c66146dd 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -154,7 +154,7 @@ 'Style', 'checkbox', ... 'String', 'Hydrogel?', ... 'HorizontalAlignment', 'right',... - 'Value', true, ... + 'Value', false, ... 'Enable', 'off'); % Input for submitting amount of water obj.WaterEntry = uicontrol('Parent', waterbox,... From e6f101e9b482ab9f7141437dd13edca042408a53 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 22 Oct 2018 10:41:20 +0100 Subject: [PATCH 178/507] update to Alyx-matlab & branch rename --- alyx-matlab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx-matlab b/alyx-matlab index 004d6c96..2c5e4977 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 004d6c9606d4a3d69f7f7fd638246a9ceb79b476 +Subproject commit 2c5e497764d70c0a68fb001b2293c39345616b49 From a5e90b198e9d896a2e7d768d4c99d5d393c2401b Mon Sep 17 00:00:00 2001 From: jaib1 Date: Tue, 23 Oct 2018 12:01:24 +0100 Subject: [PATCH 179/507] signals dev branch changes into rigbox dev branch --- signals | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/signals b/signals index c44fab9c..13db4d1d 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit c44fab9c057a4828d64a6514d04ce9a3fb0d2f47 +Subproject commit 13db4d1d6a7e11427043b514867a3807eb1f12f7 From 83172d3cb168466f644f35fddfeba9720b095091 Mon Sep 17 00:00:00 2001 From: jaib1 Date: Tue, 23 Oct 2018 16:29:07 +0100 Subject: [PATCH 180/507] merged npy-matlab pull request to jaib1 from kwikteam of master branch --- npy-matlab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npy-matlab b/npy-matlab index a99e00f7..a7c4900b 160000 --- a/npy-matlab +++ b/npy-matlab @@ -1 +1 @@ -Subproject commit a99e00f78c72a7ec5f9c3074242ffaf242de9448 +Subproject commit a7c4900b62757e1b657f2cc983a5df3282abd674 From 038768eb5b2cbcaf66bd63eb7e312e4bfcf32388 Mon Sep 17 00:00:00 2001 From: jaib1 Date: Tue, 23 Oct 2018 17:47:13 +0100 Subject: [PATCH 181/507] changed .gitmodules to have submodules point to jaib1 github repos instead of cortex-lab repos --- .gitmodules | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.gitmodules b/.gitmodules index 8e0649d5..32b91d63 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,14 +1,16 @@ [submodule "alyx-matlab"] path = alyx-matlab - url = https://github.com/cortex-lab/alyx-matlab/ + url = https://github.com/jaib1/alyx-matlab/ branch = dev [submodule "signals"] path = signals - url = https://github.com/cortex-lab/signals + url = https://github.com/jaib1/signals branch = dev [submodule "npy-matlab"] path = npy-matlab - url = https://github.com/kwikteam/npy-matlab + url = https://github.com/jaib1/npy-matlab + branch = dev [submodule "wheelAnalysis"] path = wheelAnalysis - url = https://github.com/cortex-lab/wheelAnalysis.git + url = https://github.com/jaib1/wheelAnalysis + branch = dev From 186c6260fa0a580488bc4546f1fce30f75a1ab96 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 23 Oct 2018 18:54:12 +0100 Subject: [PATCH 182/507] Git update cd to orig path on early return --- alyx-matlab | 2 +- cortexlab/+git/update.m | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/alyx-matlab b/alyx-matlab index 2c5e4977..ddc6f766 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 2c5e497764d70c0a68fb001b2293c39345616b49 +Subproject commit ddc6f766fc25e1a57cc6c61a90aa5083cbd46543 diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index 99d42ec9..c2544f0e 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -11,7 +11,10 @@ function update(fatalOnError) cmdstr = strjoin({gitexepath, 'fetch'}); [~, cmdout] = system(cmdstr); -if isempty(cmdout); return; end +if isempty(cmdout) + cd(origDir) + return +end cmdstr = strjoin({gitexepath, 'merge'}); [status, cmdout] = system(cmdstr); From 8cc73a5d75d335e05e20399721726ea62cedee30 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 23 Oct 2018 19:30:27 +0100 Subject: [PATCH 183/507] Echo in code update and fix to warning in ExpPanel --- +eui/ExpPanel.m | 2 +- cortexlab/+git/update.m | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/+eui/ExpPanel.m b/+eui/ExpPanel.m index 5ceab5dc..e3434f14 100644 --- a/+eui/ExpPanel.m +++ b/+eui/ExpPanel.m @@ -246,7 +246,7 @@ function expStopped(obj, rig, ~) try ai.postWater(subject, amount*0.001, now, 'Water', ai.SessionURL); catch - warning('Failed to post the water %s recieved during the experiment to Alyx', amount*0.001, subject); + warning('Failed to post the %.2fml %s recieved during the experiment to Alyx', amount*0.001, subject); end end end diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index c2544f0e..1d5ca6c1 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -9,26 +9,28 @@ function update(fatalOnError) origDir = pwd; cd(root) +disp('Updating code...') + cmdstr = strjoin({gitexepath, 'fetch'}); -[~, cmdout] = system(cmdstr); -if isempty(cmdout) - cd(origDir) - return -end +system(cmdstr, '-echo'); +% if isempty(cmdout) +% cd(origDir) +% return +% end cmdstr = strjoin({gitexepath, 'merge'}); -[status, cmdout] = system(cmdstr); +[status, cmdout] = system(cmdstr, '-echo'); if status ~= 0 if fatalOnError cd(origDir) - error('gitUpdate:pull:pullFailed', 'Failed to pull latest changes:, %s', cmdout) + error('gitUpdate:pull:pullFailed', 'Failed to pull latest changes, %s', cmdout) else - warning('gitUpdate:pull:pullFailed', 'Failed to pull latest changes:, %s', cmdout) + warning('gitUpdate:pull:pullFailed', 'Failed to pull latest changes, %s', cmdout) end end % TODO: check if submodules are empty and use init flag cmdstr = strjoin({gitexepath, 'submodule update --remote --merge'}); -status = system(cmdstr); +status = system(cmdstr, '-echo'); if status ~= 0 if fatalOnError cd(origDir) From 1cccfc116d7103f9e4160c2febc64cbf1e4be12a Mon Sep 17 00:00:00 2001 From: Jai Bhagat Date: Thu, 25 Oct 2018 11:39:16 +0100 Subject: [PATCH 184/507] Changed .gitmodules to point back to cortex-lab subrepos for pull request --- .gitmodules | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.gitmodules b/.gitmodules index 32b91d63..0022ed5b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,16 +1,16 @@ [submodule "alyx-matlab"] path = alyx-matlab - url = https://github.com/jaib1/alyx-matlab/ + url = https://github.com/cortex-lab/alyx-matlab/ branch = dev [submodule "signals"] path = signals - url = https://github.com/jaib1/signals + url = https://github.com/cortex-lab/signals branch = dev [submodule "npy-matlab"] path = npy-matlab - url = https://github.com/jaib1/npy-matlab - branch = dev + url = https://github.com/kwikteam/npy-matlab + branch = master [submodule "wheelAnalysis"] path = wheelAnalysis - url = https://github.com/jaib1/wheelAnalysis - branch = dev + url = https://github.com/cortex-lab/wheelAnalysis + branch = master From 13c85a8ff09399f9335283589fd4c2e8e57b1fd7 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 25 Oct 2018 12:14:47 +0100 Subject: [PATCH 185/507] Reset lick counter NB: May ditch this method in future in favour of faster 'signalsy' approach --- +exp/SignalsExp.m | 1 + npy-matlab | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 789ab556..44b5f624 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -221,6 +221,7 @@ function useRig(obj, rig) obj.Wheel = rig.mouseInput; if isfield(rig, 'lickDetector') obj.LickDetector = rig.lickDetector; + obj.LickDetector.zero(); end if ~isempty(obj.DaqController.SignalGenerators) outputNames = fieldnames(obj.Outputs); % Get list of all outputs specified in expDef function diff --git a/npy-matlab b/npy-matlab index a7c4900b..a99e00f7 160000 --- a/npy-matlab +++ b/npy-matlab @@ -1 +1 @@ -Subproject commit a7c4900b62757e1b657f2cc983a5df3282abd674 +Subproject commit a99e00f78c72a7ec5f9c3074242ffaf242de9448 From ad3b8da26d6a05b35db38fec4d930a72c8f1e23a Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 25 Oct 2018 18:39:24 +0100 Subject: [PATCH 186/507] Lick detector zero'd + git.update uses 'where git' --- +exp/SignalsExp.m | 3 +++ cortexlab/+git/update.m | 11 +++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 44b5f624..0331d4bc 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -153,6 +153,8 @@ obj.Events.newTrial = net.origin('newTrial'); obj.Events.expStop = net.origin('expStop'); obj.Inputs.wheel = net.origin('wheel'); + obj.Inputs.wheelMM = obj.Inputs.wheel.map(@(x)obj.Wheel.MillimetresFactor*(x-obj.Wheel.ZeroOffset)); + obj.Inputs.wheelDeg = obj.Inputs.wheel.map(@(x)((x-obj.Wheel.ZeroOffset)/(1024*4))*360); obj.Inputs.lick = net.origin('lick'); obj.Inputs.keyboard = net.origin('keyboard'); % get global parameters & conditional parameters structs @@ -219,6 +221,7 @@ function useRig(obj, rig) end obj.DaqController = rig.daqController; obj.Wheel = rig.mouseInput; + obj.Wheel.zero(); if isfield(rig, 'lickDetector') obj.LickDetector = rig.lickDetector; obj.LickDetector.zero(); diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index 211fe203..14814d6b 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -16,11 +16,18 @@ function update(fatalOnError, scheduled) end disp('Updating code...') -gitexepath = getOr(dat.paths, 'gitExe', 'C:\Program Files\Git\cmd\git.exe'); %TODO generalize -gitexepath = ['"', gitexepath, '"']; +% Get the path to the Git exe +gitexepath = getOr(dat.paths, 'gitExe'); +if isempty(gitexepath) + [~,gitexepath] = system('where git'); +end +gitexepath = ['"', strtrim(gitexepath), '"']; + +% Temporarily change directory into Rigbox origDir = pwd; cd(root) +% Check if there are changes before pulling % cmdstr = strjoin({gitexepath, 'fetch'}); % system(cmdstr, '-echo'); % if isempty(cmdout) From 6ceb4e6fe76c6e42a8e253c62ff36d076e20734b Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 25 Oct 2018 18:49:33 +0100 Subject: [PATCH 187/507] Removed premature commit. Wheel now zero'd + where git in update func --- +exp/SignalsExp.m | 1 + cortexlab/+git/update.m | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 44b5f624..d261de2c 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -219,6 +219,7 @@ function useRig(obj, rig) end obj.DaqController = rig.daqController; obj.Wheel = rig.mouseInput; + obj.Wheel.zero(); if isfield(rig, 'lickDetector') obj.LickDetector = rig.lickDetector; obj.LickDetector.zero(); diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index 211fe203..14814d6b 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -16,11 +16,18 @@ function update(fatalOnError, scheduled) end disp('Updating code...') -gitexepath = getOr(dat.paths, 'gitExe', 'C:\Program Files\Git\cmd\git.exe'); %TODO generalize -gitexepath = ['"', gitexepath, '"']; +% Get the path to the Git exe +gitexepath = getOr(dat.paths, 'gitExe'); +if isempty(gitexepath) + [~,gitexepath] = system('where git'); +end +gitexepath = ['"', strtrim(gitexepath), '"']; + +% Temporarily change directory into Rigbox origDir = pwd; cd(root) +% Check if there are changes before pulling % cmdstr = strjoin({gitexepath, 'fetch'}); % system(cmdstr, '-echo'); % if isempty(cmdout) From af0738b88338dcd49a96fbb350d961cfbf4bbfd5 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 25 Oct 2018 18:50:57 +0100 Subject: [PATCH 188/507] rm unfinished code --- +exp/SignalsExp.m | 2 -- 1 file changed, 2 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 0331d4bc..d261de2c 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -153,8 +153,6 @@ obj.Events.newTrial = net.origin('newTrial'); obj.Events.expStop = net.origin('expStop'); obj.Inputs.wheel = net.origin('wheel'); - obj.Inputs.wheelMM = obj.Inputs.wheel.map(@(x)obj.Wheel.MillimetresFactor*(x-obj.Wheel.ZeroOffset)); - obj.Inputs.wheelDeg = obj.Inputs.wheel.map(@(x)((x-obj.Wheel.ZeroOffset)/(1024*4))*360); obj.Inputs.lick = net.origin('lick'); obj.Inputs.keyboard = net.origin('keyboard'); % get global parameters & conditional parameters structs From 9ee295f718a9917f17aa3c1ab78f3e0306bf1c9c Mon Sep 17 00:00:00 2001 From: jaib1 Date: Fri, 26 Oct 2018 14:41:46 +0100 Subject: [PATCH 189/507] added future training flag to AlyxPanel --- +eui/AlyxPanel.m | 1336 +++++++++++++++++++++++----------------------- 1 file changed, 670 insertions(+), 666 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 675b8787..9dc3e7ab 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -1,691 +1,695 @@ classdef AlyxPanel < handle - % EUI.ALYXPANEL A GUI for interating with the Alyx database - % This class is emplyed by mc (but may also be used stand-alone) to - % post weights and water administations to the Alyx database. - % - % 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. - % Previous weighings and water infomation can be viewed by pressing - % the 'Subject history' button. - % Water administrations can be recorded by entering a value in ml - % into the text box. Pressing return does not post the water, but - % updates the text to the right of the box, showing the amount of - % water remaining (i.e. the amount below the subject's calculated - % minimum requirement for that day. The check box to the right of - % the text box is to indicate whether the water was liquid - % (unchecked) or gel (checked). To post the water to Alyx, press the - % 'Give water' button. - % To post gel for future date (for example weekend hydrogel), Click - % the 'Give gel in future' button and enter in all the values - % 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 - % - % 2017-03 NS created - % 2017-10 MW made into class - properties (SetAccess = private) - 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 - 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') - Connected % Notified when logged in to database - Disconnected % Notified when logged out of database - end - - methods - 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. If - % the active flag is set to false (default is true), the panel is - % inactive and the instance of Alyx will be set to headless. - % - % 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]); - parent = uiextras.VBox('Parent', f,... - 'Visible', 'on'); - % subject selector - sbox = uix.HBox('Parent', parent); - bui.label('Select subject: ', sbox); - obj.NewExpSubject = bui.Selector(sbox, {'default'}); % Subject dropdown box - % set a callback on subject selection so that we can show water - % requirements for new mice as they are selected. This should - % be set by any other GUI that instantiates this object (e.g. - % MControl using this as a panel. - obj.NewExpSubject.addlistener('SelectionChanged', @(src, evt)obj.dispWaterReq(src, evt)); - end - - % Default to active AlyxPanel - if nargin < 2; active = true; end - - obj.RootContainer = uix.Panel('Parent', parent, 'Title', 'Alyx'); - alyxbox = uiextras.VBox('Parent', obj.RootContainer); - - loginbox = uix.HBox('Parent', alyxbox); - % Login infomation - obj.LoginText = bui.label('Not logged in', loginbox); - % Button to log in and out of Alyx - obj.LoginButton = uicontrol('Parent', loginbox,... - 'Style', 'pushbutton', ... - 'String', 'Login', ... - 'Enable', 'on',... - '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 - uicontrol('Parent', waterReqbox,... - 'Style', 'pushbutton', ... - 'String', 'Refresh', ... - 'Enable', 'off',... - 'Callback', @(~,~)obj.dispWaterReq); - waterReqbox.Widths = [-1 75]; - - waterbox = uix.HBox('Parent', alyxbox); - % Button to launch a dialog displaying water and weight info for a given mouse - uicontrol('Parent', waterbox,... - 'Style', 'pushbutton', ... - 'String', 'Subject history', ... - 'Enable', 'off',... - 'Callback', @(~,~)obj.viewSubjectHistory); - % Button to launch a dialog displaying water and weight info for all mice - uicontrol('Parent', waterbox,... - 'Style', 'pushbutton', ... - 'String', 'All WR subjects', ... - 'Enable', 'off',... - 'Callback', @(~,~)obj.viewAllSubjects); - % Button to open a dialog for manually submitting a mouse weight - obj.WeightButton = uicontrol('Parent', waterbox,... - 'Style', 'pushbutton', ... - 'String', 'Manual weighing', ... - 'Enable', 'off',... - 'Callback', @(~,~)obj.recordWeight); - % Button to launch dialog for submitting gel administrations - % for future dates - uicontrol('Parent', waterbox,... - 'Style', 'pushbutton', ... - 'String', 'Give water in future', ... - 'Enable', 'off',... - 'Callback', @(~,~)obj.giveFutureWater); - % Check box to indicate whether water was gel or liquid - obj.IsHydrogel = uicontrol('Parent', waterbox,... - 'Style', 'checkbox', ... - 'String', 'Hydrogel?', ... - 'HorizontalAlignment', 'right',... - 'Value', false, ... - 'Enable', 'off'); - % Input for submitting amount of water - obj.WaterEntry = uicontrol('Parent', waterbox,... - 'Style', 'edit',... - 'BackgroundColor', [1 1 1],... - 'HorizontalAlignment', 'right',... - 'Enable', 'off',... - 'String', '0.00', ... - 'Callback', @(src, evt)obj.changeWaterText(src, evt)); - % Button for submitting water administration - uicontrol('Parent', waterbox,... - 'Style', 'pushbutton', ... - 'String', 'Give water', ... - 'Enable', 'off',... - 'Callback', @(~,~)giveWater(obj)); - % Label Indicating the amount of water remaining - obj.WaterRemainingText = bui.label('[]', waterbox); - waterbox.Widths = [100 100 100 100 75 75 75 75]; - - launchbox = uix.HBox('Parent', alyxbox); - % Button for launching subject page in browser - uicontrol('Parent', launchbox,... - 'Style', 'pushbutton', ... - 'String', 'Launch webpage for Subject', ... - 'Enable', 'off',... - 'Callback', @(~,~)obj.launchSubjectURL); - % Button for launching (and creating) a session for a given subject in the browser - uicontrol('Parent', launchbox,... - 'Style', 'pushbutton', ... - 'String', 'Launch webpage for Session', ... - 'Enable', 'off',... - 'Callback', @(~,~)obj.launchSessionURL); - - if ~nargin - % logging message area - obj.LoggingDisplay = uicontrol('Parent', parent, 'Style', 'listbox',... - 'Enable', 'inactive', 'String', {}); - parent.Sizes = [50 150 150]; - else - % Use parent's logging display - obj.LoggingDisplay = findobj('Tag', 'Logging Display'); - end + % EUI.ALYXPANEL A GUI for interating with the Alyx database + % This class is emplyed by mc (but may also be used stand-alone) to + % post weights and water administations to the Alyx database. + % + % 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. + % Previous weighings and water infomation can be viewed by pressing + % the 'Subject history' button. + % Water administrations can be recorded by entering a value in ml + % into the text box. Pressing return does not post the water, but + % updates the text to the right of the box, showing the amount of + % water remaining (i.e. the amount below the subject's calculated + % minimum requirement for that day. The check box to the right of + % the text box is to indicate whether the water was liquid + % (unchecked) or gel (checked). To post the water to Alyx, press the + % 'Give water' button. + % To post gel for future date (for example weekend hydrogel), Click + % the 'Give gel in future' button and enter in all the values + % 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 + % + % 2017-03 NS created + % 2017-10 MW made into class + properties (SetAccess = private) + 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 - function delete(obj) - % To be called before destroying AlyxPanel object. Deletes the - % loggin timer - disp('AlyxPanel destructor called'); - if obj.RootContainer.isvalid; delete(obj.RootContainer); end - if ~isempty(obj.LoginTimer) % If there is a timer object - stop(obj.LoginTimer) % Stop the timer... - delete(obj.LoginTimer) % ... delete it... - obj.LoginTimer = []; % ... and remove it - 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 + 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 - function login(obj) - % 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. - - % Reset headless flag in case user wishes to retry connection - obj.AlyxInstance.Headless = false; - % Are we logging in or out? - if ~obj.AlyxInstance.IsLoggedIn % logging in - % attempt login - obj.AlyxInstance = obj.AlyxInstance.login(); % 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, 'BusyMode', 'queue', 'Name', 'Login Timer'); - start(obj.LoginTimer) - % Enable all buttons - set(findall(obj.RootContainer, '-property', 'Enable'), 'Enable', 'on'); - 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 - newSubs = obj.AlyxInstance.listSubjects; - obj.NewExpSubject.Option = newSubs; - obj.SubjectList = newSubs; - - notify(obj, 'Connected'); % Notify listeners of login - 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 the main repository. - firstTimeSubs = newSubs(~ismember(newSubs, dat.listSubjects)); - for fts = 1:length(firstTimeSubs) - thisDir = fullfile(dat.reposPath('main', 'master'), firstTimeSubs{fts}); - if ~exist(thisDir, 'dir') - fprintf(1, 'making directory for %s\n', firstTimeSubs{fts}); - mkdir(thisDir); - 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.AlyxInstance.logout; - if ~isempty(obj.LoginTimer) % If there is a timer object - stop(obj.LoginTimer) % Stop the timer... - delete(obj.LoginTimer) % ... delete it... - obj.LoginTimer = []; % ... and remove it - end - set(obj.LoginText, 'String', 'Not logged in') - % Disable all buttons - set(findall(obj.RootContainer, '-property', 'Enable'), 'Enable', 'off') - set(obj.LoginButton, 'Enable', 'on', 'String', 'Login') % ... except the login button - notify(obj, 'Disconnected'); % Notify listeners of logout - obj.log('Logged out of Alyx'); - end - 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 - thisDate = now; - amount = str2double(get(obj.WaterEntry, 'String')); - type = iff(get(obj.IsHydrogel, 'Value')==1, 'Hydrogel', 'Water'); - 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 - 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); + events (NotifyAccess = 'protected') + Connected % Notified when logged in to database + Disconnected % Notified when logged out of database end - function giveFutureWater(obj) - % Open a dialog allowing one to input water submissions for - % future dates - 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 Amounts', [1 50]); - if isempty(answer)||~obj.AlyxInstance.IsLoggedIn - return % user pressed 'Close' or 'x' - end - amount = str2num(answer{:}); %#ok - weekendDates = thisDate + (1:length(amount)); - for d = 1:length(weekendDates) - if amount(d) > 0 - obj.AlyxInstance.postWater(obj.Subject, amount(d), weekendDates(d), 'Water'); - obj.log(['Hydrogel administration of %.2f for %s posted successfully to alyx for '... - datestr(weekendDates(d))], amount(d), obj.Subject); + methods + 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. If + % the active flag is set to false (default is true), the panel is + % inactive and the instance of Alyx will be set to headless. + % + % 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]); + parent = uiextras.VBox('Parent', f,... + 'Visible', 'on'); + % subject selector + sbox = uix.HBox('Parent', parent); + bui.label('Select subject: ', sbox); + obj.NewExpSubject = bui.Selector(sbox, {'default'}); % Subject dropdown box + % set a callback on subject selection so that we can show water + % requirements for new mice as they are selected. This should + % be set by any other GUI that instantiates this object (e.g. + % MControl using this as a panel. + obj.NewExpSubject.addlistener('SelectionChanged', @(src, evt)obj.dispWaterReq(src, evt)); + end + + % Default to active AlyxPanel + if nargin < 2; active = true; end + + obj.RootContainer = uix.Panel('Parent', parent, 'Title', 'Alyx'); + alyxbox = uiextras.VBox('Parent', obj.RootContainer); + + loginbox = uix.HBox('Parent', alyxbox); + % Login infomation + obj.LoginText = bui.label('Not logged in', loginbox); + % Button to log in and out of Alyx + obj.LoginButton = uicontrol('Parent', loginbox,... + 'Style', 'pushbutton', ... + 'String', 'Login', ... + 'Enable', 'on',... + '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 + uicontrol('Parent', waterReqbox,... + 'Style', 'pushbutton', ... + 'String', 'Refresh', ... + 'Enable', 'off',... + 'Callback', @(~,~)obj.dispWaterReq); + waterReqbox.Widths = [-1 75]; + + waterbox = uix.HBox('Parent', alyxbox); + % Button to launch a dialog displaying water and weight info for a given mouse + uicontrol('Parent', waterbox,... + 'Style', 'pushbutton', ... + 'String', 'Subject history', ... + 'Enable', 'off',... + 'Callback', @(~,~)obj.viewSubjectHistory); + % Button to launch a dialog displaying water and weight info for all mice + uicontrol('Parent', waterbox,... + 'Style', 'pushbutton', ... + 'String', 'All WR subjects', ... + 'Enable', 'off',... + 'Callback', @(~,~)obj.viewAllSubjects); + % Button to open a dialog for manually submitting a mouse weight + obj.WeightButton = uicontrol('Parent', waterbox,... + 'Style', 'pushbutton', ... + 'String', 'Manual weighing', ... + 'Enable', 'off',... + 'Callback', @(~,~)obj.recordWeight); + % Button to launch dialog for submitting gel administrations + % for future dates + uicontrol('Parent', waterbox,... + 'Style', 'pushbutton', ... + 'String', 'Give water in future', ... + 'Enable', 'off',... + 'Callback', @(~,~)obj.giveFutureWater); + % Check box to indicate whether water was gel or liquid + obj.IsHydrogel = uicontrol('Parent', waterbox,... + 'Style', 'checkbox', ... + 'String', 'Hydrogel?', ... + 'HorizontalAlignment', 'right',... + 'Value', false, ... + 'Enable', 'off'); + % Input for submitting amount of water + obj.WaterEntry = uicontrol('Parent', waterbox,... + 'Style', 'edit',... + 'BackgroundColor', [1 1 1],... + 'HorizontalAlignment', 'right',... + 'Enable', 'off',... + 'String', '0.00', ... + 'Callback', @(src, evt)obj.changeWaterText(src, evt)); + % Button for submitting water administration + uicontrol('Parent', waterbox,... + 'Style', 'pushbutton', ... + 'String', 'Give water', ... + 'Enable', 'off',... + 'Callback', @(~,~)giveWater(obj)); + % Label Indicating the amount of water remaining + obj.WaterRemainingText = bui.label('[]', waterbox); + waterbox.Widths = [100 100 100 100 75 75 75 75]; + + launchbox = uix.HBox('Parent', alyxbox); + % Button for launching subject page in browser + uicontrol('Parent', launchbox,... + 'Style', 'pushbutton', ... + 'String', 'Launch webpage for Subject', ... + 'Enable', 'off',... + 'Callback', @(~,~)obj.launchSubjectURL); + % Button for launching (and creating) a session for a given subject in the browser + uicontrol('Parent', launchbox,... + 'Style', 'pushbutton', ... + 'String', 'Launch webpage for Session', ... + 'Enable', 'off',... + 'Callback', @(~,~)obj.launchSessionURL); + + if ~nargin + % logging message area + obj.LoggingDisplay = uicontrol('Parent', parent, 'Style', 'listbox',... + 'Enable', 'inactive', 'String', {}); + parent.Sizes = [50 150 150]; + else + % Use parent's logging display + obj.LoggingDisplay = findobj('Tag', 'Logging Display'); + end 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>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 = getOr(record, 'weight_measured', NaN); % Get today's measured weight - water = getOr(record, 'water_given', 0); % Get total water given - gel = getOr(record, 'hydrogel_given', 0); % Get total gel given - weight_expected = getOr(record, 'weight_expected', NaN); - % Set colour based on weight percentage - weight_pct = (weight-wr.implant_weight)/(weight_expected-wr.implant_weight); - if weight_pct < 0.8 % Mouse below 80% original weight - colour = [0.91, 0.41, 0.17]; % Orange - weight_pct = '< 80%'; - elseif weight_pct < 0.7 % Mouse below 70% original weight - colour = 'red'; - weight_pct = '< 70%'; - 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).water_requirement_remaining, '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).water_requirement_total, 'up'), weight, ... - weight_pct, obj.round(sum([water gel]), 'down'))); - % Set WaterRemaining attribute for changeWaterText callback - obj.WaterRemaining = remainder; + + function delete(obj) + % To be called before destroying AlyxPanel object. Deletes the + % loggin timer + disp('AlyxPanel destructor called'); + if obj.RootContainer.isvalid; delete(obj.RootContainer); end + if ~isempty(obj.LoginTimer) % If there is a timer object + stop(obj.LoginTimer) % Stop the timer... + delete(obj.LoginTimer) % ... delete it... + obj.LoginTimer = []; % ... and remove it + end end - catch me - d = me.message; %FIXME: JSON no longer returned - if isfield(d, 'detail') && strcmp(d.detail, 'Not found.') - set(obj.WaterRequiredText, 'ForegroundColor', 'black',... - 'String', sprintf('Subject %s not found in alyx', obj.Subject)); + + function login(obj) + % 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. + + % Reset headless flag in case user wishes to retry connection + obj.AlyxInstance.Headless = false; + % Are we logging in or out? + if ~obj.AlyxInstance.IsLoggedIn % logging in + % attempt login + obj.AlyxInstance = obj.AlyxInstance.login(); % 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, 'BusyMode', 'queue', 'Name', 'Login Timer'); + start(obj.LoginTimer) + % Enable all buttons + set(findall(obj.RootContainer, '-property', 'Enable'), 'Enable', 'on'); + 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 + newSubs = obj.AlyxInstance.listSubjects; + obj.NewExpSubject.Option = newSubs; + obj.SubjectList = newSubs; + + notify(obj, 'Connected'); % Notify listeners of login + 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 the main repository. + firstTimeSubs = newSubs(~ismember(newSubs, dat.listSubjects)); + for fts = 1:length(firstTimeSubs) + thisDir = fullfile(dat.reposPath('main', 'master'), firstTimeSubs{fts}); + if ~exist(thisDir, 'dir') + fprintf(1, 'making directory for %s\n', firstTimeSubs{fts}); + mkdir(thisDir); + 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.AlyxInstance.logout; + if ~isempty(obj.LoginTimer) % If there is a timer object + stop(obj.LoginTimer) % Stop the timer... + delete(obj.LoginTimer) % ... delete it... + obj.LoginTimer = []; % ... and remove it + end + set(obj.LoginText, 'String', 'Not logged in') + % Disable all buttons + set(findall(obj.RootContainer, '-property', 'Enable'), 'Enable', 'off') + set(obj.LoginButton, 'Enable', 'on', 'String', 'Login') % ... except the login button + notify(obj, 'Disconnected'); % Notify listeners of logout + obj.log('Logged out of Alyx'); + end + obj.dispWaterReq() 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 - 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 recordWeight(obj, weight, subject) - % Post a subject's weight to Alyx. If no inputs are provided, - % create an input dialog for the user to input a weight. If no - % subject is provided, use this object's currently selected - % subject. - % - % See also VIEWSUBJECTHISTORY, VIEWALLSUBJECTS - ai = obj.AlyxInstance; - if nargin < 3; subject = obj.Subject; end - if nargin < 2 - prompt = {sprintf('weight of %s:', subject)}; - dlgTitle = 'Manual weight logging'; - numLines = 1; - defaultAns = {'',''}; - weight = inputdlg(prompt, dlgTitle, numLines, defaultAns); - if isempty(weight); return; end - end - % inputdlg 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}); - try - w = postWeight(ai, weight, subject); - obj.log('Alyx weight posting succeeded: %.2f for %s', w.weight, w.subject); - catch - 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!'); + + 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 + thisDate = now; + amount = str2double(get(obj.WaterEntry, 'String')); + type = iff(get(obj.IsHydrogel, 'Value')==1, 'Hydrogel', 'Water'); + 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 + 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 - end - % Update weight and refresh login timer - obj.dispWaterReq - end - - function 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. - % - % See also LAUNCHSUBJECTURL - ai = obj.AlyxInstance; - % determine whether there is a session for this subj and date - thisDate = ai.datestr(now); - sessions = ai.getData(['sessions?type=Base&subject=' obj.Subject]); - - % 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) || ~strcmp(sessions{end}.start_time(1:10), thisDate(1:10)) - % 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 base session?', ... - ['No base session exists for ' datestr(now, 'yyyy-mm-dd')], ... - 'Yes','No','No'); - % Handle response - switch choice - case 'Yes' - % 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'; + + function giveFutureWater(obj) + % Open a dialog allowing one to input water submissions for + % future dates + thisDate = now; + prompt=sprintf('Enter space-separated numbers \n[tomorrow, day after that, day after that.. etc] \nEnter "0" to skip a day\nEnter "-1" to indicate training for that day'); + a=inputdlg(prompt,'Future Amounts', [1 50]); + if isempty(answer)||~obj.AlyxInstance.IsLoggedIn + return % user pressed 'Close' or 'x' + end + amt = str2num(answer{:}); % amount of water + futDates = thisDate + (1:length(amt)); %datenum of all input future dates - 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]); + futTrnDates = futDates(amt<0); %future training dates + futWtrDates = futDates(amt>0); %future water giving dates + amtWtrDates = amt(amt>0); %amount of water to give on future water dates + dat.saveParamProfile('WeekendWater', obj.Subject, futTrnDates); + + for d = 1:length(futWtrDates) + obj.AlyxInstance.postWater(obj.Subject, amtWtrDates(d), futWtrDates(d), 'Water'); + obj.log(['Hydrogel administration of %.2f for %s posted successfully to alyx for '... + datestr(futWtrDates(d))], amtWtrDates(d), obj.Subject); end - case 'No' - return end - else - thisSess = sessions{end}; - end - - % parse the uuid from the url in the session object - u = thisSess.url; - uuid = u(find(u=='/', 1, 'last')+1:end); - - % make the admin url - adminURL = fullfile(ai.BaseURL, 'admin', 'actions', 'session', uuid, 'change'); - - % launch the website - web(adminURL, '-browser'); - end - - function launchSubjectURL(obj) - ai = obj.AlyxInstance; - if ai.IsLoggedIn - s = ai.getData(ai.makeEndpoint(['subjects/' obj.Subject])); - subjURL = fullfile(ai.BaseURL, 'admin', 'subjects', 'subject', s.id, 'change'); % this is wrong - need uuid - web(subjURL, '-browser'); - end - end - - 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 - - % 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 - endpnt = sprintf('water-requirement/%s?start_date=2016-01-01&end_date=%s', obj.Subject, datestr(now, 'yyyy-mm-dd')); - wr = obj.AlyxInstance.getData(endpnt); - 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 - expected = [records.weight_expected]; - expected(expected==0) = 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); - end - - plot(ax, dates, [records.weight_measured], '.-'); - 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 - if numel(dates) > 1; xlim(ax, [min(dates) max(dates)]); end - if nargin == 1 - set(ax, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false)) - 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(ax, dates, ([records.weight_measured]-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) max(dates)]); - set(ax, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false)) - ylabel(ax, 'weight as pct (%)'); - axWater = axes('Parent',plotBox); - plot(axWater, dates, obj.round([records.water_given]+[records.hydrogel_given], 'up'), '.-'); - hold(axWater, 'on'); - plot(axWater, dates, obj.round([records.hydrogel_given], 'down'), '.-'); - plot(axWater, dates, obj.round([records.water_given], 'down'), '.-'); - plot(axWater, dates, obj.round([records.water_expected], 'up'), 'r', 'LineWidth', 2.0); - box(axWater, 'off'); - xlim(axWater, [min(dates) max(dates)]); - set(axWater, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(axWater, 'XTick'), 'uni', false)) - ylabel(axWater, 'water/hydrogel (mL)'); + 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 = getOr(record, 'weight_measured', NaN); % Get today's measured weight + water = getOr(record, 'water_given', 0); % Get total water given + gel = getOr(record, 'hydrogel_given', 0); % Get total gel given + weight_expected = getOr(record, 'weight_expected', NaN); + % Set colour based on weight percentage + weight_pct = (weight-wr.implant_weight)/(weight_expected-wr.implant_weight); + if weight_pct < 0.8 % Mouse below 80% original weight + colour = [0.91, 0.41, 0.17]; % Orange + weight_pct = '< 80%'; + elseif weight_pct < 0.7 % Mouse below 70% original weight + colour = 'red'; + weight_pct = '< 70%'; + 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).water_requirement_remaining, '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).water_requirement_total, 'up'), weight, ... + weight_pct, obj.round(sum([water gel]), 'down'))); + % Set WaterRemaining attribute for changeWaterText callback + obj.WaterRemaining = remainder; + end + catch me + d = me.message; %FIXME: JSON no longer returned + if isfield(d, 'detail') && strcmp(d.detail, 'Not found.') + set(obj.WaterRequiredText, 'ForegroundColor', 'black',... + 'String', sprintf('Subject %s not found in alyx', obj.Subject)); + end + end + end - % Create table of useful weight and water information, - % sorted by date - histTable = uitable('Parent', histbox,... - 'FontName', 'Consolas',... - 'RowName', []); - weightsByDate = num2cell([records.weight_measured]); - weightsByDate = cellfun(@(x)sprintf('%.1f', x), weightsByDate, 'uni', false); - weightsByDate(isnan([records.weight_measured])) = {[]}; - weightPctByDate = num2cell(([records.weight_measured]-iw)./(expected-iw)); - weightPctByDate = cellfun(@(x)sprintf('%.1f', x*100), weightPctByDate, 'uni', false); - weightPctByDate(isnan([records.weight_measured])) = {[]}; + 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 - dat = horzcat(... - arrayfun(@(x)datestr(x), dates', 'uni', false), ... - weightsByDate', ... - arrayfun(@(x)sprintf('%.1f', 0.8*(x-iw)+iw), [records.weight_expected]', 'uni', false), ... - weightPctByDate'); - waterDat = (... - num2cell(horzcat([records.water_given]', [records.hydrogel_given]', ... - [records.water_given]'+[records.hydrogel_given]', [records.water_expected]',... - [records.water_given]'+[records.hydrogel_given]'-[records.water_expected]'))); - waterDat = cellfun(@(x)sprintf('%.2f', x), waterDat, 'uni', false); - dat = horzcat(dat, waterDat); + function recordWeight(obj, weight, subject) + % Post a subject's weight to Alyx. If no inputs are provided, + % create an input dialog for the user to input a weight. If no + % subject is provided, use this object's currently selected + % subject. + % + % See also VIEWSUBJECTHISTORY, VIEWALLSUBJECTS + ai = obj.AlyxInstance; + if nargin < 3; subject = obj.Subject; end + if nargin < 2 + prompt = {sprintf('weight of %s:', subject)}; + dlgTitle = 'Manual weight logging'; + numLines = 1; + defaultAns = {'',''}; + weight = inputdlg(prompt, dlgTitle, numLines, defaultAns); + if isempty(weight); return; end + end + % inputdlg 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}); + try + w = postWeight(ai, weight, subject); + obj.log('Alyx weight posting succeeded: %.2f for %s', w.weight, w.subject); + catch + 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!'); + end + end + % Update weight and refresh login timer + obj.dispWaterReq + end - set(histTable, 'ColumnName', {'date', 'meas. weight', '80% weight', 'weight pct', 'water', 'hydrogel', 'total', 'min water', 'excess'}, ... - 'Data', dat(end:-1:1,:),... - 'ColumnEditable', false(1,5)); - histbox.Widths = [ -1 725]; - end - end - - function viewAllSubjects(obj) - ai = obj.AlyxInstance; - 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 - wrBox = uix.VBox('Parent', f); - wrTable = uitable('Parent', wrBox,... - 'FontName', 'Consolas',... - 'RowName', []); + function 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. + % + % See also LAUNCHSUBJECTURL + ai = obj.AlyxInstance; + % determine whether there is a session for this subj and date + thisDate = ai.datestr(now); + sessions = ai.getData(['sessions?type=Base&subject=' obj.Subject]); + + % 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) || ~strcmp(sessions{end}.start_time(1:10), thisDate(1:10)) + % 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 base session?', ... + ['No base session exists for ' datestr(now, 'yyyy-mm-dd')], ... + 'Yes','No','No'); + % Handle response + switch choice + case 'Yes' + % 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'; + + 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 + case 'No' + return + end + else + thisSess = sessions{end}; + end + + % parse the uuid from the url in the session object + u = thisSess.url; + uuid = u(find(u=='/', 1, 'last')+1:end); + + % make the admin url + adminURL = fullfile(ai.BaseURL, 'admin', 'actions', 'session', uuid, 'change'); + + % launch the website + web(adminURL, '-browser'); + end - htmlColor = @(colorNum)reshape(dec2hex(round(colorNum'*255),2)',1,6); - % colorgen = @(colorNum,text) ['
',text,'
']; - colorgen = @(colorNum,text) ['',text,'']; + function launchSubjectURL(obj) + ai = obj.AlyxInstance; + if ai.IsLoggedIn + s = ai.getData(ai.makeEndpoint(['subjects/' obj.Subject])); + subjURL = fullfile(ai.BaseURL, 'admin', 'subjects', 'subject', s.id, 'change'); % this is wrong - need uuid + web(subjURL, '-browser'); + end + end - wrdat = cellfun(@(x)colorgen(1-double(x>0)*[0 0.3 0.3],... - sprintf('%.2f',obj.round(x, 'up'))), {wr.water_requirement_remaining}, 'uni', false); + 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 + + % 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 + endpnt = sprintf('water-requirement/%s?start_date=2016-01-01&end_date=%s', obj.Subject, datestr(now, 'yyyy-mm-dd')); + wr = obj.AlyxInstance.getData(endpnt); + 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 + expected = [records.weight_expected]; + expected(expected==0) = 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); + end + + plot(ax, dates, [records.weight_measured], '.-'); + 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 + if numel(dates) > 1; xlim(ax, [min(dates) max(dates)]); end + if nargin == 1 + set(ax, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false)) + 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(ax, dates, ([records.weight_measured]-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) max(dates)]); + set(ax, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false)) + ylabel(ax, 'weight as pct (%)'); + + axWater = axes('Parent',plotBox); + plot(axWater, dates, obj.round([records.water_given]+[records.hydrogel_given], 'up'), '.-'); + hold(axWater, 'on'); + plot(axWater, dates, obj.round([records.hydrogel_given], 'down'), '.-'); + plot(axWater, dates, obj.round([records.water_given], 'down'), '.-'); + plot(axWater, dates, obj.round([records.water_expected], 'up'), 'r', 'LineWidth', 2.0); + box(axWater, 'off'); + xlim(axWater, [min(dates) max(dates)]); + set(axWater, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(axWater, 'XTick'), 'uni', false)) + ylabel(axWater, 'water/hydrogel (mL)'); + + % Create table of useful weight and water information, + % sorted by date + histTable = uitable('Parent', histbox,... + 'FontName', 'Consolas',... + 'RowName', []); + weightsByDate = num2cell([records.weight_measured]); + weightsByDate = cellfun(@(x)sprintf('%.1f', x), weightsByDate, 'uni', false); + weightsByDate(isnan([records.weight_measured])) = {[]}; + weightPctByDate = num2cell(([records.weight_measured]-iw)./(expected-iw)); + weightPctByDate = cellfun(@(x)sprintf('%.1f', x*100), weightPctByDate, 'uni', false); + weightPctByDate(isnan([records.weight_measured])) = {[]}; + + dat = horzcat(... + arrayfun(@(x)datestr(x), dates', 'uni', false), ... + weightsByDate', ... + arrayfun(@(x)sprintf('%.1f', 0.8*(x-iw)+iw), [records.weight_expected]', 'uni', false), ... + weightPctByDate'); + waterDat = (... + num2cell(horzcat([records.water_given]', [records.hydrogel_given]', ... + [records.water_given]'+[records.hydrogel_given]', [records.water_expected]',... + [records.water_given]'+[records.hydrogel_given]'-[records.water_expected]'))); + waterDat = cellfun(@(x)sprintf('%.2f', x), waterDat, 'uni', false); + dat = horzcat(dat, waterDat); + + set(histTable, 'ColumnName', {'date', 'meas. weight', '80% weight', 'weight pct', 'water', 'hydrogel', 'total', 'min water', 'excess'}, ... + 'Data', dat(end:-1:1,:),... + 'ColumnEditable', false(1,5)); + histbox.Widths = [ -1 725]; + end + end - set(wrTable, 'ColumnName', {'Name', 'Water Required', 'Remaining Requirement'}, ... - 'Data', horzcat({wr.nickname}', ... - cellfun(@(x)sprintf('%.2f',obj.round(x, 'up')),{wr.water_requirement_total}', 'uni', false), ... - wrdat'), ... - 'ColumnEditable', false(1,3)); - 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) + function viewAllSubjects(obj) + ai = obj.AlyxInstance; + 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 + wrBox = uix.VBox('Parent', f); + wrTable = uitable('Parent', wrBox,... + 'FontName', 'Consolas',... + 'RowName', []); + + htmlColor = @(colorNum)reshape(dec2hex(round(colorNum'*255),2)',1,6); + % colorgen = @(colorNum,text) ['
',text,'
']; + colorgen = @(colorNum,text) ['',text,'']; + + wrdat = cellfun(@(x)colorgen(1-double(x>0)*[0 0.3 0.3],... + sprintf('%.2f',obj.round(x, 'up'))), {wr.water_requirement_remaining}, 'uni', false); + + set(wrTable, 'ColumnName', {'Name', 'Water Required', 'Remaining Requirement'}, ... + 'Data', horzcat({wr.nickname}', ... + cellfun(@(x)sprintf('%.2f',obj.round(x, 'up')),{wr.water_requirement_total}', 'uni', false), ... + wrdat'), ... + 'ColumnEditable', false(1,3)); + 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 + + function log(obj, varargin) + % Function for displaying timestamped information about + % occurrences. If the LoggingDisplay property is unset, the + % message is printed to the command prompt. + % log(formatSpec, A1,... An) + % + % See also FPRINTF + message = sprintf(varargin{:}); + if ~isempty(obj.LoggingDisplay) + timestamp = datestr(now, 'dd-mm-yyyy HH:MM:SS'); + str = sprintf('[%s] %s', timestamp, message); + 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 - function log(obj, varargin) - % Function for displaying timestamped information about - % occurrences. If the LoggingDisplay property is unset, the - % message is printed to the command prompt. - % log(formatSpec, A1,... An) - % - % See also FPRINTF - message = sprintf(varargin{:}); - if ~isempty(obj.LoggingDisplay) - timestamp = datestr(now, 'dd-mm-yyyy HH:MM:SS'); - str = sprintf('[%s] %s', timestamp, message); - 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 - - methods (Static) - function A = round(a, direction, sigFigures) - if nargin < 3; sigFigures = 2; end - c = 1*10^sigFigures; - switch direction - case 'up' - A = ceil(a*c)/c; - case 'down' - A = ceil(a*c)/c; - otherwise - A = round(a*c)/c; - end + methods (Static) + function A = round(a, direction, sigFigures) + if nargin < 3; sigFigures = 2; end + c = 1*10^sigFigures; + switch direction + case 'up' + A = ceil(a*c)/c; + case 'down' + A = ceil(a*c)/c; + otherwise + A = round(a*c)/c; + end + end end - end end \ No newline at end of file From 150a61cb73e3c2cc27f8b4981fc3e835e56418af Mon Sep 17 00:00:00 2001 From: jaib1 Date: Fri, 26 Oct 2018 16:37:03 +0100 Subject: [PATCH 190/507] Updated Output to log when giving water in AlyxPanel --- +eui/AlyxPanel.m | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 9dc3e7ab..2126838a 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -291,7 +291,7 @@ function giveWater(obj) 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 - obj.log('%s administration of %.2f for %s posted successfully to alyx', type, 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 @@ -303,22 +303,29 @@ function giveFutureWater(obj) % future dates thisDate = now; prompt=sprintf('Enter space-separated numbers \n[tomorrow, day after that, day after that.. etc] \nEnter "0" to skip a day\nEnter "-1" to indicate training for that day'); - a=inputdlg(prompt,'Future Amounts', [1 50]); - if isempty(answer)||~obj.AlyxInstance.IsLoggedIn + amtStr=inputdlg(prompt,'Future Amounts', [1 50]); + if isempty(amtStr)||~obj.AlyxInstance.IsLoggedIn return % user pressed 'Close' or 'x' end - amt = str2num(answer{:}); % amount of water + amt = str2num(amtStr{:}); % amount of water futDates = thisDate + (1:length(amt)); %datenum of all input future dates futTrnDates = futDates(amt<0); %future training dates + dat.saveParamProfile('WeekendWater', obj.Subject, futTrnDates); + for d = 1:length(futTrnDates) + [~,day] = weekday(datestr(futTrnDates(d)), 'long'); + obj.log(['"%s" marked for training for ' day ' %s'],... + obj.Subject, datestr(futTrnDates(d), 'dd mmmm yyyy')); + end + futWtrDates = futDates(amt>0); %future water giving dates amtWtrDates = amt(amt>0); %amount of water to give on future water dates - dat.saveParamProfile('WeekendWater', obj.Subject, futTrnDates); for d = 1:length(futWtrDates) obj.AlyxInstance.postWater(obj.Subject, amtWtrDates(d), futWtrDates(d), 'Water'); - obj.log(['Hydrogel administration of %.2f for %s posted successfully to alyx for '... - datestr(futWtrDates(d))], amtWtrDates(d), obj.Subject); + [~,day] = weekday(datestr(futTrnDates(d)), 'long'); + obj.log(['Water administration of %.2f for "%s" posted successfully to alyx for ' day ' %s'],... + amtWtrDates(d), obj.Subject, datestr(futWtrDates(d), 'dd mmmm yyyy')); end end From 53bce3b245151383e747e79ca44f133f94b53677 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 29 Oct 2018 11:17:54 +0000 Subject: [PATCH 191/507] Future water bug fix, better error handling is dispWtrReq and consistent log formatting --- +eui/AlyxPanel.m | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 2126838a..73277f52 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -300,32 +300,36 @@ function giveWater(obj) function giveFutureWater(obj) % Open a dialog allowing one to input water submissions for - % future dates + % 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\nEnter "-1" to indicate training for that day'); - amtStr=inputdlg(prompt,'Future Amounts', [1 50]); + prompt = sprintf(['Enter space-separated numbers \n'... + '[tomorrow, day after that, day after that.. etc] \n',... + 'Enter "0" to skip a day\nEnter "-1" to indicate training for that day\n']); + amtStr = inputdlg(prompt,'Future Amounts', [1 50]); if isempty(amtStr)||~obj.AlyxInstance.IsLoggedIn return % user pressed 'Close' or 'x' end - amt = str2num(amtStr{:}); % amount of water - futDates = thisDate + (1:length(amt)); %datenum of all input future dates + 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 + futTrnDates = futDates(amt < 0); % future training dates dat.saveParamProfile('WeekendWater', obj.Subject, futTrnDates); - for d = 1:length(futTrnDates) - [~,day] = weekday(datestr(futTrnDates(d)), 'long'); - obj.log(['"%s" marked for training for ' day ' %s'],... - obj.Subject, datestr(futTrnDates(d), 'dd mmmm yyyy')); - end + [~,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)); - futWtrDates = futDates(amt>0); %future water giving dates - amtWtrDates = amt(amt>0); %amount of water to give on future water dates + 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), 'Water'); - [~,day] = weekday(datestr(futTrnDates(d)), 'long'); - obj.log(['Water administration of %.2f for "%s" posted successfully to alyx for ' day ' %s'],... - amtWtrDates(d), obj.Subject, datestr(futWtrDates(d), 'dd mmmm yyyy')); + [~,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 @@ -393,6 +397,8 @@ function dispWaterReq(obj, src, ~) 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 @@ -465,7 +471,7 @@ function launchSessionURL(obj) % 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 base session?', ... - ['No base session exists for ' datestr(now, 'yyyy-mm-dd')], ... + ['No base session exists for ' datestr(now, 'yyyy-mmm-dd')], ... 'Yes','No','No'); % Handle response switch choice From 633416a4ab64aa5363d6d3f56646402762d6910c Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 29 Oct 2018 14:22:26 +0000 Subject: [PATCH 192/507] Merge from dev, auto update of code every monday --- +srv/expServer.m | 2 +- .gitmodules | 4 ---- signals | 2 +- wheelAnalysis | 2 +- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/+srv/expServer.m b/+srv/expServer.m index 586f03b3..be5b3081 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -20,7 +20,7 @@ function expServer(useTimelineOverride, bgColour) %% Initialisation % Pull latest changes from remote -git.update(true); +git.update(true, 2); % Update ever Monday % random seed random number generator rng('shuffle'); % communicator for receiving commands from clients diff --git a/.gitmodules b/.gitmodules index 0022ed5b..d6e7a40f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,16 +1,12 @@ [submodule "alyx-matlab"] path = alyx-matlab url = https://github.com/cortex-lab/alyx-matlab/ - branch = dev [submodule "signals"] path = signals url = https://github.com/cortex-lab/signals - branch = dev [submodule "npy-matlab"] path = npy-matlab url = https://github.com/kwikteam/npy-matlab - branch = master [submodule "wheelAnalysis"] path = wheelAnalysis url = https://github.com/cortex-lab/wheelAnalysis - branch = master diff --git a/signals b/signals index 13db4d1d..4fa58ead 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 13db4d1d6a7e11427043b514867a3807eb1f12f7 +Subproject commit 4fa58ead37f36e6a40c96f3cafc5a60227fa1be4 diff --git a/wheelAnalysis b/wheelAnalysis index 16979786..05f90203 160000 --- a/wheelAnalysis +++ b/wheelAnalysis @@ -1 +1 @@ -Subproject commit 169797868da3fe93e1581cb4d581cdc4f4d9cd34 +Subproject commit 05f902033bc834c98624d5634be3cf91b737f250 From f943f4849ba79cdeb7184c82417905a1105529dd Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 31 Oct 2018 09:20:58 +0000 Subject: [PATCH 193/507] Returns index of named arg --- cb-tools/burgbox/namedArg.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cb-tools/burgbox/namedArg.m b/cb-tools/burgbox/namedArg.m index 1da86053..5e5820fb 100644 --- a/cb-tools/burgbox/namedArg.m +++ b/cb-tools/burgbox/namedArg.m @@ -1,4 +1,4 @@ -function [present, value] = namedArg(args, name) +function [present, value, defIdx] = namedArg(args, name) %NAMEDARG Returns value from name,value argument pairs % [present, value] = NAMEDARG(args, name) returns flag for presence and % value of the argument 'name' in a list potentially containing adjacent From e4cdf247ac6354600eb319fcb17db4b957bb7196 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 31 Oct 2018 09:39:44 +0000 Subject: [PATCH 194/507] Bug fix and SPX222 support --- +hw/WeighingScale.m | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/+hw/WeighingScale.m b/+hw/WeighingScale.m index 1a2630a0..f674802a 100644 --- a/+hw/WeighingScale.m +++ b/+hw/WeighingScale.m @@ -2,15 +2,18 @@ %HW.WEIGHINGSCALE Interface to a weighing scale connected via serial % Allows you to read the current weight from scales and tare it. This % class has been tested only with the ES-300HA 300gx0.01g Precision - % Scale + RS232 to USB Converter. + % Scale, and Ohaus SPX222 Scout Portable Balance 220G x 0.01g + RS232 + % to USB Converter. % % Part of Rigbox - - % 2013-02 CB created + + % 2013-02 CB created properties + Name = 'ES-300HA' % 'SPX222' ComPort = 'COM1' - TareCommand = hex2dec('54') + TareCommand = hex2dec('54') % For SPX222 use 'T' + FormatSpec = '%s %f %*s' % For SPX222 use '%f' Port = [] end @@ -38,6 +41,15 @@ function init(obj) set(obj.Port, 'BytesAvailableFcn', @obj.onBytesAvail); fopen(obj.Port); fprintf('Opened scales on "%s"\n', obj.ComPort); + switch obj.Name + case 'SPX222' + % Optional settings may be set manually instead + fprintf(obj.Port, 'IP'); % Auto print stable non-zero weight and stable zero reading + fprintf(obj.Port, '1M'); % Weight mode + fprintf(obj.Port, '1U'); % Weight unit grammes + otherwise + % Do nothing + end end end @@ -54,13 +66,14 @@ function delete(obj) cleanup(obj); end - function onBytesAvail(obj, src, evt) + function onBytesAvail(obj, src, ~) nr = src.BytesAvailable/13; for i = 1:nr - d = sscanf(fscanf(src),'%s %f %*s'); - g = d(2); - if d(1) == 45 - g = -g; + g = sscanf(fscanf(src), obj.FormatSpec); + if length(g) > 1 && g(1) == 45 + g = -g(2); % 45 == '-' + elseif length(g) > 1 && g(1) == 43 + g = g(2); % 43 == '+' end obj.LastGrams = g; notify(obj, 'NewReading'); @@ -69,4 +82,3 @@ function onBytesAvail(obj, src, evt) end end - From 1c05914056d57577d0e669365bc84b3025c0ebea Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 31 Oct 2018 11:25:09 +0000 Subject: [PATCH 195/507] Updates to signals & wheelAnalysis modules --- signals | 2 +- wheelAnalysis | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/signals b/signals index 13db4d1d..51f56c0d 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 13db4d1d6a7e11427043b514867a3807eb1f12f7 +Subproject commit 51f56c0d90ed2232eb3255a1773d45ad695adaf4 diff --git a/wheelAnalysis b/wheelAnalysis index 16979786..05f90203 160000 --- a/wheelAnalysis +++ b/wheelAnalysis @@ -1 +1 @@ -Subproject commit 169797868da3fe93e1581cb4d581cdc4f4d9cd34 +Subproject commit 05f902033bc834c98624d5634be3cf91b737f250 From 7148e9ef5ed20fb261ada2444441407092611b93 Mon Sep 17 00:00:00 2001 From: jaib1 Date: Wed, 31 Oct 2018 13:14:37 +0000 Subject: [PATCH 196/507] commentings on SignalsExp plus last signals subrepo commit --- +exp/SignalsExp.m | 1789 +++++++++++++++++++++++---------------------- 1 file changed, 900 insertions(+), 889 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 789ab556..679f18d2 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -1,921 +1,932 @@ classdef SignalsExp < handle - %EXP.SIGNALSEXP Base class for stimuli-delivering experiments - % The class defines a framework for event- and state-based experiments. - % Visual and auditory stimuli can be controlled by experiment phases. - % Phases changes are managed by an event-handling system. - % - % Part of Rigbox - - % 2012-11 CB created - - properties - %An array of event handlers. Each should specify the name of the - %event that activates it, callback functions to be executed when - %activated and an optional delay between the event and activation. - %They should be objects of class EventHandler. - EventHandlers = exp.EventHandler.empty - - %Timekeeper used by the experiment. Clocks return the current time. See - %the Clock class definition for more information. - Clock = hw.ptb.Clock - - %Key for terminating an experiment whilst running. Shoud be a - %Psychtoolbox keyscan code (see PTB KbName function). - QuitKey = KbName('q') - - PauseKey = KbName('esc') %Key for pausing an experiment - - %String description of the type of experiment, to be saved into the - %block data field 'expType'. - Type = '' - - %Reference for the rig that this experiment is being run on, to be - %saved into the block data field 'rigName'. - RigName - - %Communcator object for sending signals updates to mc. Set by - %expServer - Communicator = io.DummyCommunicator - - %Delay (secs) before starting main experiment phase after experiment - %init phase has completed - PreDelay = 0 - - %Delay (secs) before beginning experiment cleanup phase after - %main experiment phase has completed (assuming an immediate abort - %wasn't requested). - PostDelay = 0 - - %Flag indicating whether the experiment is paused - IsPaused = false - - %Holds the wheel object, 'mouseInput' from the rig object. See also - %USERIG, HW.DAQROTARYENCODER - Wheel - - %Holds the object for interating with the lick detector. See also - %HW.DAQEDGECOUNTER - LickDetector - - %Holds the object for interating with the DAQ outputs (reward valve, - %etc.) See also HW.DAQCONTROLLER - DaqController - - %Get the handle to the PTB window opened by expServer - StimWindowPtr - - TextureById - - LayersByStim - - %Occulus viewing model - Occ - - Time - - Inputs - - Outputs - - Events - - Visual - - Audio % = aud.AudioRegistry - - %Holds the parameters structure for this experiment - Params - - ParamsLog - - %The bounds for the photodiode square - SyncBounds - - %Sync colour cycle (usually [0, 255]) - cycles through these each - %time the screen flips. - SyncColourCycle - - %Index into SyncColourCycle for next sync colour - NextSyncIdx - - %Alyx instance from client. See also SAVEDATA - AlyxInstance = [] - end - - properties (SetAccess = protected) - %Number of stimulus window flips - StimWindowUpdateCount = 0 - - %Data from the currently running experiment, if any. - Data = struct - - %Currently active phases of the experiment. Cell array of their names - %(i.e. strings) - ActivePhases = {} - - Listeners - - Net - - SignalUpdates = struct('name', cell(500,1), 'value', cell(500,1), 'timestamp', cell(500,1)) - NumSignalUpdates = 0 - - end - - properties (Access = protected) - %Set triggers awaiting activation: a list of Triggered objects, which - %are awaiting activation pending completion of their delay period. - Pending - - IsLooping = false %flag indicating whether to continue in experiment loop - - AsyncFlipping = false - - StimWindowInvalid = false - end - - methods - function obj = SignalsExp(paramStruct, rig) - clock = rig.clock; - clockFun = @clock.now; - obj.TextureById = containers.Map('KeyType', 'char', 'ValueType', 'uint32'); - obj.LayersByStim = containers.Map; - obj.Inputs = sig.Registry(clockFun); - obj.Outputs = sig.Registry(clockFun); - obj.Visual = StructRef; - obj.Audio = audstream.Registry(rig.audioDevices); - obj.Events = sig.Registry(clockFun); - %% configure signals - net = sig.Net; - obj.Net = net; - obj.Time = net.origin('t'); - obj.Events.expStart = net.origin('expStart'); - obj.Events.newTrial = net.origin('newTrial'); - obj.Events.expStop = net.origin('expStop'); - obj.Inputs.wheel = net.origin('wheel'); - obj.Inputs.lick = net.origin('lick'); - obj.Inputs.keyboard = net.origin('keyboard'); - % get global parameters & conditional parameters structs - [~, globalStruct, allCondStruct] = toConditionServer(... - exp.Parameters(paramStruct)); - % start the first trial after expStart - advanceTrial = net.origin('advanceTrial'); - % configure parameters signal - globalPars = net.origin('globalPars'); - allCondPars = net.origin('condPars'); - [obj.Params, hasNext, obj.Events.repeatNum] = exp.trialConditions(... - globalPars, allCondPars, advanceTrial); - - obj.Events.trialNum = obj.Events.newTrial.scan(@plus, 0); % track trial number - - lastTrialOver = then(~hasNext, true); - -% obj.Events.expStop = then(~hasNext, true); - % run experiment definition - if ischar(paramStruct.defFunction) - expDefFun = fileFunction(paramStruct.defFunction); - obj.Data.expDef = paramStruct.defFunction; - else - expDefFun = paramStruct.defFunction; - obj.Data.expDef = func2str(expDefFun); - end - fprintf('takes %i args\n', nargout(expDefFun)); - expDefFun(obj.Time, obj.Events, obj.Params, obj.Visual, obj.Inputs,... - obj.Outputs, obj.Audio); - % listeners - obj.Listeners = [ - obj.Events.expStart.map(true).into(advanceTrial) %expStart signals advance - obj.Events.endTrial.into(advanceTrial) %endTrial signals advance - advanceTrial.map(true).keepWhen(hasNext).into(obj.Events.newTrial) %newTrial if more - lastTrialOver.into(obj.Events.expStop) %newTrial if more - onValue(obj.Events.expStop, @(~)quit(obj));]; -% obj.Events.trialNum.onValue(fun.partial(@fprintf, 'trial %i started\n'))]; - % initialise the parameter signals - globalPars.post(rmfield(globalStruct, 'defFunction')); - allCondPars.post(allCondStruct); - %% data struct - -% obj.Params = obj.Params.map(@(v)v, [], @(n,s)sig.Logger([n '[L]'],s)); - %initialise stim window frame times array, large enough for ~2 hours - obj.Data.stimWindowUpdateTimes = zeros(60*60*60*2, 1); - obj.Data.stimWindowRenderTimes = zeros(60*60*60*2, 1); -% obj.Data.stimWindowUpdateLags = zeros(60*60*60*2, 1); - obj.ParamsLog = obj.Params.log(); - obj.useRig(rig); + %EXP.SIGNALSEXP Base class for stimuli-delivering experiments + % The class defines a framework for event- and state-based experiments. + % Visual and auditory stimuli can be controlled by experiment phases. + % Phases changes are managed by an event-handling system. + % + % Part of Rigbox + + % 2012-11 CB created + + %% properties (default - all public) + + properties + %An array of event handlers. Each should specify the name of the + %event that activates it, callback functions to be executed when + %activated and an optional delay between the event and activation. + %They should be objects of class EventHandler. + EventHandlers = exp.EventHandler.empty + + %Timekeeper used by the experiment. Clocks return the current time. See + %the Clock class definition for more information. + Clock = hw.ptb.Clock + + %Key for terminating an experiment whilst running. Shoud be a + %Psychtoolbox keyscan code (see PTB KbName function). + QuitKey = KbName('q') + + PauseKey = KbName('esc') %Key for pausing an experiment + + %String description of the type of experiment, to be saved into the + %block data field 'expType'. + Type = '' + + %Reference for the rig that this experiment is being run on, to be + %saved into the block data field 'rigName'. + RigName + + %Communcator object for sending signals updates to mc. Set by + %expServer + Communicator = io.DummyCommunicator + + %Delay (secs) before starting main experiment phase after experiment + %init phase has completed + PreDelay = 0 + + %Delay (secs) before beginning experiment cleanup phase after + %main experiment phase has completed (assuming an immediate abort + %wasn't requested). + PostDelay = 0 + + %Flag indicating whether the experiment is paused + IsPaused = false + + %Holds the wheel object, 'mouseInput' from the rig object. See also + %USERIG, HW.DAQROTARYENCODER + Wheel + + %Holds the object for interating with the lick detector. See also + %HW.DAQEDGECOUNTER + LickDetector + + %Holds the object for interating with the DAQ outputs (reward valve, + %etc.) See also HW.DAQCONTROLLER + DaqController + + %Get the handle to the PTB window opened by expServer + StimWindowPtr + + TextureById + + LayersByStim + + %Occulus viewing model + Occ + + Time + + Inputs + + Outputs + + Events + + Visual + + Audio % = aud.AudioRegistry + + %Holds the parameters structure for this experiment + Params + + ParamsLog + + %The bounds for the photodiode square + SyncBounds + + %Sync colour cycle (usually [0, 255]) - cycles through these each + %time the screen flips. + SyncColourCycle + + %Index into SyncColourCycle for next sync colour + NextSyncIdx + + %Alyx instance from client. See also SAVEDATA + AlyxInstance = [] end - function useRig(obj, rig) - obj.Clock = rig.clock; - obj.Data.rigName = rig.name; - obj.SyncBounds = rig.stimWindow.SyncBounds; - obj.SyncColourCycle = rig.stimWindow.SyncColourCycle; - obj.NextSyncIdx = 1; - obj.StimWindowPtr = rig.stimWindow.PtbHandle; - obj.Occ = vis.init(obj.StimWindowPtr); - if isfield(rig, 'screens') - obj.Occ.screens = rig.screens; - else - warning('squeak:hw', 'No screen configuration specified. Visual locations will be wrong.'); - end - obj.DaqController = rig.daqController; - obj.Wheel = rig.mouseInput; - if isfield(rig, 'lickDetector') - obj.LickDetector = rig.lickDetector; - end - if ~isempty(obj.DaqController.SignalGenerators) - outputNames = fieldnames(obj.Outputs); % Get list of all outputs specified in expDef function - for m = 1:length(outputNames) - id = find(strcmp(outputNames{m},... - obj.DaqController.ChannelNames)); % Find matching channel from rig hardware file - if id % if the output is present, create callback - obj.Listeners = [obj.Listeners - obj.Outputs.(outputNames{m}).onValue(@(v)obj.DaqController.command([zeros(size(v,1),id-1) v])) % pad value with zeros in order to output to correct channel - obj.Outputs.(outputNames{m}).onValue(@(v)fprintf('delivering output of %.2f\n',v)) - ]; - elseif strcmp(outputNames{m}, 'reward') % special case; rewardValve is always first signals generator in list - obj.Listeners = [obj.Listeners - obj.Outputs.reward.onValue(@(v)obj.DaqController.command(v)) - obj.Outputs.reward.onValue(@(v)fprintf('delivering reward of %.2f\n', v)) - ]; - end - end - end - end - - function abortPendingHandlers(obj, handler) - if nargin < 2 - % Sets all pending triggers inactive - [obj.Pending(:).isActive] = deal(false); - else - % Sets pending triggers for specified handler inactive - abortList = ([obj.Pending.handler] == handler); - [obj.Pending(abortList).isActive] = deal(false); - end - end + %% properties (SetAccess = protected) - function startPhase(obj, name, time) - % Starts a phase - % - % startPhase(NAME, TIME) causes a phase to start. The phase is added - % to the list of active phases. The change is also signalled to any - % interested triggers as having occured at TIME. - % - % Note: The time specified is signalled to the triggers, which is - % important for maintaining rigid timing offsets even if there are - % delays in calling this function. e.g. if a trigger is set to go off - % one second after a phase starts, the trigger will become due one - % second after the time specified, *not* one second from calling this - % function. - - % make sure the phase isn't already active - if ~any(strcmpi(obj.ActivePhases, name)) - % add the phase from list - obj.ActivePhases = [obj.ActivePhases; name]; - - % action any triggers contingent on this phase change - fireEvent(obj, exp.EventInfo([name 'Started'], time, obj)); - end + properties (SetAccess = protected) + %Number of stimulus window flips + StimWindowUpdateCount = 0 + + %Data from the currently running experiment, if any. + Data = struct + + %Currently active phases of the experiment. Cell array of their names + %(i.e. strings) + ActivePhases = {} + + Listeners + + Net + + SignalUpdates = struct('name', cell(500,1), 'value', cell(500,1), 'timestamp', cell(500,1)) + NumSignalUpdates = 0 + end - - function endPhase(obj, name, time) - % Ends a phase - % - % endPhase(NAME, TIME) causes a phase to end. The phase is removed - % from the list of active phases. The event is also signalled to any - % interested triggers as having occured at TIME. - % - % Note: The time specified is signalled to the triggers, which is - % important for maintaining rigid timing offsets even if there are - % delays in calling this function. e.g. if a trigger is set to go off - % one second after a phase starts, the trigger will become due one - % second after the time specified, *not* one second from calling this - % function. - - % make sure the phase is active - if any(strcmpi(obj.ActivePhases, name)) - % remove the phase from list - obj.ActivePhases(strcmpi(obj.ActivePhases, name)) = []; - - % action any triggers contingent on this phase change - fireEvent(obj, exp.EventInfo([name 'Ended'], time, obj)); - end - end - function addEventHandler(obj, handler, varargin) - % Adds one or more event handlers - % - % addEventHandler(HANLDER) adds one or more handlers specified by the - % HANLDER parameter (either a single object of class EventHandler, or - % an array of them), to this experiment's list of handlers. - if iscell(handler) - handler = cell2mat(handler); - end - obj.EventHandlers = [obj.EventHandlers; handler(:)]; - if ~isempty(varargin) - % deal with extra handle arguments recursively - addEventHandler(obj, varargin{:}); - end + %% properties (SetAccess && GetAccess = protected) + + properties (Access = protected) + %Set triggers awaiting activation: a list of Triggered objects, which + %are awaiting activation pending completion of their delay period. + Pending + + IsLooping = false %flag indicating whether to continue in experiment loop + + AsyncFlipping = false + + StimWindowInvalid = false end - function data = run(obj, ref) - % Runs the experiment - % - % run(REF) will start the experiment running, first initialising - % everything, then running the experiment loop until the experiment - % is complete. REF is a reference to be saved with the block data - % under the 'expRef' field, and will be used to ascertain the - % location to save the data into. If REF is an empty, i.e. [], the - % data won't be saved. - - if ~isempty(ref) - %ensure experiment ref exists - assert(dat.expExists(ref), 'Experiment ref ''%s'' does not exist', ref); - end - - %do initialisation - init(obj); - - obj.Data.expRef = ref; %record the experiment reference - - %Trigger the 'experimentInit' event so any handlers will be called - initInfo = exp.EventInfo('experimentInit', obj.Clock.now, obj); - fireEvent(obj, initInfo); - - %set pending handler to begin the experiment 'PreDelay' secs from now - start = exp.EventHandler('experimentInit', exp.StartPhase('experiment')); - start.addCallback(@(varargin) obj.Events.expStart.post(ref)); - obj.Pending = dueHandlerInfo(obj, start, initInfo, obj.Clock.now + obj.PreDelay); - - %refresh the stimulus window - Screen('Flip', obj.StimWindowPtr); - - try - % start the experiment loop - mainLoop(obj); - - %post comms notification with event name and time - if isempty(obj.AlyxInstance) - post(obj, 'AlyxRequest', obj.Data.expRef); %request token from client - pause(0.2) + %% methods (default - all public) + + methods + function obj = SignalsExp(paramStruct, rig) + % configure clock, ...? + clock = rig.clock; %don't like shadowing the clock fn here... + clockFun = @clock.now; + obj.TextureById = containers.Map('KeyType', 'char', 'ValueType', 'uint32'); + obj.LayersByStim = containers.Map; + obj.Inputs = sig.Registry(clockFun); + obj.Outputs = sig.Registry(clockFun); + obj.Visual = StructRef; + obj.Audio = audstream.Registry(rig.audioDevices); + obj.Events = sig.Registry(clockFun); + % configure signals + net = sig.Net; + obj.Net = net; + obj.Time = net.origin('t'); + obj.Events.expStart = net.origin('expStart'); + obj.Events.newTrial = net.origin('newTrial'); + obj.Events.expStop = net.origin('expStop'); + obj.Inputs.wheel = net.origin('wheel'); + obj.Inputs.lick = net.origin('lick'); + obj.Inputs.keyboard = net.origin('keyboard'); + % get global parameters & conditional parameters structs + [~, globalStruct, allCondStruct] = toConditionServer(... + exp.Parameters(paramStruct)); + % start the first trial after expStart + advanceTrial = net.origin('advanceTrial'); + % configure parameters signal + globalPars = net.origin('globalPars'); + allCondPars = net.origin('condPars'); + [obj.Params, hasNext, obj.Events.repeatNum] = exp.trialConditions(... + globalPars, allCondPars, advanceTrial); + + obj.Events.trialNum = obj.Events.newTrial.scan(@plus, 0); % track trial number + + lastTrialOver = then(~hasNext, true); + + % obj.Events.expStop = then(~hasNext, true); + % run experiment definition + if ischar(paramStruct.defFunction) + expDefFun = fileFunction(paramStruct.defFunction); + obj.Data.expDef = paramStruct.defFunction; + else + expDefFun = paramStruct.defFunction; + obj.Data.expDef = func2str(expDefFun); + end + fprintf('takes %i args\n', nargout(expDefFun)); + expDefFun(obj.Time, obj.Events, obj.Params, obj.Visual, obj.Inputs,... + obj.Outputs, obj.Audio); + % listeners + obj.Listeners = [ + obj.Events.expStart.map(true).into(advanceTrial) %expStart signals advance + obj.Events.endTrial.into(advanceTrial) %endTrial signals advance + advanceTrial.map(true).keepWhen(hasNext).into(obj.Events.newTrial) %newTrial if more + lastTrialOver.into(obj.Events.expStop) %newTrial if more + onValue(obj.Events.expStop, @(~)quit(obj));]; + % obj.Events.trialNum.onValue(fun.partial(@fprintf, 'trial %i started\n'))]; + % initialise the parameter signals + globalPars.post(rmfield(globalStruct, 'defFunction')); + allCondPars.post(allCondStruct); + % data struct + + % obj.Params = obj.Params.map(@(v)v, [], @(n,s)sig.Logger([n '[L]'],s)); + % initialise stim window frame times array, large enough for ~2 hours + obj.Data.stimWindowUpdateTimes = zeros(60*60*60*2, 1); + obj.Data.stimWindowRenderTimes = zeros(60*60*60*2, 1); + % obj.Data.stimWindowUpdateLags = zeros(60*60*60*2, 1); + obj.ParamsLog = obj.Params.log(); + obj.useRig(rig); end - %Trigger the 'experimentCleanup' event so any handlers will be called - cleanupInfo = exp.EventInfo('experimentCleanup', obj.Clock.now, obj); - fireEvent(obj, cleanupInfo); + function useRig(obj, rig) + obj.Clock = rig.clock; + obj.Data.rigName = rig.name; + obj.SyncBounds = rig.stimWindow.SyncBounds; + obj.SyncColourCycle = rig.stimWindow.SyncColourCycle; + obj.NextSyncIdx = 1; + obj.StimWindowPtr = rig.stimWindow.PtbHandle; + obj.Occ = vis.init(obj.StimWindowPtr); + if isfield(rig, 'screens') + obj.Occ.screens = rig.screens; + else + warning('squeak:hw', 'No screen configuration specified. Visual locations will be wrong.'); + end + obj.DaqController = rig.daqController; + obj.Wheel = rig.mouseInput; + if isfield(rig, 'lickDetector') + obj.LickDetector = rig.lickDetector; + end + if ~isempty(obj.DaqController.SignalGenerators) + outputNames = fieldnames(obj.Outputs); % Get list of all outputs specified in expDef function + for m = 1:length(outputNames) + id = find(strcmp(outputNames{m},... + obj.DaqController.ChannelNames)); % Find matching channel from rig hardware file + if id % if the output is present, create callback + obj.Listeners = [obj.Listeners + obj.Outputs.(outputNames{m}).onValue(@(v)obj.DaqController.command([zeros(size(v,1),id-1) v])) % pad value with zeros in order to output to correct channel + obj.Outputs.(outputNames{m}).onValue(@(v)fprintf('delivering output of %.2f\n',v)) + ]; + elseif strcmp(outputNames{m}, 'reward') % special case; rewardValve is always first signals generator in list + obj.Listeners = [obj.Listeners + obj.Outputs.reward.onValue(@(v)obj.DaqController.command(v)) + obj.Outputs.reward.onValue(@(v)fprintf('delivering reward of %.2f\n', v)) + ]; + end + end + end + end - %do our cleanup - cleanup(obj); + function abortPendingHandlers(obj, handler) + if nargin < 2 + % Sets all pending triggers inactive + [obj.Pending(:).isActive] = deal(false); + else + % Sets pending triggers for specified handler inactive + abortList = ([obj.Pending.handler] == handler); + [obj.Pending(abortList).isActive] = deal(false); + end + end - %return the data structure that has been built up - data = obj.Data; + function startPhase(obj, name, time) + % Starts a phase + % + % startPhase(NAME, TIME) causes a phase to start. The phase is added + % to the list of active phases. The change is also signalled to any + % interested triggers as having occured at TIME. + % + % Note: The time specified is signalled to the triggers, which is + % important for maintaining rigid timing offsets even if there are + % delays in calling this function. e.g. if a trigger is set to go off + % one second after a phase starts, the trigger will become due one + % second after the time specified, *not* one second from calling this + % function. + + % make sure the phase isn't already active + if ~any(strcmpi(obj.ActivePhases, name)) + % add the phase from list + obj.ActivePhases = [obj.ActivePhases; name]; - if ~isempty(ref) - saveData(obj); %save the data + % action any triggers contingent on this phase change + fireEvent(obj, exp.EventInfo([name 'Started'], time, obj)); + end end - catch ex - %mark that an exception occured in the block data, then save - obj.Data.endStatus = 'exception'; - obj.Data.exceptionMessage = ex.message; - if ~isempty(ref) - saveData(obj); %save the data + + function endPhase(obj, name, time) + % Ends a phase + % + % endPhase(NAME, TIME) causes a phase to end. The phase is removed + % from the list of active phases. The event is also signalled to any + % interested triggers as having occured at TIME. + % + % Note: The time specified is signalled to the triggers, which is + % important for maintaining rigid timing offsets even if there are + % delays in calling this function. e.g. if a trigger is set to go off + % one second after a phase starts, the trigger will become due one + % second after the time specified, *not* one second from calling this + % function. + + % make sure the phase is active + if any(strcmpi(obj.ActivePhases, name)) + % remove the phase from list + obj.ActivePhases(strcmpi(obj.ActivePhases, name)) = []; + + % action any triggers contingent on this phase change + fireEvent(obj, exp.EventInfo([name 'Ended'], time, obj)); + end end - ensureWindowReady(obj); % complete any outstanding refresh - %rethrow the exception - rethrow(ex) - end - end - - function bool = inPhase(obj, name) - % Reports whether currently in specified phase - % - % inPhase(NAME) checks whether the experiment is currently in the - % phase called NAME. - bool = any(strcmpi(obj.ActivePhases, name)); - end - - function log(obj, field, value) - % Logs the value in the experiment data - if isfield(obj.Data, field) - obj.Data.(field) = [obj.Data.(field) value]; - else - obj.Data.(field) = value; - end - end - - function quit(obj, immediately) - if isempty(obj.Events.expStop.Node.CurrValue) - obj.Events.expStop.post(true); - end - %stop delay timers. todo: need to use a less global tag - tmrs = timerfind('Tag', 'sig.delay'); - if ~isempty(tmrs) - stop(tmrs); - delete(tmrs); - end - - % set any pending handlers inactive - abortPendingHandlers(obj); - - % clear all phases except 'experiment' "dirtily", i.e. without - % setting off any triggers for those phases. - % *** IN FUTURE MAY CHANGE SO THAT WE DO END TRIAL CLEANLY *** - if nargin < 2 - immediately = false; - end - - if inPhase(obj, 'experiment') - obj.ActivePhases = {'experiment'}; % clear active phases except experiment - % end the experiment phase "cleanly", i.e. with triggers - endPhase(obj, 'experiment', obj.Clock.now); - else - obj.ActivePhases = {}; %clear active phases - end - - if immediately - %flag as 'aborted' meaning terminated early, and as quickly as possible - obj.Data.endStatus = 'aborted'; - else - %flag as 'quit', meaning quit before all trials were naturally complete, - %but still shut down with usual cleanup delays etc - obj.Data.endStatus = 'quit'; - end - - if immediately || obj.PostDelay == 0 - obj.IsLooping = false; %unset looping flag now - else - %add a pending handler to unset looping flag - %NB, since we create a pending item directly, the EventHandler delay - %and triggering event name are only set for clarity and wont be - %used - endExp = exp.EventHandler('experimentEnded'); %event name just for clarity - endExp.Delay = obj.PostDelay; %delay just for clarity - endExp.addCallback(@stopLooping); - pending = dueHandlerInfo(obj, endExp, [], obj.Clock.now + obj.PostDelay); - obj.Pending = [obj.Pending, pending]; - end - - function stopLooping(varargin) - obj.IsLooping = false; - end - end - - function ensureWindowReady(obj) - % complete any outstanding asynchronous flip - if obj.AsyncFlipping - % wait for flip to complete, and record the time - time = Screen('AsyncFlipEnd', obj.StimWindowPtr); - obj.AsyncFlipping = false; - time = fromPtb(obj.Clock, time); %convert ptb/sys time to our clock's time -% assert(obj.Data.stimWindowUpdateTimes(obj.StimWindowUpdateCount) == 0); - obj.Data.stimWindowUpdateTimes(obj.StimWindowUpdateCount) = time; -% lag = time - obj.Data.stimWindowRenderTimes(obj.StimWindowUpdateCount); -% obj.Data.stimWindowUpdateLags(obj.StimWindowUpdateCount) = lag; - end - end - - function queueSignalUpdate(obj, name, value) - timestamp = clock; - nupdates = obj.NumSignalUpdates; - if nupdates == length(obj.SignalUpdates) - %grow message queue by doubling in size - obj.SignalUpdates(2*end+1).value = []; - end - idx = nupdates + 1; - obj.SignalUpdates(idx).name = name; - obj.SignalUpdates(idx).value = value; - obj.SignalUpdates(idx).timestamp = timestamp; - obj.NumSignalUpdates = idx; - end - - function post(obj, id, msg) - send(obj.Communicator, id, msg); - end - - function sendSignalUpdates(obj) - try - if obj.NumSignalUpdates > 0 - post(obj, 'signals', obj.SignalUpdates(1:obj.NumSignalUpdates)); + + function addEventHandler(obj, handler, varargin) + % Adds one or more event handlers + % + % addEventHandler(HANLDER) adds one or more handlers specified by the + % HANLDER parameter (either a single object of class EventHandler, or + % an array of them), to this experiment's list of handlers. + if iscell(handler) + handler = cell2mat(handler); + end + obj.EventHandlers = [obj.EventHandlers; handler(:)]; + if ~isempty(varargin) + % deal with extra handle arguments recursively + addEventHandler(obj, varargin{:}); + end end - catch ex - warning(getReport(ex)); - end - obj.NumSignalUpdates = 0; - end - - function loadVisual(obj, name) - %% configure signals - layersSig = obj.Visual.(name).Node.CurrValue.layers; - obj.Listeners = [obj.Listeners - layersSig.onValue(fun.partial(@obj.newLayerValues, name))]; - newLayerValues(obj, name, layersSig.Node.CurrValue); - -% %% load textures -% layerData = obj.LayersByStim(name); -% Screen('BeginOpenGL', win); -% try -% for ii = 1:numel(layerData) -% id = layerData(ii).textureId; -% if ~obj.TextureById.isKey(id) -% obj.TextureById(id) = ... -% vis.loadLayerTextures(layerData(ii)); -% end -% end -% catch glEx -% Screen('EndOpenGL', win); -% rethrow(glEx); -% end -% Screen('EndOpenGL', win); - end - - function newLayerValues(obj, name, val) -% fprintf('new layer value for %s\n', name); -% show = [val.show] - if isKey(obj.LayersByStim, name) - prev = obj.LayersByStim(name); - prevshow = any([prev.show]); - else - prevshow = false; - end - obj.LayersByStim(name) = val; - - if any([val.show]) || prevshow - obj.StimWindowInvalid = true; - end - - end - - function delete(obj) - disp('delete exp.SqueakExp'); - obj.Net.delete(); - end - end - - methods (Access = protected) - function init(obj) - % Performs initialisation before running - % - % init() is called when the experiment is run before the experiment - % loop begins. Subclasses can override to perform their own - % initialisation, but must chain a call to this. - - % create and initialise a key press queue for responding to input - KbQueueCreate(); - KbQueueStart(); - - % MATLAB time stamp for starting the experiment - obj.Data.startDateTime = now; - obj.Data.startDateTimeStr = datestr(obj.Data.startDateTime); - - %init end status to nothing - obj.Data.endStatus = []; - - % load each visual stimulus - cellfun(@obj.loadVisual, fieldnames(obj.Visual)); - % each event signal should send signal updates - queuefun = @(n,s)s.onValue(fun.partial(@queueSignalUpdate, obj, n)); - evtlist = mapToCell(@(n,v)queuefun(['events.' n],v),... - fieldnames(obj.Events), struct2cell(obj.Events)); - outlist = mapToCell(@(n,v)queuefun(['outputs.' n],v),... - fieldnames(obj.Outputs), struct2cell(obj.Outputs)); - inlist = mapToCell(@(n,v)queuefun(['inputs.' n],v),... - fieldnames(obj.Inputs), struct2cell(obj.Inputs)); - parslist = queuefun('pars', obj.Params); - obj.Listeners = vertcat(obj.Listeners, ... - evtlist(:), outlist(:), inlist(:), parslist(:)); - end - - function cleanup(obj) - % Performs cleanup after experiment completes - % - % cleanup() is called when the experiment is run after the experiment - % loop completes. Subclasses can override to perform their own - % cleanup, but must chain a call to this. - - stopdatetime = now; - %clear the stimulus window - Screen('Flip', obj.StimWindowPtr); - - % collate the logs - %events - obj.Data.events = logs(obj.Events, obj.Clock.ReferenceTime); - %params - parsLog = obj.ParamsLog.Node.CurrValue; - obj.Data.paramsValues = [parsLog.value]; - obj.Data.paramsTimes = [parsLog.time]; - %inputs - obj.Data.inputs = logs(obj.Inputs, obj.Clock.ReferenceTime); - %outputs - obj.Data.outputs = logs(obj.Outputs, obj.Clock.ReferenceTime); - %audio -% obj.Data.audio = logs(audio, clockZeroTime); - - % MATLAB time stamp for ending the experiment - obj.Data.endDateTime = stopdatetime; - obj.Data.endDateTimeStr = datestr(obj.Data.endDateTime); - - % some useful data - obj.Data.duration = etime(... - datevec(obj.Data.endDateTime), datevec(obj.Data.startDateTime)); - - %clip the stim window update times array - obj.Data.stimWindowUpdateTimes((obj.StimWindowUpdateCount + 1):end) = []; -% obj.Data.stimWindowUpdateLags((obj.StimWindowUpdateCount + 1):end) = []; - obj.Data.stimWindowRenderTimes((obj.StimWindowUpdateCount + 1):end) = []; - - % release resources - obj.Listeners = []; - deleteGlTextures(obj); - KbQueueStop(); - KbQueueRelease(); - - % delete cached experiment definition function from memory - [~, exp_func] = fileparts(obj.Data.expDef); - clear(exp_func) - end - - function deleteGlTextures(obj) - tex = cell2mat(obj.TextureById.values); - win = obj.StimWindowPtr; - fprintf('Deleting %i textures\n', numel(tex)); - Screen('AsyncFlipEnd', win); - Screen('BeginOpenGL', win); - glDeleteTextures(numel(tex), tex); - obj.TextureById.remove(obj.TextureById.keys); - Screen('EndOpenGL', win); - end - - function mainLoop(obj) - % Executes the main experiment loop - % - % mainLoop() enters a loop that updates the stimulus window, checks - % for and deals with inputs, updates state and activates triggers. - % Will run until the experiment completes (phase 'experiment' ends). - - %set looping flag - obj.IsLooping = true; - t = obj.Clock.now; - % begin the loop - while obj.IsLooping - %% create a list of handlers that have become due - dueIdx = find([obj.Pending.dueTime] <= now(obj.Clock)); - ndue = length(dueIdx); - - %% check for and process any input - checkInput(obj); - - %% execute pending event handlers that have become due - for i = 1:ndue - due = obj.Pending(dueIdx(i)); - if due.isActive % check handler is still active - activateEventHandler(obj, due.handler, due.eventInfo, due.dueTime); - obj.Pending(dueIdx(i)).isActive = false; % set as inactive in pending - end + + function data = run(obj, ref) + % Runs the experiment + % + % run(REF) will start the experiment running, first initialising + % everything, then running the experiment loop until the experiment + % is complete. REF is a reference to be saved with the block data + % under the 'expRef' field, and will be used to ascertain the + % location to save the data into. If REF is an empty, i.e. [], the + % data won't be saved. + + if ~isempty(ref) + %ensure experiment ref exists + assert(dat.expExists(ref), 'Experiment ref ''%s'' does not exist', ref); + end + + %do initialisation + init(obj); + + obj.Data.expRef = ref; %record the experiment reference + + %Trigger the 'experimentInit' event so any handlers will be called + initInfo = exp.EventInfo('experimentInit', obj.Clock.now, obj); + fireEvent(obj, initInfo); + + %set pending handler to begin the experiment 'PreDelay' secs from now + start = exp.EventHandler('experimentInit', exp.StartPhase('experiment')); + start.addCallback(@(varargin) obj.Events.expStart.post(ref)); + obj.Pending = dueHandlerInfo(obj, start, initInfo, obj.Clock.now + obj.PreDelay); + + %refresh the stimulus window + Screen('Flip', obj.StimWindowPtr); + + try + % start the experiment loop + mainLoop(obj); + + %post comms notification with event name and time + if isempty(obj.AlyxInstance) + post(obj, 'AlyxRequest', obj.Data.expRef); %request token from client + pause(0.2) + end + + %Trigger the 'experimentCleanup' event so any handlers will be called + cleanupInfo = exp.EventInfo('experimentCleanup', obj.Clock.now, obj); + fireEvent(obj, cleanupInfo); + + %do our cleanup + cleanup(obj); + + %return the data structure that has been built up + data = obj.Data; + + if ~isempty(ref) + saveData(obj); %save the data + end + catch ex + %mark that an exception occured in the block data, then save + obj.Data.endStatus = 'exception'; + obj.Data.exceptionMessage = ex.message; + if ~isempty(ref) + saveData(obj); %save the data + end + ensureWindowReady(obj); % complete any outstanding refresh + %rethrow the exception + rethrow(ex) + end end - - % now remove executed (or otherwise inactived) ones from pending - inactiveIdx = ~[obj.Pending.isActive]; - obj.Pending(inactiveIdx) = []; - - %% signalling -% tic - wx = readAbsolutePosition(obj.Wheel); - post(obj.Inputs.wheel, wx); - if ~isempty(obj.LickDetector) - % read and log the current lick count - [nlicks, ~, licked] = readPosition(obj.LickDetector); - if licked - post(obj.Inputs.lick, nlicks); - fprintf('lick count now %i\n', nlicks); - end + + function bool = inPhase(obj, name) + % Reports whether currently in specified phase + % + % inPhase(NAME) checks whether the experiment is currently in the + % phase called NAME. + bool = any(strcmpi(obj.ActivePhases, name)); end - post(obj.Time, now(obj.Clock)); - runSchedule(obj.Net); - -% runSchedule(obj.Net); -% nChars = overfprintf(nChars, 'post took %.1fms\n', 1000*toc); - - %% redraw the stimulus window if it has been invalidated - if obj.StimWindowInvalid - ensureWindowReady(obj); % complete any outstanding refresh - % draw the visual frame -% tic - drawFrame(obj); -% toc; - if ~isempty(obj.SyncBounds) % render sync rectangle - % render sync region with next colour in cycle - col = obj.SyncColourCycle(obj.NextSyncIdx,:); - % render rectangle in the sync region bounds in the required colour - Screen('FillRect', obj.StimWindowPtr, col, obj.SyncBounds); - % cyclically increment the next sync idx - obj.NextSyncIdx = mod(obj.NextSyncIdx, size(obj.SyncColourCycle, 1)) + 1; - end - renderTime = now(obj.Clock); - % start the 'flip' of the frame onto the screen - Screen('AsyncFlipBegin', obj.StimWindowPtr); - obj.AsyncFlipping = true; - obj.StimWindowUpdateCount = obj.StimWindowUpdateCount + 1; - obj.Data.stimWindowRenderTimes(obj.StimWindowUpdateCount) = renderTime; - obj.StimWindowInvalid = false; + + function log(obj, field, value) + % Logs the value in the experiment data + if isfield(obj.Data, field) + obj.Data.(field) = [obj.Data.(field) value]; + else + obj.Data.(field) = value; + end end - if (obj.Clock.now - t) > 0.1 - sendSignalUpdates(obj); - t = obj.Clock.now; + + function quit(obj, immediately) + if isempty(obj.Events.expStop.Node.CurrValue) + obj.Events.expStop.post(true); + end + %stop delay timers. todo: need to use a less global tag + tmrs = timerfind('Tag', 'sig.delay'); + if ~isempty(tmrs) + stop(tmrs); + delete(tmrs); + end + + % set any pending handlers inactive + abortPendingHandlers(obj); + + % clear all phases except 'experiment' "dirtily", i.e. without + % setting off any triggers for those phases. + % *** IN FUTURE MAY CHANGE SO THAT WE DO END TRIAL CLEANLY *** + if nargin < 2 + immediately = false; + end + + if inPhase(obj, 'experiment') + obj.ActivePhases = {'experiment'}; % clear active phases except experiment + % end the experiment phase "cleanly", i.e. with triggers + endPhase(obj, 'experiment', obj.Clock.now); + else + obj.ActivePhases = {}; %clear active phases + end + + if immediately + %flag as 'aborted' meaning terminated early, and as quickly as possible + obj.Data.endStatus = 'aborted'; + else + %flag as 'quit', meaning quit before all trials were naturally complete, + %but still shut down with usual cleanup delays etc + obj.Data.endStatus = 'quit'; + end + + if immediately || obj.PostDelay == 0 + obj.IsLooping = false; %unset looping flag now + else + %add a pending handler to unset looping flag + %NB, since we create a pending item directly, the EventHandler delay + %and triggering event name are only set for clarity and wont be + %used + endExp = exp.EventHandler('experimentEnded'); %event name just for clarity + endExp.Delay = obj.PostDelay; %delay just for clarity + endExp.addCallback(@stopLooping); + pending = dueHandlerInfo(obj, endExp, [], obj.Clock.now + obj.PostDelay); + obj.Pending = [obj.Pending, pending]; + end + + function stopLooping(varargin) + obj.IsLooping = false; + end end -% q = toc; -% if q>0.005 -% fprintf(1, 'send updates took %.1fms\n', 1000*toc); -% end - drawnow; % allow other callbacks to execute - end - ensureWindowReady(obj); % complete any outstanding refresh - end - - function checkInput(obj) - % Checks for and handles inputs during experiment - % - % checkInput() is called during the experiment loop to check for and - % handle any inputs. This function specifically checks for any - % keyboard input that occurred since the last check, and passes that - % information on to handleKeyboardInput. Subclasses should override - % this function to check for any non-keyboard inputs of interest, but - % must chain a call to this function. - [pressed, keysPressed] = KbQueueCheck(); - if pressed - if any(keysPressed(obj.QuitKey)) - % handle the quit key being pressed - if strcmp(obj.Data.endStatus, 'quit') - %quitting already in progress - a second time means fast abort - fprintf('Abort fast (quit key pressed during quitting)\n'); - obj.quit(true); - else - fprintf('Quit key pressed\n'); - obj.quit(false); - end - elseif any(keysPressed(obj.PauseKey)) - fprintf('Pause key pressed\n'); - if obj.IsPaused - resume(obj); - else - pause(obj); - end - else -% key = keysPressed(find(keysPressed~=obj.QuitKey&... -% keysPressed~=obj.PauseKey,1,'first')); - key = KbName(keysPressed); - if ~isempty(key) - post(obj.Inputs.keyboard, key(1)); - end + function ensureWindowReady(obj) + % complete any outstanding asynchronous flip + if obj.AsyncFlipping + % wait for flip to complete, and record the time + time = Screen('AsyncFlipEnd', obj.StimWindowPtr); + obj.AsyncFlipping = false; + time = fromPtb(obj.Clock, time); %convert ptb/sys time to our clock's time + % assert(obj.Data.stimWindowUpdateTimes(obj.StimWindowUpdateCount) == 0); + obj.Data.stimWindowUpdateTimes(obj.StimWindowUpdateCount) = time; + % lag = time - obj.Data.stimWindowRenderTimes(obj.StimWindowUpdateCount); + % obj.Data.stimWindowUpdateLags(obj.StimWindowUpdateCount) = lag; + end end - end - end - - function fireEvent(obj, eventInfo, logEvent) - %fireEvent Called when an event occurs to log and handle them - % fireEvent(EVENTINFO) logs the time of the event, and checks the list - % of experiment event handlers for any that are listening to the event - % detailed in EVENTINFO. Those that are will be activated after their - % desired delay period. EVENTINFO must be an object of class EventInfo. - - event = eventInfo.Event; - - %post comms notification with event name and time - tnow = now(obj.Clock); - msg = {'update', obj.Data.expRef, 'event', event, tnow}; - post(obj, 'status', msg); - - if nargin < 3 - % log events by default - logEvent = true; - end - - if logEvent - % Save the actual time the event completed. For events that occur - % during a trial, timestamps are saved within the trial data, otherwise - % we just save in experiment-wide data. - log(obj, [event 'Time'], tnow); - end - - % create a list of handlers for this event - if isempty(obj.EventHandlers) - % handle special case bug in matlab - % if EventHandlers is empty, the alternate case below will fail so - % we handle it here - handleEventNames = {}; - else - handleEventNames = {obj.EventHandlers.Event}; - end - - evexp = ['(^|\|)' event '($|\|)']; - match = ~strcmp(regexp(handleEventNames, evexp, 'match', 'once'), ''); - handlers = obj.EventHandlers(match); - nhandlers = length(handlers); - for i = 1:nhandlers - if islogical(handlers(i).Delay) && handlers(i).Delay == false - % delay is false, so activate immediately - due = eventInfo.Time; - immediate = true; - else - % delayed handler - due = eventInfo.Time + handlers(i).Delay.secs; - immediate = false; + + function queueSignalUpdate(obj, name, value) + timestamp = clock; + nupdates = obj.NumSignalUpdates; + if nupdates == length(obj.SignalUpdates) + %grow message queue by doubling in size + obj.SignalUpdates(2*end+1).value = []; + end + idx = nupdates + 1; + obj.SignalUpdates(idx).name = name; + obj.SignalUpdates(idx).value = value; + obj.SignalUpdates(idx).timestamp = timestamp; + obj.NumSignalUpdates = idx; + end + + function post(obj, id, msg) + send(obj.Communicator, id, msg); end - - % if the handler has no delay, then activate it now, - % otherwise add it to our pending list - if immediate - activateEventHandler(obj, handlers(i), eventInfo, due); - else - pending = dueHandlerInfo(obj, handlers(i), eventInfo, due); - obj.Pending = [obj.Pending, pending]; % append to pending handlers + + function sendSignalUpdates(obj) + try + if obj.NumSignalUpdates > 0 + post(obj, 'signals', obj.SignalUpdates(1:obj.NumSignalUpdates)); + end + catch ex + warning(getReport(ex)); + end + obj.NumSignalUpdates = 0; + end + + function loadVisual(obj, name) + % configure signals + layersSig = obj.Visual.(name).Node.CurrValue.layers; + obj.Listeners = [obj.Listeners + layersSig.onValue(fun.partial(@obj.newLayerValues, name))]; + newLayerValues(obj, name, layersSig.Node.CurrValue); + + % %% load textures + % layerData = obj.LayersByStim(name); + % Screen('BeginOpenGL', win); + % try + % for ii = 1:numel(layerData) + % id = layerData(ii).textureId; + % if ~obj.TextureById.isKey(id) + % obj.TextureById(id) = ... + % vis.loadLayerTextures(layerData(ii)); + % end + % end + % catch glEx + % Screen('EndOpenGL', win); + % rethrow(glEx); + % end + % Screen('EndOpenGL', win); + end + + function newLayerValues(obj, name, val) + % fprintf('new layer value for %s\n', name); + % show = [val.show] + if isKey(obj.LayersByStim, name) + prev = obj.LayersByStim(name); + prevshow = any([prev.show]); + else + prevshow = false; + end + obj.LayersByStim(name) = val; + + if any([val.show]) || prevshow + obj.StimWindowInvalid = true; + end + + end + + function delete(obj) + disp('delete exp.SqueakExp'); + obj.Net.delete(); end - end - end - - function s = dueHandlerInfo(~, handler, eventInfo, dueTime) - s = struct('handler', handler,... - 'eventInfo', eventInfo,... - 'dueTime', dueTime,... - 'isActive', true); % handlers starts active - end - - function drawFrame(obj) - % Called to draw current stimulus window frame - % - % drawFrame(obj) does nothing in this class but can be overrriden - % in a subclass to draw the stimulus frame when it is invalidated - win = obj.StimWindowPtr; - layerValues = cell2mat(obj.LayersByStim.values()); - Screen('BeginOpenGL', win); - vis.draw(win, obj.Occ, layerValues, obj.TextureById); - Screen('EndOpenGL', win); end - function activateEventHandler(obj, handler, eventInfo, dueTime) - activate(handler, eventInfo, dueTime); - % if the handler requests the stimulus window be invalided, do so. - if handler.InvalidateStimWindow - obj.StimWindow.invalidate; - end - end + %% methods (SetAccess && GetAccess = protected) - function saveData(obj) - % save the data to the appropriate locations indicated by expRef - savepaths = dat.expFilePath(obj.Data.expRef, 'block'); - superSave(savepaths, struct('block', obj.Data)); - [subject, ~, ~] = dat.parseExpRef(obj.Data.expRef); - - % if this is a 'ChoiceWorld' experiment, let's save out for - % relevant data for basic behavioural analysis and register them to - % Alyx - if contains(lower(obj.Data.expDef), 'choiceworld') ... - && ~strcmp(subject, 'default') && isfield(obj.Data, 'events') ... - && ~strcmp(obj.Data.endStatus,'aborted') - try - fullpath = alf.block2ALF(obj.Data); - obj.AlyxInstance.registerFile(fullpath); - catch ex - warning(ex.identifier, 'Failed to register alf files: %s.', ex.message); + methods (Access = protected) + function init(obj) + % Performs initialisation before running + % + % init() is called when the experiment is run before the experiment + % loop begins. Subclasses can override to perform their own + % initialisation, but must chain a call to this. + + % create and initialise a key press queue for responding to input + KbQueueCreate(); + KbQueueStart(); + + % MATLAB time stamp for starting the experiment + obj.Data.startDateTime = now; + obj.Data.startDateTimeStr = datestr(obj.Data.startDateTime); + + %init end status to nothing + obj.Data.endStatus = []; + + % load each visual stimulus + cellfun(@obj.loadVisual, fieldnames(obj.Visual)); + % each event signal should send signal updates + queuefun = @(n,s)s.onValue(fun.partial(@queueSignalUpdate, obj, n)); + evtlist = mapToCell(@(n,v)queuefun(['events.' n],v),... + fieldnames(obj.Events), struct2cell(obj.Events)); + outlist = mapToCell(@(n,v)queuefun(['outputs.' n],v),... + fieldnames(obj.Outputs), struct2cell(obj.Outputs)); + inlist = mapToCell(@(n,v)queuefun(['inputs.' n],v),... + fieldnames(obj.Inputs), struct2cell(obj.Inputs)); + parslist = queuefun('pars', obj.Params); + obj.Listeners = vertcat(obj.Listeners, ... + evtlist(:), outlist(:), inlist(:), parslist(:)); + end + + function cleanup(obj) + % Performs cleanup after experiment completes + % + % cleanup() is called when the experiment is run after the experiment + % loop completes. Subclasses can override to perform their own + % cleanup, but must chain a call to this. + + stopdatetime = now; + %clear the stimulus window + Screen('Flip', obj.StimWindowPtr); + + % collate the logs + %events + obj.Data.events = logs(obj.Events, obj.Clock.ReferenceTime); + %params + parsLog = obj.ParamsLog.Node.CurrValue; + obj.Data.paramsValues = [parsLog.value]; + obj.Data.paramsTimes = [parsLog.time]; + %inputs + obj.Data.inputs = logs(obj.Inputs, obj.Clock.ReferenceTime); + %outputs + obj.Data.outputs = logs(obj.Outputs, obj.Clock.ReferenceTime); + %audio + % obj.Data.audio = logs(audio, clockZeroTime); + + % MATLAB time stamp for ending the experiment + obj.Data.endDateTime = stopdatetime; + obj.Data.endDateTimeStr = datestr(obj.Data.endDateTime); + + % some useful data + obj.Data.duration = etime(... + datevec(obj.Data.endDateTime), datevec(obj.Data.startDateTime)); + + %clip the stim window update times array + obj.Data.stimWindowUpdateTimes((obj.StimWindowUpdateCount + 1):end) = []; + % obj.Data.stimWindowUpdateLags((obj.StimWindowUpdateCount + 1):end) = []; + obj.Data.stimWindowRenderTimes((obj.StimWindowUpdateCount + 1):end) = []; + + % release resources + obj.Listeners = []; + deleteGlTextures(obj); + KbQueueStop(); + KbQueueRelease(); + + % delete cached experiment definition function from memory + [~, exp_func] = fileparts(obj.Data.expDef); + clear(exp_func) + end + + function deleteGlTextures(obj) + tex = cell2mat(obj.TextureById.values); + win = obj.StimWindowPtr; + fprintf('Deleting %i textures\n', numel(tex)); + Screen('AsyncFlipEnd', win); + Screen('BeginOpenGL', win); + glDeleteTextures(numel(tex), tex); + obj.TextureById.remove(obj.TextureById.keys); + Screen('EndOpenGL', win); + end + + function mainLoop(obj) + % Executes the main experiment loop + % + % mainLoop() enters a loop that updates the stimulus window, checks + % for and deals with inputs, updates state and activates triggers. + % Will run until the experiment completes (phase 'experiment' ends). + + %set looping flag + obj.IsLooping = true; + t = obj.Clock.now; + % begin the loop + while obj.IsLooping + % create a list of handlers that have become due + dueIdx = find([obj.Pending.dueTime] <= now(obj.Clock)); + ndue = length(dueIdx); + + % check for and process any input + checkInput(obj); + + % execute pending event handlers that have become due + for i = 1:ndue + due = obj.Pending(dueIdx(i)); + if due.isActive % check handler is still active + activateEventHandler(obj, due.handler, due.eventInfo, due.dueTime); + obj.Pending(dueIdx(i)).isActive = false; % set as inactive in pending + end + end + + % now remove executed (or otherwise inactived) ones from pending + inactiveIdx = ~[obj.Pending.isActive]; + obj.Pending(inactiveIdx) = []; + + % signalling + % tic + wx = readAbsolutePosition(obj.Wheel); + post(obj.Inputs.wheel, wx); + if ~isempty(obj.LickDetector) + % read and log the current lick count + [nlicks, ~, licked] = readPosition(obj.LickDetector); + if licked + post(obj.Inputs.lick, nlicks); + fprintf('lick count now %i\n', nlicks); + end + end + post(obj.Time, now(obj.Clock)); + runSchedule(obj.Net); + + % runSchedule(obj.Net); + % nChars = overfprintf(nChars, 'post took %.1fms\n', 1000*toc); + + % redraw the stimulus window if it has been invalidated + if obj.StimWindowInvalid + ensureWindowReady(obj); % complete any outstanding refresh + % draw the visual frame + % tic + drawFrame(obj); + % toc; + if ~isempty(obj.SyncBounds) % render sync rectangle + % render sync region with next colour in cycle + col = obj.SyncColourCycle(obj.NextSyncIdx,:); + % render rectangle in the sync region bounds in the required colour + Screen('FillRect', obj.StimWindowPtr, col, obj.SyncBounds); + % cyclically increment the next sync idx + obj.NextSyncIdx = mod(obj.NextSyncIdx, size(obj.SyncColourCycle, 1)) + 1; + end + renderTime = now(obj.Clock); + % start the 'flip' of the frame onto the screen + Screen('AsyncFlipBegin', obj.StimWindowPtr); + obj.AsyncFlipping = true; + obj.StimWindowUpdateCount = obj.StimWindowUpdateCount + 1; + obj.Data.stimWindowRenderTimes(obj.StimWindowUpdateCount) = renderTime; + obj.StimWindowInvalid = false; + end + if (obj.Clock.now - t) > 0.1 + sendSignalUpdates(obj); + t = obj.Clock.now; + end + + % q = toc; + % if q>0.005 + % fprintf(1, 'send updates took %.1fms\n', 1000*toc); + % end + drawnow; % allow other callbacks to execute + end + ensureWindowReady(obj); % complete any outstanding refresh end - end - - if isempty(obj.AlyxInstance) - warning('No Alyx token set'); - else - try - subject = dat.parseExpRef(obj.Data.expRef); - if strcmp(subject, 'default'); return; end - % Register saved files - obj.AlyxInstance.registerFile(savepaths{end}); -% obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... -% {subject, expDate, seq}, 'Block', []); - % Save the session end time - if ~isempty(obj.AlyxInstance.SessionURL) - numCorrect = []; - if isfield(obj.Data, 'events') - numTrials = length(obj.Data.events.endTrialValues); - if isfield(obj.Data.events, 'feedbackValues') - numCorrect = sum(obj.Data.events.feedbackValues == 1); - end + + function checkInput(obj) + % Checks for and handles inputs during experiment + % + % checkInput() is called during the experiment loop to check for and + % handle any inputs. This function specifically checks for any + % keyboard input that occurred since the last check, and passes that + % information on to handleKeyboardInput. Subclasses should override + % this function to check for any non-keyboard inputs of interest, but + % must chain a call to this function. + [pressed, keysPressed] = KbQueueCheck(); + if pressed + if any(keysPressed(obj.QuitKey)) + % handle the quit key being pressed + if strcmp(obj.Data.endStatus, 'quit') + %quitting already in progress - a second time means fast abort + fprintf('Abort fast (quit key pressed during quitting)\n'); + obj.quit(true); + else + fprintf('Quit key pressed\n'); + obj.quit(false); + end + elseif any(keysPressed(obj.PauseKey)) + fprintf('Pause key pressed\n'); + if obj.IsPaused + resume(obj); + else + pause(obj); + end + else + % key = keysPressed(find(keysPressed~=obj.QuitKey&... + % keysPressed~=obj.PauseKey,1,'first')); + key = KbName(keysPressed); + if ~isempty(key) + post(obj.Inputs.keyboard, key(1)); + end + end + end + end + + function fireEvent(obj, eventInfo, logEvent) + %fireEvent Called when an event occurs to log and handle them + % fireEvent(EVENTINFO) logs the time of the event, and checks the list + % of experiment event handlers for any that are listening to the event + % detailed in EVENTINFO. Those that are will be activated after their + % desired delay period. EVENTINFO must be an object of class EventInfo. + + event = eventInfo.Event; + + %post comms notification with event name and time + tnow = now(obj.Clock); + msg = {'update', obj.Data.expRef, 'event', event, tnow}; + post(obj, 'status', msg); + + if nargin < 3 + % log events by default + logEvent = true; + end + + if logEvent + % Save the actual time the event completed. For events that occur + % during a trial, timestamps are saved within the trial data, otherwise + % we just save in experiment-wide data. + log(obj, [event 'Time'], tnow); + end + + % create a list of handlers for this event + if isempty(obj.EventHandlers) + % handle special case bug in matlab + % if EventHandlers is empty, the alternate case below will fail so + % we handle it here + handleEventNames = {}; + else + handleEventNames = {obj.EventHandlers.Event}; + end + + evexp = ['(^|\|)' event '($|\|)']; + match = ~strcmp(regexp(handleEventNames, evexp, 'match', 'once'), ''); + handlers = obj.EventHandlers(match); + nhandlers = length(handlers); + for i = 1:nhandlers + if islogical(handlers(i).Delay) && handlers(i).Delay == false + % delay is false, so activate immediately + due = eventInfo.Time; + immediate = true; + else + % delayed handler + due = eventInfo.Time + handlers(i).Delay.secs; + immediate = false; + end + + % if the handler has no delay, then activate it now, + % otherwise add it to our pending list + if immediate + activateEventHandler(obj, handlers(i), eventInfo, due); + else + pending = dueHandlerInfo(obj, handlers(i), eventInfo, due); + obj.Pending = [obj.Pending, pending]; % append to pending handlers + end + end + end + + function s = dueHandlerInfo(~, handler, eventInfo, dueTime) + s = struct('handler', handler,... + 'eventInfo', eventInfo,... + 'dueTime', dueTime,... + 'isActive', true); % handlers starts active + end + + function drawFrame(obj) + % Called to draw current stimulus window frame + % + % drawFrame(obj) does nothing in this class but can be overrriden + % in a subclass to draw the stimulus frame when it is invalidated + win = obj.StimWindowPtr; + layerValues = cell2mat(obj.LayersByStim.values()); + Screen('BeginOpenGL', win); + vis.draw(win, obj.Occ, layerValues, obj.TextureById); + Screen('EndOpenGL', win); + end + + function activateEventHandler(obj, handler, eventInfo, dueTime) + activate(handler, eventInfo, dueTime); + % if the handler requests the stimulus window be invalided, do so. + if handler.InvalidateStimWindow + obj.StimWindow.invalidate; + end + end + + function saveData(obj) + % save the data to the appropriate locations indicated by expRef + savepaths = dat.expFilePath(obj.Data.expRef, 'block'); + superSave(savepaths, struct('block', obj.Data)); + [subject, ~, ~] = dat.parseExpRef(obj.Data.expRef); + + % if this is a 'ChoiceWorld' experiment, let's save out for + % relevant data for basic behavioural analysis and register them to + % Alyx + if contains(lower(obj.Data.expDef), 'choiceworld') ... + && ~strcmp(subject, 'default') && isfield(obj.Data, 'events') ... + && ~strcmp(obj.Data.endStatus,'aborted') + try + fullpath = alf.block2ALF(obj.Data); + obj.AlyxInstance.registerFile(fullpath); + catch ex + warning(ex.identifier, 'Failed to register alf files: %s.', ex.message); + end + end + + if isempty(obj.AlyxInstance) + warning('No Alyx token set'); else - numTrials = 0; - numCorrect = 0; - end - sessionData = struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject); - if ~isempty(numTrials); sessionData.numberOfTrials = numTrials; end - if ~isempty(numCorrect); sessionData.numberOfCorrectTrials = numCorrect; end - obj.AlyxInstance.postData(obj.AlyxInstance.SessionURL,... - sessionData, 'put'); - else - % Retrieve session from endpoint -% subsessions = obj.AlyxInstance.getData(... -% sprintf('sessions?type=Experiment&subject=%s&number=%i', subject, seq)); - end - catch ex - warning(ex.identifier, 'Failed to register files to Alyx: %s', ex.message); + try + subject = dat.parseExpRef(obj.Data.expRef); + if strcmp(subject, 'default'); return; end + % Register saved files + obj.AlyxInstance.registerFile(savepaths{end}); + % obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... + % {subject, expDate, seq}, 'Block', []); + % Save the session end time + if ~isempty(obj.AlyxInstance.SessionURL) + numCorrect = []; + if isfield(obj.Data, 'events') + numTrials = length(obj.Data.events.endTrialValues); + if isfield(obj.Data.events, 'feedbackValues') + numCorrect = sum(obj.Data.events.feedbackValues == 1); + end + else + numTrials = 0; + numCorrect = 0; + end + sessionData = struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject); + if ~isempty(numTrials); sessionData.numberOfTrials = numTrials; end + if ~isempty(numCorrect); sessionData.numberOfCorrectTrials = numCorrect; end + obj.AlyxInstance.postData(obj.AlyxInstance.SessionURL,... + sessionData, 'put'); + else + % Retrieve session from endpoint + % subsessions = obj.AlyxInstance.getData(... + % sprintf('sessions?type=Experiment&subject=%s&number=%i', subject, seq)); + end + catch ex + warning(ex.identifier, 'Failed to register files to Alyx: %s', ex.message); + end + end end - end end - end - + end \ No newline at end of file From 9bba914111732d86a258518acf0f28072aea3072 Mon Sep 17 00:00:00 2001 From: jaib1 Date: Wed, 31 Oct 2018 13:22:34 +0000 Subject: [PATCH 197/507] repoint submodules back at Jai's forks --- .gitmodules | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.gitmodules b/.gitmodules index 0022ed5b..32b91d63 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,16 +1,16 @@ [submodule "alyx-matlab"] path = alyx-matlab - url = https://github.com/cortex-lab/alyx-matlab/ + url = https://github.com/jaib1/alyx-matlab/ branch = dev [submodule "signals"] path = signals - url = https://github.com/cortex-lab/signals + url = https://github.com/jaib1/signals branch = dev [submodule "npy-matlab"] path = npy-matlab - url = https://github.com/kwikteam/npy-matlab - branch = master + url = https://github.com/jaib1/npy-matlab + branch = dev [submodule "wheelAnalysis"] path = wheelAnalysis - url = https://github.com/cortex-lab/wheelAnalysis - branch = master + url = https://github.com/jaib1/wheelAnalysis + branch = dev From 66f8d8f4448f8133cdb61a9e921048e05034edb7 Mon Sep 17 00:00:00 2001 From: jaib1 Date: Wed, 31 Oct 2018 13:33:26 +0000 Subject: [PATCH 198/507] last changes made to signals subrepo --- signals | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/signals b/signals index 13db4d1d..0596b553 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 13db4d1d6a7e11427043b514867a3807eb1f12f7 +Subproject commit 0596b553fc7283ced49d2e403f6b681e37e00096 From 00af9896bd1723cb9671eba89e5565bd36f81d0b Mon Sep 17 00:00:00 2001 From: jaib1 Date: Wed, 31 Oct 2018 15:08:25 +0000 Subject: [PATCH 199/507] typo fixes for readme --- readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index fa163b12..120f73ae 100644 --- a/readme.md +++ b/readme.md @@ -1,7 +1,7 @@ ---------- # Rigbox -Rigbox is a (mostly) object-oriented MATLAB software package for designing and controlling behavioural experiments (principally, the [steering wheel setup](https://www.ucl.ac.uk/cortexlab/tools/wheel) which [we](https://www.ucl.ac.uk/cortexlab) developed to probe mouse behaviour. Rigbox requires two machines, one for stimulus presentation ('the stimulus server') and another for controlling and monitoring the experiment ('mc'). +Rigbox is a (mostly) object-oriented MATLAB software package for designing and controlling neurophysiological behavioural experiments (principally, the [steering wheel setup](https://www.ucl.ac.uk/cortexlab/tools/wheel) which [we](https://www.ucl.ac.uk/cortexlab) developed to probe mouse behaviour). Rigbox requires two machines, one for stimulus presentation ('the stimulus server') and another for controlling and monitoring the experiment ('mc'). ## Getting Started @@ -75,7 +75,7 @@ This opens a GUI that will allow you to choose a subject, edit some of the exper ## Code organization -Below is a list of the principle directories and their general purpose. +Below is a list of Rigbox's subdirectories and an overview of their respective contents. ### +dat From 814cfde112841d41f94fd888a1b918eb695532f3 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 31 Oct 2018 16:30:54 +0000 Subject: [PATCH 200/507] View all subjects window is now a better size --- +eui/AlyxPanel.m | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 73277f52..94c848bc 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -629,7 +629,10 @@ function viewAllSubjects(obj) 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',... From 4ec10e684f85a870f6730de99f5d489eb0dc9471 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 15 Nov 2018 12:04:48 +0000 Subject: [PATCH 201/507] Wheel signal availiable in degrees and mm --- +exp/SignalsExp.m | 5 ++- +hw/DaqRotaryEncoder.m | 71 +++++++++++++++++++++++++++++++++--------- +hw/PositionSensor.m | 3 +- 3 files changed, 63 insertions(+), 16 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index d261de2c..8647baa6 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -220,6 +220,9 @@ function useRig(obj, rig) obj.DaqController = rig.daqController; obj.Wheel = rig.mouseInput; obj.Wheel.zero(); + obj.Inputs.wheelMM = obj.Inputs.wheel.map(@(x)obj.Wheel.MillimetresFactor*(x-obj.Wheel.ZeroOffset)); + obj.Inputs.wheelDeg = obj.Inputs.wheel.map(... + @(x)((x-obj.Wheel.ZeroOffset) / (obj.Wheel.EncoderResolution*4))*360); if isfield(rig, 'lickDetector') obj.LickDetector = rig.lickDetector; obj.LickDetector.zero(); @@ -719,7 +722,7 @@ function mainLoop(obj) obj.Data.stimWindowRenderTimes(obj.StimWindowUpdateCount) = renderTime; obj.StimWindowInvalid = false; end - if (obj.Clock.now - t) > 0.1 + if (obj.Clock.now - t) > 0.1 || obj.IsLooping == false sendSignalUpdates(obj); t = obj.Clock.now; end diff --git a/+hw/DaqRotaryEncoder.m b/+hw/DaqRotaryEncoder.m index c3d014fc..2d6cc123 100644 --- a/+hw/DaqRotaryEncoder.m +++ b/+hw/DaqRotaryEncoder.m @@ -36,31 +36,61 @@ % 2013-01 CB created properties - DaqSession = [] %DAQ session for input (see session-based interface docs) - DaqId = 'Dev1' %DAQ's device ID, e.g. 'Dev1' - DaqChannelId = 'ctr0' %DAQ's ID for the counter channel. e.g. 'ctr0' - %Size of DAQ counter range for detecting over- and underflows (e.g. if - %the DAQ's counter is 32-bit, this should be 2^32) + % DAQ session for input (see session-based interface docs) + DaqSession = [] + % DAQ's device ID, e.g. 'Dev1' + DaqId = 'Dev1' + % DAQ's ID for the counter channel. e.g. 'ctr0' + % Size of DAQ counter range for detecting over- and underflows (e.g. if + % the DAQ's counter is 32-bit, this should be 2^32) + DaqChannelId = 'ctr0' DaqCounterPeriod = 2^32 end + properties (SetObservable) + % Number of pulses per revolution. Found at the end of the K�BLER + % product number, e.g. 05.2400.1122.0100 has a resolution of 100 + EncoderResolution = 1024 + % Diameter of the wheel in mm + WheelDiameter = 62 + end + properties (Access = protected) - %Created when listenForAvailableData is called, allowing logging of - %positions during DAQ background acquision - DaqListener - DaqInputChannelIdx %Index into acquired input data matrices for our channel - LastDaqValue %Last value obtained from the DAQ counter - %Accumulated cycle number for position (i.e. when the DAQ's counter has - %over- or underflowed its range, this is incremented or decremented - %accordingly) + % Index into acquired input data matrices for our channel + DaqInputChannelIdx + % Last value obtained from the DAQ counter Accumulated cycle number for + % position (i.e. when the DAQ's counter has over- or underflowed its + % range, this is incremented or decremented accordingly) + LastDaqValue Cycle end + properties (Transient, Access = protected) + % Created when listenForAvailableData is called, allowing logging of + % positions during DAQ background acquision + DaqListener + PropertyListener + end + properties (Dependent) - DaqChannelIdx % index into DaqSession's channels for our data + % Index into DaqSession's channels for our data + DaqChannelIdx end methods + + function obj = DaqRotaryEncoder() + p1 = findprop(obj,'EncoderResolution'); + p2 = findprop(obj,'WheelDiameter'); + obj.PropertyListener = event.proplistener(obj,[p1, p2],'PostSet',... + @(src,~)obj.setMillimetresFactor(src)); + setMillimetresFactor(obj); + end + + function setMillimetresFactor(obj,~) + obj.MillimetresFactor = obj.WheelDiameter*pi/(obj.EncoderResolution*4); + end + function value = get.DaqChannelIdx(obj) inputs = find(strcmpi('input', io.daqSessionChannelDirections(obj.DaqSession))); value = inputs(obj.DaqInputChannelIdx); @@ -155,5 +185,18 @@ function daqListener(obj, ~, event) logSamples(obj, values, times); end end + + methods (Static) + function obj = loadobj(obj) + if isstruct(obj) % Handle error + obj = hw.DaqRotaryEncoder(); + else + p1 = findprop(obj,'EncoderResolution'); + p2 = findprop(obj,'WheelDiameter'); + obj.PropertyListener = event.proplistener(obj,[p1, p2],'PostSet',... + @(src,~)obj.setMillimetresFactor(src)); + end + end + end end diff --git a/+hw/PositionSensor.m b/+hw/PositionSensor.m index 350bbfe2..99ee3c62 100644 --- a/+hw/PositionSensor.m +++ b/+hw/PositionSensor.m @@ -1,7 +1,8 @@ classdef PositionSensor < hw.DataLogging %HW.POSITIONSENSOR Abstract class for tracking positions from a sensor % Takes care of logging positions and times every time readPosition is - % called. Has a zeroing function and a gain parameter. + % called. Has a zeroing function and a gain parameter. This class is + % intended only for linear position sensors. % % Part of Rigbox From 3d23d11e2e643e821f745cc992b8744184af3df8 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 15 Nov 2018 15:09:37 +0000 Subject: [PATCH 202/507] Moved useRig to be before expDef run --- +exp/SignalsExp.m | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 8647baa6..b96ee89e 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -165,11 +165,9 @@ allCondPars = net.origin('condPars'); [obj.Params, hasNext, obj.Events.repeatNum] = exp.trialConditions(... globalPars, allCondPars, advanceTrial); - obj.Events.trialNum = obj.Events.newTrial.scan(@plus, 0); % track trial number - lastTrialOver = then(~hasNext, true); - + obj.useRig(rig); % obj.Events.expStop = then(~hasNext, true); % run experiment definition if ischar(paramStruct.defFunction) @@ -201,7 +199,6 @@ obj.Data.stimWindowRenderTimes = zeros(60*60*60*2, 1); % obj.Data.stimWindowUpdateLags = zeros(60*60*60*2, 1); obj.ParamsLog = obj.Params.log(); - obj.useRig(rig); end function useRig(obj, rig) From 7d222c1ad9a5d6c1bff09cb7499584f90b870ea6 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 15 Nov 2018 16:01:25 +0000 Subject: [PATCH 203/507] Skip repeats on wheel trace and zero offset now accessable --- +exp/SignalsExp.m | 5 +++-- +hw/PositionSensor.m | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index b96ee89e..e84a864f 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -217,9 +217,10 @@ function useRig(obj, rig) obj.DaqController = rig.daqController; obj.Wheel = rig.mouseInput; obj.Wheel.zero(); - obj.Inputs.wheelMM = obj.Inputs.wheel.map(@(x)obj.Wheel.MillimetresFactor*(x-obj.Wheel.ZeroOffset)); + obj.Inputs.wheelMM = obj.Inputs.wheel.map(@... + (x)obj.Wheel.MillimetresFactor*(x-obj.Wheel.ZeroOffset)).skipRepeats(); obj.Inputs.wheelDeg = obj.Inputs.wheel.map(... - @(x)((x-obj.Wheel.ZeroOffset) / (obj.Wheel.EncoderResolution*4))*360); + @(x)((x-obj.Wheel.ZeroOffset) / (obj.Wheel.EncoderResolution*4))*360).skipRepeats(); if isfield(rig, 'lickDetector') obj.LickDetector = rig.lickDetector; obj.LickDetector.zero(); diff --git a/+hw/PositionSensor.m b/+hw/PositionSensor.m index 99ee3c62..87aac7f1 100644 --- a/+hw/PositionSensor.m +++ b/+hw/PositionSensor.m @@ -18,7 +18,7 @@ LastPosition %Most recent position read end - properties (Access = protected) + properties (SetAccess = protected) ZeroOffset = 0 end From 385d6ba3acad6cf724bd88ecf9a543c67919009c Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 16 Nov 2018 14:27:04 +0000 Subject: [PATCH 204/507] Moved wheel out of useRig method: conflict with setting output --- +exp/SignalsExp.m | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index e84a864f..8b0ded5b 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -153,6 +153,12 @@ obj.Events.newTrial = net.origin('newTrial'); obj.Events.expStop = net.origin('expStop'); obj.Inputs.wheel = net.origin('wheel'); + obj.Wheel = rig.mouseInput; + obj.Wheel.zero(); + obj.Inputs.wheelMM = obj.Inputs.wheel.map(@... + (x)obj.Wheel.MillimetresFactor*(x-obj.Wheel.ZeroOffset)).skipRepeats(); + obj.Inputs.wheelDeg = obj.Inputs.wheel.map(... + @(x)((x-obj.Wheel.ZeroOffset) / (obj.Wheel.EncoderResolution*4))*360).skipRepeats(); obj.Inputs.lick = net.origin('lick'); obj.Inputs.keyboard = net.origin('keyboard'); % get global parameters & conditional parameters structs @@ -167,7 +173,6 @@ globalPars, allCondPars, advanceTrial); obj.Events.trialNum = obj.Events.newTrial.scan(@plus, 0); % track trial number lastTrialOver = then(~hasNext, true); - obj.useRig(rig); % obj.Events.expStop = then(~hasNext, true); % run experiment definition if ischar(paramStruct.defFunction) @@ -199,6 +204,7 @@ obj.Data.stimWindowRenderTimes = zeros(60*60*60*2, 1); % obj.Data.stimWindowUpdateLags = zeros(60*60*60*2, 1); obj.ParamsLog = obj.Params.log(); + obj.useRig(rig); end function useRig(obj, rig) @@ -215,12 +221,6 @@ function useRig(obj, rig) warning('squeak:hw', 'No screen configuration specified. Visual locations will be wrong.'); end obj.DaqController = rig.daqController; - obj.Wheel = rig.mouseInput; - obj.Wheel.zero(); - obj.Inputs.wheelMM = obj.Inputs.wheel.map(@... - (x)obj.Wheel.MillimetresFactor*(x-obj.Wheel.ZeroOffset)).skipRepeats(); - obj.Inputs.wheelDeg = obj.Inputs.wheel.map(... - @(x)((x-obj.Wheel.ZeroOffset) / (obj.Wheel.EncoderResolution*4))*360).skipRepeats(); if isfield(rig, 'lickDetector') obj.LickDetector = rig.lickDetector; obj.LickDetector.zero(); From 81d7e8d39e146d48fc6d864b2f6bdb6ba8260b70 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 16 Nov 2018 14:56:46 +0000 Subject: [PATCH 205/507] Fix to paths; npy-matlab added properly --- addRigboxPaths.m | 2 +- alyx-matlab | 2 +- cortexlab/+git/changes.m | 1 + cortexlab/+git/update.m | 7 +++++++ npy-matlab | 2 +- signals | 2 +- 6 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 cortexlab/+git/changes.m diff --git a/addRigboxPaths.m b/addRigboxPaths.m index cd5d85ee..b705367e 100644 --- a/addRigboxPaths.m +++ b/addRigboxPaths.m @@ -110,7 +110,7 @@ function addRigboxPaths(savePaths) % NumPy binary files. Used by Rigbox to save data as .npy files with the % ALF (ALex File) naming convention. For more information please visit: % https://docs.scipy.org/doc/numpy-dev/neps/npy-format.html -addpath(fullfile(root, 'npy-matlab')); +addpath(fullfile(root, 'npy-matlab', 'npy-matlab')); % Add the Java paths for Java WebSockets used for communications between % the stimulus computer and the master computer diff --git a/alyx-matlab b/alyx-matlab index ddc6f766..5420b606 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit ddc6f766fc25e1a57cc6c61a90aa5083cbd46543 +Subproject commit 5420b6065ccca9b438827fc1419fe30566e2ed7b diff --git a/cortexlab/+git/changes.m b/cortexlab/+git/changes.m new file mode 100644 index 00000000..9232640b --- /dev/null +++ b/cortexlab/+git/changes.m @@ -0,0 +1 @@ +addRigboxPaths; \ No newline at end of file diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index 14814d6b..52705ca1 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -59,5 +59,12 @@ function update(fatalOnError, scheduled) end end +% Run any new tasks +changesPath = fullfile(root, 'cortexlab', '+git', 'changes.m'); +if exist(changesPath, 'file') + git.changes; + delete(changesPath); +end + cd(origDir) end \ No newline at end of file diff --git a/npy-matlab b/npy-matlab index a99e00f7..b7b0a4ef 160000 --- a/npy-matlab +++ b/npy-matlab @@ -1 +1 @@ -Subproject commit a99e00f78c72a7ec5f9c3074242ffaf242de9448 +Subproject commit b7b0a4ef6ba26d98a8c54e651d5444083c88311c diff --git a/signals b/signals index 51f56c0d..19fb9da3 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 51f56c0d90ed2232eb3255a1773d45ad695adaf4 +Subproject commit 19fb9da3c4485782c81f2e57d43337da0b77940d From 8982b199b4bdd1fbaa76efc110114e931cae8c84 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 16 Nov 2018 15:35:44 +0000 Subject: [PATCH 206/507] Update from master --- signals | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/signals b/signals index 19fb9da3..071e4420 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 19fb9da3c4485782c81f2e57d43337da0b77940d +Subproject commit 071e44207410dab429f5c962999a56ade4d050dc From b53653c9d99feb69301a6030f00fe96b06bceb81 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 16 Nov 2018 17:12:39 +0000 Subject: [PATCH 207/507] Docstring fix --- +hw/TLOutputChrono.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/+hw/TLOutputChrono.m b/+hw/TLOutputChrono.m index 1bf9c52d..bacca3fe 100644 --- a/+hw/TLOutputChrono.m +++ b/+hw/TLOutputChrono.m @@ -56,8 +56,8 @@ function init(obj, timeline) % INIT Initialize the output session % INIT(obj, timeline) is called when timeline is initialized. - % Creates the DAQ session and ensures that the clocking pulse test - % can not be read back + % Creates the DAQ session and ensures that the clocking test pulse + % can be read back % % See Also HW.TIMELINE/INIT if obj.Enable From 64aa3ca54870ac002ea5781eaa8f5aa0e5df4f62 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 16 Nov 2018 17:23:43 +0000 Subject: [PATCH 208/507] Fix log for when no future train dates --- +eui/AlyxPanel.m | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 94c848bc..76cf0377 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -316,11 +316,13 @@ function giveFutureWater(obj) futDates = thisDate + (1:length(amt)); % datenum of all input future dates futTrnDates = futDates(amt < 0); % future training dates - 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)); + 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)); + end futWtrDates = futDates(amt > 0); % future water giving dates amtWtrDates = amt(amt > 0); % amount of water to give on future water dates From 9b7a815c18695a93c87bda8f7278578d508e9289 Mon Sep 17 00:00:00 2001 From: Olivier Winter Date: Mon, 19 Nov 2018 16:59:14 +0000 Subject: [PATCH 209/507] Alyx changes in subject endpoint --- +dat/parseAlyxInstance.m | 6 +++--- +eui/AlyxPanel.m | 8 ++++---- +eui/MControl.m | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/+dat/parseAlyxInstance.m b/+dat/parseAlyxInstance.m index 2eb09711..9db5cf4a 100644 --- a/+dat/parseAlyxInstance.m +++ b/+dat/parseAlyxInstance.m @@ -18,9 +18,9 @@ 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'); + % remove water remaining_water field + if isfield(ai, 'remaining_water') + ai = rmfield(ai, 'remaining_water'); end fname = fieldnames(ai); % get fieldnames emp = structfun(@isempty, ai); % find empty fields diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 73277f52..53e2c083 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -382,12 +382,12 @@ function dispWaterReq(obj, src, ~) weight_pct = '> 80%'; end % Round up water remaining to the near 0.01 - remainder = obj.round(s(idx).water_requirement_remaining, 'up'); + 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).water_requirement_total, 'up'), weight, ... + remainder, obj.round(s(idx).expected_water, 'up'), weight, ... weight_pct, obj.round(sum([water gel]), 'down'))); % Set WaterRemaining attribute for changeWaterText callback obj.WaterRemaining = remainder; @@ -640,11 +640,11 @@ function viewAllSubjects(obj) colorgen = @(colorNum,text) ['',text,'']; wrdat = cellfun(@(x)colorgen(1-double(x>0)*[0 0.3 0.3],... - sprintf('%.2f',obj.round(x, 'up'))), {wr.water_requirement_remaining}, 'uni', false); + sprintf('%.2f',obj.round(x, 'up'))), {wr.remaining_water}, 'uni', false); set(wrTable, 'ColumnName', {'Name', 'Water Required', 'Remaining Requirement'}, ... 'Data', horzcat({wr.nickname}', ... - cellfun(@(x)sprintf('%.2f',obj.round(x, 'up')),{wr.water_requirement_total}', 'uni', false), ... + cellfun(@(x)sprintf('%.2f',obj.round(x, 'up')),{wr.expected_water}', 'uni', false), ... wrdat'), ... 'ColumnEditable', false(1,3)); end diff --git a/+eui/MControl.m b/+eui/MControl.m index 9e5a30ca..3ca11966 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -346,8 +346,8 @@ function rigExpStopped(obj, rig, evt) % Announce that the experiment has stopped subject = dat.parseExpRef(evt.Ref); 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); From a8c895c66cd0f70431ed771693bf06441de2da93 Mon Sep 17 00:00:00 2001 From: Olivier Winter Date: Mon, 19 Nov 2018 18:01:18 +0000 Subject: [PATCH 210/507] eui.AlyxPanel: water restriction management endpoint refactoring --- +eui/AlyxPanel.m | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 53e2c083..009d7f52 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -365,12 +365,12 @@ function dispWaterReq(obj, src, ~) else record = struct(); end - weight = getOr(record, 'weight_measured', NaN); % Get today's measured weight - water = getOr(record, 'water_given', 0); % Get total water given - gel = getOr(record, 'hydrogel_given', 0); % Get total gel given - weight_expected = getOr(record, 'weight_expected', NaN); + weight = getOr(record, 'weight', NaN); % Get today's measured weight + water = getOr(record, 'given_water_liquid', 0); % Get total water given + gel = getOr(record, 'given_water_hydrogel', 0); % Get total gel given + expected_weight = getOr(record, 'expected_weight', NaN); % Set colour based on weight percentage - weight_pct = (weight-wr.implant_weight)/(weight_expected-wr.implant_weight); + weight_pct = (weight-wr.implant_weight)/(expected_weight-wr.implant_weight); if weight_pct < 0.8 % Mouse below 80% original weight colour = [0.91, 0.41, 0.17]; % Orange weight_pct = '< 80%'; @@ -541,7 +541,7 @@ function viewSubjectHistory(obj, ax) obj.log('No weight data found for subject %s', obj.Subject); return end - expected = [records.weight_expected]; + expected = [records.expected_weight]; expected(expected==0) = nan; dates = cellfun(@(x)datenum(x), {records.date}); @@ -555,7 +555,7 @@ function viewSubjectHistory(obj, ax) ax = axes('Parent', plotBox); end - plot(ax, dates, [records.weight_measured], '.-'); + plot(ax, dates, [records.weight], '.-'); 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); @@ -572,7 +572,7 @@ function viewSubjectHistory(obj, ax) if nargin==1 ax = axes('Parent', plotBox); - plot(ax, dates, ([records.weight_measured]-iw)./(expected-iw), '.-'); + plot(ax, dates, ([records.weight]-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); @@ -582,11 +582,11 @@ function viewSubjectHistory(obj, ax) ylabel(ax, 'weight as pct (%)'); axWater = axes('Parent',plotBox); - plot(axWater, dates, obj.round([records.water_given]+[records.hydrogel_given], 'up'), '.-'); + plot(axWater, dates, obj.round([records.given_water_liquid]+[records.given_water_hydrogel], 'up'), '.-'); hold(axWater, 'on'); - plot(axWater, dates, obj.round([records.hydrogel_given], 'down'), '.-'); - plot(axWater, dates, obj.round([records.water_given], 'down'), '.-'); - plot(axWater, dates, obj.round([records.water_expected], 'up'), 'r', 'LineWidth', 2.0); + plot(axWater, dates, obj.round([records.given_water_hydrogel], 'down'), '.-'); + plot(axWater, dates, obj.round([records.given_water_liquid], 'down'), '.-'); + plot(axWater, dates, obj.round([records.expected_water], 'up'), 'r', 'LineWidth', 2.0); box(axWater, 'off'); xlim(axWater, [min(dates) max(dates)]); set(axWater, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(axWater, 'XTick'), 'uni', false)) @@ -597,22 +597,22 @@ function viewSubjectHistory(obj, ax) histTable = uitable('Parent', histbox,... 'FontName', 'Consolas',... 'RowName', []); - weightsByDate = num2cell([records.weight_measured]); + weightsByDate = num2cell([records.weight]); weightsByDate = cellfun(@(x)sprintf('%.1f', x), weightsByDate, 'uni', false); - weightsByDate(isnan([records.weight_measured])) = {[]}; - weightPctByDate = num2cell(([records.weight_measured]-iw)./(expected-iw)); + weightsByDate(isnan([records.weight])) = {[]}; + weightPctByDate = num2cell(([records.weight]-iw)./(expected-iw)); weightPctByDate = cellfun(@(x)sprintf('%.1f', x*100), weightPctByDate, 'uni', false); - weightPctByDate(isnan([records.weight_measured])) = {[]}; + weightPctByDate(isnan([records.weight])) = {[]}; dat = horzcat(... arrayfun(@(x)datestr(x), dates', 'uni', false), ... weightsByDate', ... - arrayfun(@(x)sprintf('%.1f', 0.8*(x-iw)+iw), [records.weight_expected]', 'uni', false), ... + arrayfun(@(x)sprintf('%.1f', 0.8*(x-iw)+iw), [records.expected_weight]', 'uni', false), ... weightPctByDate'); waterDat = (... - num2cell(horzcat([records.water_given]', [records.hydrogel_given]', ... - [records.water_given]'+[records.hydrogel_given]', [records.water_expected]',... - [records.water_given]'+[records.hydrogel_given]'-[records.water_expected]'))); + num2cell(horzcat([records.given_water_liquid]', [records.given_water_hydrogel]', ... + [records.given_water_liquid]'+[records.given_water_hydrogel]', [records.expected_water]',... + [records.given_water_liquid]'+[records.given_water_hydrogel]'-[records.expected_water]'))); waterDat = cellfun(@(x)sprintf('%.2f', x), waterDat, 'uni', false); dat = horzcat(dat, waterDat); @@ -705,4 +705,4 @@ function log(obj, varargin) end end end -end \ No newline at end of file +end From e960a1abad56f2ad134b18a49bbcfdea4ea87081 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 20 Nov 2018 13:44:55 +0000 Subject: [PATCH 211/507] Water type set in hardware --- +eui/ExpPanel.m | 12 +++++++----- +exp/SignalsExp.m | 24 +++++++++++++++++++----- +hw/RewardValveControl.m | 3 +++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/+eui/ExpPanel.m b/+eui/ExpPanel.m index e3434f14..d9fd958c 100644 --- a/+eui/ExpPanel.m +++ b/+eui/ExpPanel.m @@ -213,6 +213,7 @@ function expStopped(obj, rig, ~) % ended. This function also records to Alyx the amount of water, % if any, that the subject received during the task. % + % TODO: Move water to save data functions % See also EXPSTARTED, ALYX.POSTWATER set(obj.StatusLabel, 'String', 'Completed'); %staus to completed obj.ExpRunning = false; @@ -236,11 +237,12 @@ function expStopped(obj, rig, ~) sum([obj.Block.trial.feedbackType]==1); end if numel(amount)>1; amount = amount(1); end % Take first element (second being laser) - otherwise - infoFields = {obj.InfoFields.String}; - inc = cellfun(@(x) any(strfind(x(:)','�l')), {obj.InfoFields.String}); % Find event values ending with 'ul'. - reward = cell2mat(cellfun(@str2num,strsplit(infoFields{find(inc,1)},'�l'),'UniformOutput',0)); - amount = iff(isempty(reward),0,@()reward); + otherwise + % Done in exp.SignalsExp/saveData + %infoFields = {obj.InfoFields.String}; + %inc = cellfun(@(x) any(strfind(x(:)','�l')), {obj.InfoFields.String}); % Find event values ending with 'ul'. + %reward = cell2mat(cellfun(@str2num,strsplit(infoFields{find(inc,1)},'�l'),'UniformOutput',0)); + %amount = iff(isempty(reward),0,@()reward); end if ~any(amount); return; end % Return if no water was given try diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index d261de2c..b8f1b74c 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -892,7 +892,8 @@ function saveData(obj) % obj.AlyxInstance.registerFile(savepaths{end}, 'mat',... % {subject, expDate, seq}, 'Block', []); % Save the session end time - if ~isempty(obj.AlyxInstance.SessionURL) + url = obj.AlyxInstance.SessionURL; + if ~isempty(url) numCorrect = []; if isfield(obj.Data, 'events') numTrials = length(obj.Data.events.endTrialValues); @@ -903,19 +904,32 @@ function saveData(obj) numTrials = 0; numCorrect = 0; end + % Update Alyx session with end time, trial counts and water tye sessionData = struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject); if ~isempty(numTrials); sessionData.numberOfTrials = numTrials; end if ~isempty(numCorrect); sessionData.numberOfCorrectTrials = numCorrect; end - obj.AlyxInstance.postData(obj.AlyxInstance.SessionURL,... - sessionData, 'put'); + obj.AlyxInstance.postData(url, sessionData, 'put'); else % Retrieve session from endpoint -% subsessions = obj.AlyxInstance.getData(... -% sprintf('sessions?type=Experiment&subject=%s&number=%i', subject, seq)); + % subsessions = obj.AlyxInstance.getData(... + % sprintf('sessions?type=Experiment&subject=%s&number=%i', subject, seq)); end catch ex warning(ex.identifier, 'Failed to register files to Alyx: %s', ex.message); end + % Post water to Alyx + try + valve_controller = obj.DaqController.SignalGenerators(strcmp(obj.DaqController.ChannelNames,'rewardValve')); + type = iff(isprop(valve_controller, 'WaterType'), valve_controller.WaterType, 'Water'); + if isfield(outputs, 'rewardValues') + amount = sum(obj.Data.outputs.rewardValues)*0.001; + else + amount = 0; + end + obj.AlyxInstance.postWater(subject, amount, now, type, url); + catch ex + warning(ex.identifier, 'Failed to post water to Alyx: %s', ex.message); + end end end end diff --git a/+hw/RewardValveControl.m b/+hw/RewardValveControl.m index aa94b999..3713ed6a 100644 --- a/+hw/RewardValveControl.m +++ b/+hw/RewardValveControl.m @@ -14,6 +14,9 @@ % 'volumeMicroLitres' indicating the duration the valve was open, and the % measured volume (in ul) for that delivery. These points are interpolated % to work out how long to open the valve for arbitrary volumes. + WaterType = 'Water' + % The type of water dispenced by the rig. This is used to populate the + % water_type field in Alyx sessions. end methods From 34a4b7a13d0ad38bd341281a168a274655cf853a Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 20 Nov 2018 14:39:43 +0000 Subject: [PATCH 212/507] field name change for trials numbers --- +exp/SignalsExp.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index b8f1b74c..23340cc9 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -906,8 +906,8 @@ function saveData(obj) end % Update Alyx session with end time, trial counts and water tye sessionData = struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject); - if ~isempty(numTrials); sessionData.numberOfTrials = numTrials; end - if ~isempty(numCorrect); sessionData.numberOfCorrectTrials = numCorrect; end + if ~isempty(numTrials); sessionData.n_trials = numTrials; end + if ~isempty(numCorrect); sessionData.n_correct_trials = numCorrect; end obj.AlyxInstance.postData(url, sessionData, 'put'); else % Retrieve session from endpoint From 86b4ce0cd928750d8f42b325bb5a81d621781997 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 20 Nov 2018 15:54:41 +0000 Subject: [PATCH 213/507] Bug fix for post water --- +exp/SignalsExp.m | 2 +- alyx-matlab | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 23340cc9..7e705c0d 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -921,7 +921,7 @@ function saveData(obj) try valve_controller = obj.DaqController.SignalGenerators(strcmp(obj.DaqController.ChannelNames,'rewardValve')); type = iff(isprop(valve_controller, 'WaterType'), valve_controller.WaterType, 'Water'); - if isfield(outputs, 'rewardValues') + if isfield(obj.Data.outputs, 'rewardValues') amount = sum(obj.Data.outputs.rewardValues)*0.001; else amount = 0; diff --git a/alyx-matlab b/alyx-matlab index 5420b606..9f75b53a 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 5420b6065ccca9b438827fc1419fe30566e2ed7b +Subproject commit 9f75b53a7fbbce7f80caf674157c592e19d91db2 From 8b455028e6ff8421a0086f998d9b4e7bf709f588 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 20 Nov 2018 18:09:57 +0000 Subject: [PATCH 214/507] Ugly but functional drop-down for water type --- +eui/AlyxPanel.m | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 76cf0377..792ff5dc 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -45,7 +45,7 @@ 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 @@ -150,11 +150,11 @@ 'Enable', 'off',... 'Callback', @(~,~)obj.giveFutureWater); % Check box to indicate whether water was gel or liquid - obj.IsHydrogel = uicontrol('Parent', waterbox,... - 'Style', 'checkbox', ... - 'String', 'Hydrogel?', ... + obj.WaterType = uicontrol('Parent', waterbox,... + 'Style', 'popupmenu', ... + 'String', {'Water'}, ... 'HorizontalAlignment', 'right',... - 'Value', false, ... + 'Value', 1, ... 'Enable', 'off'); % Input for submitting amount of water obj.WaterEntry = uicontrol('Parent', waterbox,... @@ -241,6 +241,10 @@ function login(obj) 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', obj.AlyxInstance.User); @@ -287,7 +291,7 @@ function giveWater(obj) % state of the 'is hydrogel' check box thisDate = now; amount = str2double(get(obj.WaterEntry, 'String')); - type = iff(get(obj.IsHydrogel, 'Value')==1, 'Hydrogel', 'Water'); + 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 From 3994da9f865b8ceebc07d53adaf69b23cfa7fba6 Mon Sep 17 00:00:00 2001 From: Olivier Winter Date: Mon, 19 Nov 2018 18:01:18 +0000 Subject: [PATCH 215/507] eui.AlyxPanel: water restriction management endpoint refactoring --- +eui/AlyxPanel.m | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 792ff5dc..ada340ac 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -371,12 +371,12 @@ function dispWaterReq(obj, src, ~) else record = struct(); end - weight = getOr(record, 'weight_measured', NaN); % Get today's measured weight - water = getOr(record, 'water_given', 0); % Get total water given - gel = getOr(record, 'hydrogel_given', 0); % Get total gel given - weight_expected = getOr(record, 'weight_expected', NaN); + weight = getOr(record, 'weight', NaN); % Get today's measured weight + water = getOr(record, 'given_water_liquid', 0); % Get total water given + gel = getOr(record, 'given_water_hydrogel', 0); % Get total gel given + expected_weight = getOr(record, 'expected_weight', NaN); % Set colour based on weight percentage - weight_pct = (weight-wr.implant_weight)/(weight_expected-wr.implant_weight); + weight_pct = (weight-wr.implant_weight)/(expected_weight-wr.implant_weight); if weight_pct < 0.8 % Mouse below 80% original weight colour = [0.91, 0.41, 0.17]; % Orange weight_pct = '< 80%'; @@ -547,7 +547,7 @@ function viewSubjectHistory(obj, ax) obj.log('No weight data found for subject %s', obj.Subject); return end - expected = [records.weight_expected]; + expected = [records.expected_weight]; expected(expected==0) = nan; dates = cellfun(@(x)datenum(x), {records.date}); @@ -561,7 +561,7 @@ function viewSubjectHistory(obj, ax) ax = axes('Parent', plotBox); end - plot(ax, dates, [records.weight_measured], '.-'); + plot(ax, dates, [records.weight], '.-'); 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); @@ -578,7 +578,7 @@ function viewSubjectHistory(obj, ax) if nargin==1 ax = axes('Parent', plotBox); - plot(ax, dates, ([records.weight_measured]-iw)./(expected-iw), '.-'); + plot(ax, dates, ([records.weight]-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); @@ -588,11 +588,11 @@ function viewSubjectHistory(obj, ax) ylabel(ax, 'weight as pct (%)'); axWater = axes('Parent',plotBox); - plot(axWater, dates, obj.round([records.water_given]+[records.hydrogel_given], 'up'), '.-'); + plot(axWater, dates, obj.round([records.given_water_liquid]+[records.given_water_hydrogel], 'up'), '.-'); hold(axWater, 'on'); - plot(axWater, dates, obj.round([records.hydrogel_given], 'down'), '.-'); - plot(axWater, dates, obj.round([records.water_given], 'down'), '.-'); - plot(axWater, dates, obj.round([records.water_expected], 'up'), 'r', 'LineWidth', 2.0); + plot(axWater, dates, obj.round([records.given_water_hydrogel], 'down'), '.-'); + plot(axWater, dates, obj.round([records.given_water_liquid], 'down'), '.-'); + plot(axWater, dates, obj.round([records.expected_water], 'up'), 'r', 'LineWidth', 2.0); box(axWater, 'off'); xlim(axWater, [min(dates) max(dates)]); set(axWater, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(axWater, 'XTick'), 'uni', false)) @@ -603,22 +603,22 @@ function viewSubjectHistory(obj, ax) histTable = uitable('Parent', histbox,... 'FontName', 'Consolas',... 'RowName', []); - weightsByDate = num2cell([records.weight_measured]); + weightsByDate = num2cell([records.weight]); weightsByDate = cellfun(@(x)sprintf('%.1f', x), weightsByDate, 'uni', false); - weightsByDate(isnan([records.weight_measured])) = {[]}; - weightPctByDate = num2cell(([records.weight_measured]-iw)./(expected-iw)); + weightsByDate(isnan([records.weight])) = {[]}; + weightPctByDate = num2cell(([records.weight]-iw)./(expected-iw)); weightPctByDate = cellfun(@(x)sprintf('%.1f', x*100), weightPctByDate, 'uni', false); - weightPctByDate(isnan([records.weight_measured])) = {[]}; + weightPctByDate(isnan([records.weight])) = {[]}; dat = horzcat(... arrayfun(@(x)datestr(x), dates', 'uni', false), ... weightsByDate', ... - arrayfun(@(x)sprintf('%.1f', 0.8*(x-iw)+iw), [records.weight_expected]', 'uni', false), ... + arrayfun(@(x)sprintf('%.1f', 0.8*(x-iw)+iw), [records.expected_weight]', 'uni', false), ... weightPctByDate'); waterDat = (... - num2cell(horzcat([records.water_given]', [records.hydrogel_given]', ... - [records.water_given]'+[records.hydrogel_given]', [records.water_expected]',... - [records.water_given]'+[records.hydrogel_given]'-[records.water_expected]'))); + num2cell(horzcat([records.given_water_liquid]', [records.given_water_hydrogel]', ... + [records.given_water_liquid]'+[records.given_water_hydrogel]', [records.expected_water]',... + [records.given_water_liquid]'+[records.given_water_hydrogel]'-[records.expected_water]'))); waterDat = cellfun(@(x)sprintf('%.2f', x), waterDat, 'uni', false); dat = horzcat(dat, waterDat); @@ -714,4 +714,4 @@ function log(obj, varargin) end end end -end \ No newline at end of file +end From 770ef43aa7c1cb75ad2c4b50e5ae47524ca79019 Mon Sep 17 00:00:00 2001 From: Olivier Winter Date: Mon, 19 Nov 2018 16:59:14 +0000 Subject: [PATCH 216/507] Alyx changes in subject endpoint --- +dat/parseAlyxInstance.m | 6 +++--- +eui/AlyxPanel.m | 8 ++++---- +eui/MControl.m | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/+dat/parseAlyxInstance.m b/+dat/parseAlyxInstance.m index 2eb09711..9db5cf4a 100644 --- a/+dat/parseAlyxInstance.m +++ b/+dat/parseAlyxInstance.m @@ -18,9 +18,9 @@ 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'); + % remove water remaining_water field + if isfield(ai, 'remaining_water') + ai = rmfield(ai, 'remaining_water'); end fname = fieldnames(ai); % get fieldnames emp = structfun(@isempty, ai); % find empty fields diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index ada340ac..9f2e83dc 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -388,12 +388,12 @@ function dispWaterReq(obj, src, ~) weight_pct = '> 80%'; end % Round up water remaining to the near 0.01 - remainder = obj.round(s(idx).water_requirement_remaining, 'up'); + 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).water_requirement_total, 'up'), weight, ... + remainder, obj.round(s(idx).expected_water, 'up'), weight, ... weight_pct, obj.round(sum([water gel]), 'down'))); % Set WaterRemaining attribute for changeWaterText callback obj.WaterRemaining = remainder; @@ -649,11 +649,11 @@ function viewAllSubjects(obj) colorgen = @(colorNum,text) ['',text,'']; wrdat = cellfun(@(x)colorgen(1-double(x>0)*[0 0.3 0.3],... - sprintf('%.2f',obj.round(x, 'up'))), {wr.water_requirement_remaining}, 'uni', false); + sprintf('%.2f',obj.round(x, 'up'))), {wr.remaining_water}, 'uni', false); set(wrTable, 'ColumnName', {'Name', 'Water Required', 'Remaining Requirement'}, ... 'Data', horzcat({wr.nickname}', ... - cellfun(@(x)sprintf('%.2f',obj.round(x, 'up')),{wr.water_requirement_total}', 'uni', false), ... + cellfun(@(x)sprintf('%.2f',obj.round(x, 'up')),{wr.expected_water}', 'uni', false), ... wrdat'), ... 'ColumnEditable', false(1,3)); end diff --git a/+eui/MControl.m b/+eui/MControl.m index 9e5a30ca..3ca11966 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -346,8 +346,8 @@ function rigExpStopped(obj, rig, evt) % Announce that the experiment has stopped subject = dat.parseExpRef(evt.Ref); 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); From 3cb585195df551b98902a455c494b838994c3e20 Mon Sep 17 00:00:00 2001 From: jaib1 Date: Mon, 26 Nov 2018 15:37:40 +0000 Subject: [PATCH 217/507] Initial DaqController changes for clock output + tidying other files --- +hw/DaqController.m | 77 ++++++++++++++++++++++++++++++++------------- +hw/DaqSingleScan.m | 4 +-- +hw/Timeline.m | 13 ++++---- 3 files changed, 64 insertions(+), 30 deletions(-) diff --git a/+hw/DaqController.m b/+hw/DaqController.m index a9835d40..b2fa0891 100644 --- a/+hw/DaqController.m +++ b/+hw/DaqController.m @@ -53,6 +53,7 @@ properties (Transient) DaqSession % should be a DAQ session containing at least one analogue output channel DigitalDaqSession % a DAQ session containing only digital output channels + ClockDaqSession % a DAQ session for implementing output to a clock channel end properties (Dependent) @@ -67,32 +68,41 @@ methods function createDaqChannels(obj) - if isempty(obj.DaqSession) + if isempty(obj.DaqSession)&&any(obj.AnalogueChannelsIdx) obj.DaqSession = daq.createSession('ni'); obj.DaqSession.Rate = obj.SampleRate; end if isempty(obj.DigitalDaqSession)&&any(~obj.AnalogueChannelsIdx) obj.DigitalDaqSession = daq.createSession('ni'); end + if isempty(obj.ClockDaqSession)&&any(strncmp('ctr',(obj.DaqChannelIds),3)) + obj.ClockDaqSession = daq.createSession('ni'); + end n = obj.NumChannels; if n > 0 - for ii = 1:n + for i = 1:n if iscell(obj.DaqIds) - daqid = obj.DaqIds{ii}; + daqid = obj.DaqIds{i}; else daqid = obj.DaqIds; end - if obj.AnalogueChannelsIdx(ii) % is channal analogue? + % is channel analogue? + if strncmp('ao',(obj.DaqChannelIds{i}),2) obj.DaqSession.addAnalogOutputChannel(... - daqid, obj.DaqChannelIds{ii}, 'Voltage'); + daqid, obj.DaqChannelIds{i}, 'Voltage'); + % is channel clock output? + elseif strncmp('ctr',(obj.DaqChannelIds{i}),3) + obj.ClockDaqSession.addCounterOutputChannel(... + daqid, obj.DaqChannelIds{i}, 'PulseGeneration'); else % assume digital, always output only obj.DigitalDaqSession.addDigitalChannel(... - daqid, obj.DaqChannelIds{ii}, 'OutputOnly'); + daqid, obj.DaqChannelIds{i}, 'OutputOnly'); end end v = [obj.SignalGenerators.DefaultValue]; obj.DaqSession.outputSingleScan(v(obj.AnalogueChannelsIdx)); - if any(~obj.AnalogueChannelsIdx) + if any(~obj.AnalogueChannelsIdx) && ... + ~( any(strncmp('ctr',(obj.DaqChannelIds),3)) ) obj.DigitalDaqSession.outputSingleScan(v(~obj.AnalogueChannelsIdx)); end obj.CurrValue = v; @@ -105,12 +115,14 @@ function command(obj, varargin) % Sends command signals to each channel % % command(channels, values) - % sends command signals to each channel carrying each value. - % 'channels' is a cell array of strings with each channel name, and - % value is + % sends command signal to a channel with the corresponding value + % (i.e. there is a channel-value pair for each command signal) + % 'channels' is a cell array of strings with each channel name, and + % 'value' is a cell array of values? % % command(values) - % sends command signals to all channels carrying each value + % for length of values, sends command signals to the corresponding + % ordered channels % % [CHANNEL,INDEX] = addAnalogInputChannel(...) % addAnalogInputChannel optionally returns CHANNEL, which is an @@ -143,13 +155,13 @@ function command(obj, varargin) gen = obj.SignalGenerators(1:n); rate = obj.DaqSession.Rate; waveforms = cell(1, n); - for ii = 1:n + for i = 1:n if iscell(values) - v = values{ii}; + v = values{i}; else - v = values(:,ii); + v = values(:,i); end - waveforms{ii} = gen(ii).waveform(rate, v); + waveforms{i} = gen(i).waveform(rate, v); end if obj.DaqSession.IsRunning % if a daq operation is in progress, stop it, and set its output @@ -158,6 +170,7 @@ function command(obj, varargin) end channelNames = obj.ChannelNames(1:n); analogueChannelsIdx = obj.AnalogueChannelsIdx(1:n); + % for all analogue channel outputs if any(analogueChannelsIdx)&&any(any(values(:,analogueChannelsIdx)~=0)) queue(obj, channelNames(analogueChannelsIdx), waveforms(analogueChannelsIdx)); if foreground @@ -167,18 +180,38 @@ function command(obj, varargin) end readyWait(obj); obj.DaqSession.release; - elseif any(~analogueChannelsIdx) - waveforms = waveforms(~analogueChannelsIdx); - for n = 1:length(waveforms) - digitalValues = waveforms{n}; - for m = 1:length(digitalValues) - obj.DigitalDaqSession.outputSingleScan(digitalValues(m)); - end +% elseif any(~analogueChannelsIdx) %why is this an elseif? +% waveforms = waveforms(~analogueChannelsIdx); + else % for all digital or clock outputs + maxLnWaveform = max(cellfun(@length, waveforms)); + % pad shorter waveforms + for i = 1:length(waveforms) + waveforms{i}(end:maxLnWaveform) = waveforms{i}(end); + end + waveformsMtx = vec2mat(cell2mat(waveforms), maxLnWaveform); + if iscolumn(waveformsMtx), waveformsMtx = waveformsMtx'; end + % output first rows of waveformsMtx (values for each channel) (to + % account for waveforms of different lengths) + for n = 1:size(waveformsMtx,1) + % for clock output channels with a valid value to output + if strncmp('ctr',(obj.DaqChannelIds{n}),3) && waveformsMtx(n,1)>0 + obj.ClockDaqSession.dt = length(waveformsMtx(n,1)) / obj.SampleRate; + obj.ClockDaqSession.F = 1/obj.ClockDaqSession.dt; + obj.ClockDaqSession.Duty = 1; + startBackground(obj.ClockDaqSession); + else %for digital output channels + obj.DigitalDaqSession.outputSingleScan(waveformsMtx(n,:)); end + end end end end + function clearSessions(obj) + obj.DaqSession = []; + obj.DigitalDaqSession = []; + end + function v = get.NumChannels(obj) v = numel(obj.DaqChannelIds); end diff --git a/+hw/DaqSingleScan.m b/+hw/DaqSingleScan.m index 2bb53282..b37fd102 100644 --- a/+hw/DaqSingleScan.m +++ b/+hw/DaqSingleScan.m @@ -17,11 +17,11 @@ obj.Scale = scale; end - function samples = waveform(obj, v) + function samples = waveform(obj, varargin) % just take the first value (if multiple were provided) and output % it, scaled, as a single number. This will result in the analog % output channel switching to that value and staying there. - samples = v(1)*obj.Scale; + samples = varargin{end}*obj.Scale; end end diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 2e8a3b16..020a96c2 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -1,5 +1,5 @@ classdef Timeline < handle -% HW.TIMELINE Returns an object that generate and aquires clocking pulses +% HW.TIMELINE Returns an object that generates and aquires clocking pulses % Timeline (tl) manages the aquisition and generation of experimental % timing data using an NI data aquisition device. The main timing signal % is called 'chrono' and consists of a digital squarewave that flips each @@ -45,7 +45,7 @@ % %Add the rotary encoder % timeline.addInput('rotaryEncoder', 'ctr0', 'Position'); % %For a lick detector -% timeline.addInput('lickDetector', 'ctr2', 'EdgeCount'); +% timeline.addInput('lickDetector', 'ctr1', 'EdgeCount'); % %We want use camera frame acquisition trigger by default % timeline.UseOutputs{end+1} = 'clock'; % %Save your hardware.mat file @@ -132,7 +132,7 @@ function start(obj, expRef, ai) % START Starts timeline data acquisition % START(obj, ref, AlyxInstance) starts all DAQ sessions and adds - % the relevent output and input channels. + % the relevant output and input channels. % % See Also HW.TLOUTPUT/START @@ -192,8 +192,8 @@ function start(obj, expRef, ai) function record(obj, name, event, t) % Records an event in Timeline % TL.RECORD(name, event, [time]) records an event in the Timeline - % struct in fields prefixed with 'name', with data in 'event'. Optionally - % specify 'time', otherwise the time of call will be used (relative to + % object in fields prefixed with 'name', with data in 'event'. Optionally + % specify time 't', otherwise the time of call will be used (relative to % Timeline acquisition). if nargin < 4; t = time(obj); end % default to time now (using Timeline clock) initLength = 100; % default initial length of event data arrays @@ -604,7 +604,8 @@ function livePlot(obj, data) % TL.LIVEPLOT(source, event) plots the data aquired by the % DAQ while the PlotLive property is true. if isempty(obj.Axes) - f = figure('Units', 'Normalized', 'Position', [0 0 1 1]); % create a figure for plotting aquired data + %f = figure('Units', 'Normalized', 'Position', [0 0 1 1]); % create a figure for plotting aquired data + f = figure('Units', 'Normalized'); obj.Axes = gca; % store a handle to the axes if isprop(obj, 'FigurePosition') && ~isempty(obj.FigurePosition) set(f, 'Position', obj.FigurePosition); % set the figure position From a94ab94c79ca8bca2145a39c808b9c7d4d4662fd Mon Sep 17 00:00:00 2001 From: jaib1 Date: Tue, 27 Nov 2018 14:24:23 +0000 Subject: [PATCH 218/507] working RewardAsClock in DaqController --- +hw/DaqController.m | 103 +++++++++++++++++++++++++++++--------------- 1 file changed, 68 insertions(+), 35 deletions(-) diff --git a/+hw/DaqController.m b/+hw/DaqController.m index b2fa0891..524047e4 100644 --- a/+hw/DaqController.m +++ b/+hw/DaqController.m @@ -15,7 +15,7 @@ % %Define the channel ID to output on % daqController.DaqChannelIds = {'ai0'}; % %As it is an analogue output, set the AnalogueChannelsIdx to true - % daqController.AnalogueChannelIdx(1) = true; + % daqController.AnalogueChannelsIdx(1) = true; % %Add a signal generator that will return the correct samples for % %delivering a reward of a specified volume % daqController.SignalGenerators(1) = hw.RewardValveControl; @@ -37,8 +37,11 @@ % See also HW.CONTROLSIGNALGENERATOR, HW.DAQROTARYENCODER % 2013 CB created % 2017-07 MW added digital output support + % 2018-11 JB added output reward as clock channel + % 2018-11 JB added simultaneous analog/digital and digital/digital output properties + ChannelNames = {} % name to refer to each channel %Signal generator for each channel. Each should be an object of class %hw.ControlSignalGenerator, for generating command waveforms. @@ -48,31 +51,39 @@ SampleRate = 1000 % output sample rate ("scans/sec") of the daq device % 1000 is also the default of the ni daq devices themselves, so if % you don't change this, it doesn't actually do anything. + end properties (Transient) + DaqSession % should be a DAQ session containing at least one analogue output channel DigitalDaqSession % a DAQ session containing only digital output channels ClockDaqSession % a DAQ session for implementing output to a clock channel + end properties (Dependent) + Value %The current voltage on each DAQ channel NumChannels %Number of channels controlled AnalogueChannelsIdx %Logical array of analogue channel IDs + end properties (Access = private, Transient) + CurrValue + end methods + function createDaqChannels(obj) - if isempty(obj.DaqSession)&&any(obj.AnalogueChannelsIdx) + if isempty(obj.DaqSession)&&any(strncmp('ao',(obj.DaqChannelIds),2)) obj.DaqSession = daq.createSession('ni'); obj.DaqSession.Rate = obj.SampleRate; end - if isempty(obj.DigitalDaqSession)&&any(~obj.AnalogueChannelsIdx) + if isempty(obj.DigitalDaqSession)&&any(strncmp('port',(obj.DaqChannelIds),4)) obj.DigitalDaqSession = daq.createSession('ni'); end if isempty(obj.ClockDaqSession)&&any(strncmp('ctr',(obj.DaqChannelIds),3)) @@ -94,17 +105,21 @@ function createDaqChannels(obj) elseif strncmp('ctr',(obj.DaqChannelIds{i}),3) obj.ClockDaqSession.addCounterOutputChannel(... daqid, obj.DaqChannelIds{i}, 'PulseGeneration'); - else % assume digital, always output only + else % assume digital, always 'OutputOnly' obj.DigitalDaqSession.addDigitalChannel(... daqid, obj.DaqChannelIds{i}, 'OutputOnly'); end end + + % what are these lines doing? why outputSingleScan? why a/d separate? + v = [obj.SignalGenerators.DefaultValue]; - obj.DaqSession.outputSingleScan(v(obj.AnalogueChannelsIdx)); - if any(~obj.AnalogueChannelsIdx) && ... - ~( any(strncmp('ctr',(obj.DaqChannelIds),3)) ) - obj.DigitalDaqSession.outputSingleScan(v(~obj.AnalogueChannelsIdx)); - end +% obj.DaqSession.outputSingleScan(v(obj.AnalogueChannelsIdx)); +% % digital (non-clock) channel only +% nonClockDigis = strncmp('port',(obj.DaqChannelIds),4); +% if any(nonClockDigis) +% obj.DigitalDaqSession.outputSingleScan(v(nonClockDigis)); +% end obj.CurrValue = v; else obj.CurrValue = []; @@ -171,7 +186,7 @@ function command(obj, varargin) channelNames = obj.ChannelNames(1:n); analogueChannelsIdx = obj.AnalogueChannelsIdx(1:n); % for all analogue channel outputs - if any(analogueChannelsIdx)&&any(any(values(:,analogueChannelsIdx)~=0)) + if any(analogueChannelsIdx)&&any(values(:,analogueChannelsIdx)~=0) queue(obj, channelNames(analogueChannelsIdx), waveforms(analogueChannelsIdx)); if foreground startForeground(obj.DaqSession); @@ -180,27 +195,31 @@ function command(obj, varargin) end readyWait(obj); obj.DaqSession.release; -% elseif any(~analogueChannelsIdx) %why is this an elseif? -% waveforms = waveforms(~analogueChannelsIdx); - else % for all digital or clock outputs + end + % for all digital or clock outputs (why does this have to be an else?) + if any(~analogueChannelsIdx)&&any(values(:,~analogueChannelsIdx)~=0) maxLnWaveform = max(cellfun(@length, waveforms)); % pad shorter waveforms for i = 1:length(waveforms) waveforms{i}(end:maxLnWaveform) = waveforms{i}(end); end - waveformsMtx = vec2mat(cell2mat(waveforms), maxLnWaveform); - if iscolumn(waveformsMtx), waveformsMtx = waveformsMtx'; end - % output first rows of waveformsMtx (values for each channel) (to - % account for waveforms of different lengths) - for n = 1:size(waveformsMtx,1) - % for clock output channels with a valid value to output - if strncmp('ctr',(obj.DaqChannelIds{n}),3) && waveformsMtx(n,1)>0 - obj.ClockDaqSession.dt = length(waveformsMtx(n,1)) / obj.SampleRate; - obj.ClockDaqSession.F = 1/obj.ClockDaqSession.dt; - obj.ClockDaqSession.Duty = 1; - startBackground(obj.ClockDaqSession); - else %for digital output channels - obj.DigitalDaqSession.outputSingleScan(waveformsMtx(n,:)); + waveformsMtx = cell2mat(waveforms); + %if iscolumn(waveformsMtx), waveformsMtx = waveformsMtx'; end + % output columns of waveformsMtx (values for each channel) + for n = 1:size(waveformsMtx,2) + %if we have some value to output + if any(waveformsMtx(:,n)) + % for clock output channels with a valid value to output + if strncmp('ctr',(obj.DaqChannelIds{n}),3) + obj.ClockDaqSession.DurationInSeconds = length(waveformsMtx) / obj.SampleRate; + %Duty Cycle must be b/w 0-1, so set to 'n' and scale frequency by 1/n + obj.ClockDaqSession.Channels.DutyCycle = 0.99; + obj.ClockDaqSession.Channels.Frequency = 1/obj.ClockDaqSession.DurationInSeconds/0.99; + startBackground(obj.ClockDaqSession); + %for digital output channels + elseif strncmp('port',(obj.DaqChannelIds{n}),4) + obj.DigitalDaqSession.outputSingleScan(waveformsMtx(:,n)); + end end end end @@ -210,6 +229,7 @@ function command(obj, varargin) function clearSessions(obj) obj.DaqSession = []; obj.DigitalDaqSession = []; + obj.ClockDaqSession = []; end function v = get.NumChannels(obj) @@ -226,7 +246,9 @@ function clearSessions(obj) function set.Value(obj, v) readyWait(obj); - obj.DaqSession.outputSingleScan(v(obj.AnalogueChannelsIdx)); + if any (obj.AnalogueChannelsIdx) + obj.DaqSession.outputSingleScan(v(obj.AnalogueChannelsIdx)); + end if any(~obj.AnalogueChannelsIdx) obj.DigitalDaqSession.outputSingleScan(v(~obj.AnalogueChannelsIdx)); end @@ -234,20 +256,26 @@ function clearSessions(obj) end function reset(obj) - stop(obj.DaqSession); + if ~isempty(obj.DaqSession) + stop(obj.DaqSession); + end if ~isempty(obj.DigitalDaqSession) stop(obj.DigitalDaqSession); end v = [obj.SignalGenerators.DefaultValue]; - outputSingleScan(obj.DaqSession, v(obj.AnalogueChannelsIdx)); + if any(obj.AnalogueChannelsIdx) + outputSingleScan(obj.DaqSession, v(obj.AnalogueChannelsIdx)); + end if any(~obj.AnalogueChannelsIdx) outputSingleScan(obj.DigitalDaqSession, v(~obj.AnalogueChannelsIdx)); end obj.CurrValue = v; end + end methods (Access = protected) + function queue(obj, names, waveforms) names = ensureCell(names); waveforms = ensureCell(waveforms); @@ -256,12 +284,13 @@ function queue(obj, names, waveforms) len = cellfun(@numel, waveforms); defaultValues = [obj.SignalGenerators.DefaultValue]; samples = repmat(defaultValues(obj.AnalogueChannelsIdx), max(len), 1); - for ii = 1:numel(waveforms) - cidx = strcmp(names{ii}, obj.ChannelNames); - assert(sum(cidx) == 1, 'Channel name mismatch'); - samples(1:len(ii),cidx) = waveforms{ii}; + for i = 1:numel(waveforms) + % cidx = strcmp(names{i}, obj.ChannelNames); + % assert(sum(cidx) == 1, 'Channel name mismatch'); + % samples(1:len(i),cidx) = waveforms{i}; + samples(1:len(i),i) = waveforms{i}; end - readyWait(obj); + %readyWait(obj); % plot(samples,'-x'), xlim([-1 300]) obj.DaqSession.queueOutputData(samples); % samplelen = size(samples,1)/1000 @@ -269,13 +298,17 @@ function queue(obj, names, waveforms) end function readyWait(obj) - if obj.DaqSession.IsRunning + if ~isempty(obj.DaqSession)&&obj.DaqSession.IsRunning obj.DaqSession.wait(); end if ~isempty(obj.DigitalDaqSession)&&obj.DigitalDaqSession.IsRunning obj.DigitalDaqSession.wait(); end + if ~isempty(obj.ClockDaqSession)&&obj.ClockDaqSession.IsRunning + obj.ClockDaqSession.wait(); + end end + end end From 7526eee6f17e90dab70f34b7025c4762bcd07c9a Mon Sep 17 00:00:00 2001 From: jaib1 Date: Tue, 27 Nov 2018 15:51:32 +0000 Subject: [PATCH 219/507] manual changes to resolve remote conflicts --- .gitmodules | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.gitmodules b/.gitmodules index 32b91d63..77c1349b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,16 +1,16 @@ [submodule "alyx-matlab"] path = alyx-matlab - url = https://github.com/jaib1/alyx-matlab/ + url = https://github.com/cortex-lab/alyx-matlab/ branch = dev [submodule "signals"] path = signals - url = https://github.com/jaib1/signals + url = https://github.com/cortex-lab/signals branch = dev [submodule "npy-matlab"] path = npy-matlab - url = https://github.com/jaib1/npy-matlab - branch = dev + url = https://github.com/cortex-lab/npy-matlab + branch = master [submodule "wheelAnalysis"] path = wheelAnalysis - url = https://github.com/jaib1/wheelAnalysis - branch = dev + url = https://github.com/cortex-lab/wheelAnalysis + branch = master From 53273c6d25bcab0a84b2b3873cfbefa642ece759 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 27 Nov 2018 19:01:53 +0000 Subject: [PATCH 220/507] Session water posted by rig instead of mc. Allows rig specific water type. --- +eui/ExpPanel.m | 58 ++++++++++++++++----------------- +exp/Experiment.m | 27 ---------------- cortexlab/+exp/ChoiceWorld.m | 63 +++++++++++++++++++++++++++++++++++- cortexlab/+git/update.m | 12 ------- 4 files changed, 91 insertions(+), 69 deletions(-) diff --git a/+eui/ExpPanel.m b/+eui/ExpPanel.m index d9fd958c..6827dbdc 100644 --- a/+eui/ExpPanel.m +++ b/+eui/ExpPanel.m @@ -222,35 +222,35 @@ function expStopped(obj, rig, ~) obj.Listeners = []; obj.Root.TitleColor = [1 0.3 0.22]; % red title area %post water to Alyx - ai = rig.AlyxInstance; - subject = obj.SubjectRef; - if ~isempty(ai)&&~strcmp(subject,'default') - switch class(obj) - case 'eui.ChoiceExpPanel' - if ~isfield(obj.Block.trial,'feedbackType'); return; end % No completed trials - if any(strcmp(obj.Parameters.TrialSpecificNames,'rewardVolume')) % Reward is trial specific - condition = [obj.Block.trial.condition]; - reward = [condition.rewardVolume]; - amount = sum(reward(:,[obj.Block.trial.feedbackType]==1), 2); - else % Global reward x positive feedback - amount = obj.Parameters.Struct.rewardVolume(1)*... - sum([obj.Block.trial.feedbackType]==1); - end - if numel(amount)>1; amount = amount(1); end % Take first element (second being laser) - otherwise - % Done in exp.SignalsExp/saveData - %infoFields = {obj.InfoFields.String}; - %inc = cellfun(@(x) any(strfind(x(:)','�l')), {obj.InfoFields.String}); % Find event values ending with 'ul'. - %reward = cell2mat(cellfun(@str2num,strsplit(infoFields{find(inc,1)},'�l'),'UniformOutput',0)); - %amount = iff(isempty(reward),0,@()reward); - end - if ~any(amount); return; end % Return if no water was given - try - ai.postWater(subject, amount*0.001, now, 'Water', ai.SessionURL); - catch - warning('Failed to post the %.2fml %s recieved during the experiment to Alyx', amount*0.001, subject); - end - end +% ai = rig.AlyxInstance; +% subject = obj.SubjectRef; +% if ~isempty(ai)&&~strcmp(subject,'default') +% switch class(obj) +% case 'eui.ChoiceExpPanel' +% if ~isfield(obj.Block.trial,'feedbackType'); return; end % No completed trials +% if any(strcmp(obj.Parameters.TrialSpecificNames,'rewardVolume')) % Reward is trial specific +% condition = [obj.Block.trial.condition]; +% reward = [condition.rewardVolume]; +% amount = sum(reward(:,[obj.Block.trial.feedbackType]==1), 2); +% else % Global reward x positive feedback +% amount = obj.Parameters.Struct.rewardVolume(1)*... +% sum([obj.Block.trial.feedbackType]==1); +% end +% if numel(amount)>1; amount = amount(1); end % Take first element (second being laser) +% otherwise +% % Done in exp.SignalsExp/saveData +% %infoFields = {obj.InfoFields.String}; +% %inc = cellfun(@(x) any(strfind(x(:)','�l')), {obj.InfoFields.String}); % Find event values ending with 'ul'. +% %reward = cell2mat(cellfun(@str2num,strsplit(infoFields{find(inc,1)},'�l'),'UniformOutput',0)); +% %amount = iff(isempty(reward),0,@()reward); +% end +% if ~any(amount); return; end % Return if no water was given +% try +% ai.postWater(subject, amount*0.001, now, 'Water', ai.SessionURL); +% catch +% warning('Failed to post the %.2fml %s recieved during the experiment to Alyx', amount*0.001, subject); +% end +% end end function expUpdate(obj, rig, evt) diff --git a/+exp/Experiment.m b/+exp/Experiment.m index b236aa93..5f411ac9 100644 --- a/+exp/Experiment.m +++ b/+exp/Experiment.m @@ -775,33 +775,6 @@ function saveData(obj) % save the data to the appropriate locations indicated by expRef savepaths = dat.expFilePath(obj.Data.expRef, 'block'); superSave(savepaths, struct('block', obj.Data)); - - if ~obj.AlyxInstance.IsLoggedIn - warning('No Alyx token set'); - else - try - subject = dat.parseExpRef(obj.Data.expRef); - if strcmp(subject, 'default'); return; end - % Register saved files - obj.AlyxInstance.registerFile(savepaths{end}); - % Save the session end time - if ~isempty(obj.AlyxInstance.SessionURL) - numTrials = obj.Data.numCompletedTrials; - if isfield(obj.Data, 'trial')&&isfield(obj.Data.trial, 'feedbackType') - numCorrect = sum([obj.Data.trial.feedbackType] == 1); - else - numCorrect = 0; - end - sessionData = struct('end_time', obj.AlyxInstance.datestr(now), ... - 'subject', subject, 'numberOfTrials', numTrials, 'numberOfCorrectTrials', numCorrect); - obj.AlyxInstance.postData(obj.AlyxInstance.SessionURL, sessionData, 'put'); - else - % Infer from date session and retrieve using expFilePath - end - catch ex - warning(ex.identifier, 'Failed to register files to Alyx: %s', ex.message); - end - end end end diff --git a/cortexlab/+exp/ChoiceWorld.m b/cortexlab/+exp/ChoiceWorld.m index 4c8d1edb..f55f0bb2 100644 --- a/cortexlab/+exp/ChoiceWorld.m +++ b/cortexlab/+exp/ChoiceWorld.m @@ -172,7 +172,68 @@ function drawFrame(obj) % obj.Data.trial(obj.TrialNum).stimFrame(n).time = obj.Clock.now; % obj.Data.trial(obj.TrialNum).stimFrame(n).targetBounds = round(bounds); end - end + end + + function saveData(obj) + saveData@exp.Experiment(obj); + if ~obj.AlyxInstance.IsLoggedIn + warning('No Alyx token set'); + else + try + subject = dat.parseExpRef(obj.Data.expRef); + if strcmp(subject, 'default'); return; end + % Register saved files + savepaths = dat.expFilePath(obj.Data.expRef, 'block'); + obj.AlyxInstance.registerFile(savepaths{end}); + % Save the session end time + if ~isempty(obj.AlyxInstance.SessionURL) + numTrials = obj.Data.numCompletedTrials; + if isfield(obj.Data, 'trial')&&isfield(obj.Data.trial, 'feedbackType') + numCorrect = sum([obj.Data.trial.feedbackType] == 1); + else + numCorrect = 0; + end + sessionData = struct('end_time', obj.AlyxInstance.datestr(now), ... + 'subject', subject, 'n_trials', numTrials, 'n_correct_trials', numCorrect); + obj.AlyxInstance.postData(obj.AlyxInstance.SessionURL, sessionData, 'put'); + else + % Infer from date session and retrieve using expFilePath + end + catch ex + warning(ex.identifier, 'Failed to register files to Alyx: %s', ex.message); + end + try + if ~isfield(obj.Data,'trial') && ~isfield(obj.Data.trial,'feedbackType') + return % No completed trials + end + % Reward volume + if ~any(strcmp(fieldnames(obj.Data.parameters),'rewardVolume')) % Reward is trial specific + condition = [obj.Data.trial.condition]; + reward = [condition.rewardVolume]; + amount = sum(reward(:,[obj.Data.trial.feedbackType]==1), 2); + else % Global reward x positive feedback + amount = obj.Data.parameters.rewardVolume(1)*... + sum([obj.Data.trial.feedbackType]==1); + end + % Reward on stimulus + if ~any(strcmp(fieldnames(obj.Data.parameters),'rewardOnStimulus')) % Reward is trial specific + condition = [obj.Data.trial.condition]; + stimReward = sum([condition.rewardOnStimulus],2); + amount = amount(1) + stimReward; + else % Global reward x positive feedback + amount = amount(1) + obj.Data.parameters.rewardOnStimulus(1); + end + if numel(amount)>1; amount = amount(1); end % Take first element (second being laser) + if ~any(amount); return; end % Return if no water was given + controller = obj.RewardController.SignalGenerators(strcmp(obj.RewardController.ChannelNames,'rewardValve')); + type = iff(isprop(controller, 'WaterType'), controller.WaterType, 'Water'); + obj.AlyxInstance.postWater(subject, amount*0.001, now, type, obj.AlyxInstance.SessionURL); + catch ex + warning(ex.identifier, 'Failed to post water to Alyx: %s', ex.message); + end + end + end + end end diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index 52705ca1..d45d1f79 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -46,18 +46,6 @@ function update(fatalOnError, scheduled) end end % TODO: check if submodules are empty and use init flag -cmdstr = strjoin({gitexepath, 'submodule update --remote --merge'}); -status = system(cmdstr, '-echo'); -if status ~= 0 - if fatalOnError - cd(origDir) - error('gitUpdate:submodule:updateFailed', ... - 'Failed to pull latest changes for submodules:, %s', cmdout) - else - warning('gitUpdate:submodule:updateFailed', ... - 'Failed to pull latest changes for submodules:, %s', cmdout) - end -end % Run any new tasks changesPath = fullfile(root, 'cortexlab', '+git', 'changes.m'); From f6a1ff5bcedc4fd7d8fe34e224cfe917a67a64ba Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 27 Nov 2018 19:37:13 +0000 Subject: [PATCH 221/507] Simplified ChoiceWorld water post --- cortexlab/+exp/ChoiceWorld.m | 22 +++------------------- cortexlab/+git/changes.m | 1 - 2 files changed, 3 insertions(+), 20 deletions(-) delete mode 100644 cortexlab/+git/changes.m diff --git a/cortexlab/+exp/ChoiceWorld.m b/cortexlab/+exp/ChoiceWorld.m index f55f0bb2..607d6d06 100644 --- a/cortexlab/+exp/ChoiceWorld.m +++ b/cortexlab/+exp/ChoiceWorld.m @@ -203,27 +203,11 @@ function saveData(obj) warning(ex.identifier, 'Failed to register files to Alyx: %s', ex.message); end try - if ~isfield(obj.Data,'trial') && ~isfield(obj.Data.trial,'feedbackType') + if ~isfield(obj.Data,'rewardDeliveredSizes') || ... + strcmp(obj.Data.endStatus, 'aborted') return % No completed trials end - % Reward volume - if ~any(strcmp(fieldnames(obj.Data.parameters),'rewardVolume')) % Reward is trial specific - condition = [obj.Data.trial.condition]; - reward = [condition.rewardVolume]; - amount = sum(reward(:,[obj.Data.trial.feedbackType]==1), 2); - else % Global reward x positive feedback - amount = obj.Data.parameters.rewardVolume(1)*... - sum([obj.Data.trial.feedbackType]==1); - end - % Reward on stimulus - if ~any(strcmp(fieldnames(obj.Data.parameters),'rewardOnStimulus')) % Reward is trial specific - condition = [obj.Data.trial.condition]; - stimReward = sum([condition.rewardOnStimulus],2); - amount = amount(1) + stimReward; - else % Global reward x positive feedback - amount = amount(1) + obj.Data.parameters.rewardOnStimulus(1); - end - if numel(amount)>1; amount = amount(1); end % Take first element (second being laser) + amount = sum(obj.Data.rewardDeliveredSizes(:,1)); % Take first element (second being laser) if ~any(amount); return; end % Return if no water was given controller = obj.RewardController.SignalGenerators(strcmp(obj.RewardController.ChannelNames,'rewardValve')); type = iff(isprop(controller, 'WaterType'), controller.WaterType, 'Water'); diff --git a/cortexlab/+git/changes.m b/cortexlab/+git/changes.m deleted file mode 100644 index 9232640b..00000000 --- a/cortexlab/+git/changes.m +++ /dev/null @@ -1 +0,0 @@ -addRigboxPaths; \ No newline at end of file From 779a9f817726d1c7e37ba5eb9cc66478cf893b20 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 27 Nov 2018 19:57:48 +0000 Subject: [PATCH 222/507] Scheduled update day set in paths file --- +dat/paths.m | 1 + +srv/expServer.m | 3 ++- mc.m | 5 +++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/+dat/paths.m b/+dat/paths.m index 37f96514..aff32ea7 100644 --- a/+dat/paths.m +++ b/+dat/paths.m @@ -28,6 +28,7 @@ p.databaseURL = 'https://alyx.cortexlab.net'; % p.databaseURL = 'https://dev.alyx.internationalbrainlab.org/'; p.gitExe = 'C:\Program Files\Git\cmd\git.exe'; +p.updateSchedule = 2; % Day on which to update code (2 = Monday) % Under the new system of having data grouped by mouse % rather than data type, all experimental data are saved here. diff --git a/+srv/expServer.m b/+srv/expServer.m index be5b3081..05e9c591 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -20,7 +20,8 @@ function expServer(useTimelineOverride, bgColour) %% Initialisation % Pull latest changes from remote -git.update(true, 2); % Update ever Monday +schedule = getOr(dat.paths, 'updateSchedule', 2); +git.update(true, schedule); % random seed random number generator rng('shuffle'); % communicator for receiving commands from clients diff --git a/mc.m b/mc.m index 071f4656..e81013d8 100644 --- a/mc.m +++ b/mc.m @@ -5,8 +5,9 @@ % 2013-06 CB created -% Pull latest changes from remote -git.update(true); +% Pull latest changes from remote every Monday +schedule = getOr(dat.paths, 'updateSchedule', 2); +git.update(true, schedule); f = figure('Name', 'MC',... 'MenuBar', 'none',... 'Toolbar', 'none',... From 318cae02c1c3c6abfb5ff9caa5cd034d6205edd1 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 28 Nov 2018 20:01:42 +0000 Subject: [PATCH 223/507] Launch Webpage for session fix --- +eui/AlyxPanel.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 9f2e83dc..bdf360a2 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -473,7 +473,7 @@ function launchSessionURL(obj) % 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) || ~strcmp(sessions{end}.start_time(1:10), thisDate(1:10)) + if isempty(sessions) || ~strcmp(sessions(end).start_time(1:10), thisDate(1:10)) % 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 base session?', ... From a60e93af3eafaf923bba33b8ce92af16b8858660 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 30 Nov 2018 15:14:55 +0000 Subject: [PATCH 224/507] Small delay for correct behaviour of reward on stim --- cortexlab/+exp/configureChoiceExperiment.m | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cortexlab/+exp/configureChoiceExperiment.m b/cortexlab/+exp/configureChoiceExperiment.m index c37225f9..714f081b 100644 --- a/cortexlab/+exp/configureChoiceExperiment.m +++ b/cortexlab/+exp/configureChoiceExperiment.m @@ -117,6 +117,8 @@ % or 'onsetToneSoundPlayed' 'stimulusCueStarted' stimRewardHandler = exp.EventHandler('stimulusCueStarted'); stimRewardHandler.addAction(exp.DeliverReward('rewardOnStimulus')); + % Small delay to allow time for screen flip before the samples output + stimRewardHandler.Delay = exp.FixedTime(0.05); experiment.addEventHandler(stimRewardHandler); terminationHandler = exp.EventHandler('responseMade'); terminationHandler.addCallback(@(inf,t)reset(inf.Experiment.RewardController)); From b3a0f2f9987d608815a78cb64eab6a87c397e4bf Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 30 Nov 2018 16:06:14 +0000 Subject: [PATCH 225/507] Update tp submodules --- alyx-matlab | 2 +- signals | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/alyx-matlab b/alyx-matlab index 1e193a9d..9f75b53a 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 1e193a9d21d59ac67900a41ef8181c7504d90b00 +Subproject commit 9f75b53a7fbbce7f80caf674157c592e19d91db2 diff --git a/signals b/signals index 8c480d8b..071e4420 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 8c480d8b002ef8ae24c82b138ce4fbeea0d961cb +Subproject commit 071e44207410dab429f5c962999a56ade4d050dc From 0908e3353f806c770d1df7b3d2001469a4f14949 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 3 Dec 2018 16:06:16 +0000 Subject: [PATCH 226/507] Removed star and end dates in water-restriction GET for view subject method --- +eui/AlyxPanel.m | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index bdf360a2..15025b61 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -538,8 +538,7 @@ function viewSubjectHistory(obj, ax) % 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 - endpnt = sprintf('water-requirement/%s?start_date=2016-01-01&end_date=%s', obj.Subject, datestr(now, 'yyyy-mm-dd')); - wr = obj.AlyxInstance.getData(endpnt); + 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 From c5cccc143f2ae63b62ca131409dc2652f48f9e26 Mon Sep 17 00:00:00 2001 From: jaib1 Date: Tue, 4 Dec 2018 14:57:48 +0000 Subject: [PATCH 227/507] commit for updated submodules --- alyx-matlab | 2 +- npy-matlab | 2 +- signals | 2 +- wheelAnalysis | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/alyx-matlab b/alyx-matlab index 3e88d1e9..9f75b53a 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 3e88d1e9c61a9b04eb99c49f269a64db843cb017 +Subproject commit 9f75b53a7fbbce7f80caf674157c592e19d91db2 diff --git a/npy-matlab b/npy-matlab index a7c4900b..b7b0a4ef 160000 --- a/npy-matlab +++ b/npy-matlab @@ -1 +1 @@ -Subproject commit a7c4900b62757e1b657f2cc983a5df3282abd674 +Subproject commit b7b0a4ef6ba26d98a8c54e651d5444083c88311c diff --git a/signals b/signals index 4e64108c..3bfc5dfc 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 4e64108ca6a8874edcb7a3e316df78004e6551ef +Subproject commit 3bfc5dfcdc3bbabbb24f93e25900a4f689161564 diff --git a/wheelAnalysis b/wheelAnalysis index 16979786..05f90203 160000 --- a/wheelAnalysis +++ b/wheelAnalysis @@ -1 +1 @@ -Subproject commit 169797868da3fe93e1581cb4d581cdc4f4d9cd34 +Subproject commit 05f902033bc834c98624d5634be3cf91b737f250 From 6f667d1872c69196e5dab25e4051fe0d5c2e23ed Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 5 Dec 2018 14:35:58 +0000 Subject: [PATCH 228/507] Fix'd weight plot; no longer ploting unweighed days --- +eui/AlyxPanel.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 15025b61..eeb477be 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -547,7 +547,7 @@ function viewSubjectHistory(obj, ax) return end expected = [records.expected_weight]; - expected(expected==0) = nan; + expected(expected==0|cellfun('isempty',{records.weighing_at})) = nan; dates = cellfun(@(x)datenum(x), {records.date}); % build the figure to show it From 1462af7f939cd66f20f147bb1a6923afa0a69763 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 5 Dec 2018 14:40:17 +0000 Subject: [PATCH 229/507] Typo fix in signals repo --- signals | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/signals b/signals index 8c480d8b..0dc60c11 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 8c480d8b002ef8ae24c82b138ce4fbeea0d961cb +Subproject commit 0dc60c11e20a75a3c63da9f80956fb1bb938f801 From 066ec03f97d851adfa411dcefef2f50ce65f060a Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 5 Dec 2018 17:53:24 +0000 Subject: [PATCH 230/507] Updates to modules and update fun --- alyx-matlab | 2 +- cortexlab/+git/update.m | 12 ++++++++---- npy-matlab | 2 +- signals | 2 +- wheelAnalysis | 2 +- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/alyx-matlab b/alyx-matlab index 3e88d1e9..9f75b53a 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 3e88d1e9c61a9b04eb99c49f269a64db843cb017 +Subproject commit 9f75b53a7fbbce7f80caf674157c592e19d91db2 diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index d45d1f79..f084e274 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -11,7 +11,8 @@ function update(fatalOnError, scheduled) root = fileparts(which('addRigboxPaths')); lastFetch = getOr(dir(fullfile(root, '.git', 'FETCH_HEAD')), 'datenum'); -if scheduled && weekday(now) ~= scheduled && now - lastFetch < 7 +if (scheduled && weekday(now) ~= scheduled && now - lastFetch < 7) || ... + (~scheduled && now - lastFetch < 1/24) return end disp('Updating code...') @@ -35,8 +36,12 @@ function update(fatalOnError, scheduled) % return % end -cmdstr = strjoin({gitexepath, 'pull'}); -[status, cmdout] = system(cmdstr); +%%% Check that the submodules are initialized +cmdstr = strjoin({gitexepath, 'submodule', 'update', '--init'}); +[status, cmdout] = system(cmdstr, '-echo'); + +cmdstr = strjoin({gitexepath, 'pull --recurse-submodules'}); +[status, cmdout] = system(cmdstr, '-echo'); if status ~= 0 if fatalOnError cd(origDir) @@ -45,7 +50,6 @@ function update(fatalOnError, scheduled) warning('gitUpdate:pull:pullFailed', 'Failed to pull latest changes:, %s', cmdout) end end -% TODO: check if submodules are empty and use init flag % Run any new tasks changesPath = fullfile(root, 'cortexlab', '+git', 'changes.m'); diff --git a/npy-matlab b/npy-matlab index a7c4900b..b7b0a4ef 160000 --- a/npy-matlab +++ b/npy-matlab @@ -1 +1 @@ -Subproject commit a7c4900b62757e1b657f2cc983a5df3282abd674 +Subproject commit b7b0a4ef6ba26d98a8c54e651d5444083c88311c diff --git a/signals b/signals index 4e64108c..c33a2791 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 4e64108ca6a8874edcb7a3e316df78004e6551ef +Subproject commit c33a2791b7d346f1be97433679fadc5467eedfdb diff --git a/wheelAnalysis b/wheelAnalysis index 16979786..05f90203 160000 --- a/wheelAnalysis +++ b/wheelAnalysis @@ -1 +1 @@ -Subproject commit 169797868da3fe93e1581cb4d581cdc4f4d9cd34 +Subproject commit 05f902033bc834c98624d5634be3cf91b737f250 From ddc5045d6bd1dd86dab61b5b5cd64faf0c479a3c Mon Sep 17 00:00:00 2001 From: jaib1 Date: Thu, 6 Dec 2018 19:18:02 +0000 Subject: [PATCH 231/507] stash and pull strategy changes to git.update --- cortexlab/+git/update.m | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index f084e274..ee2fee72 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -17,14 +17,15 @@ function update(fatalOnError, scheduled) end disp('Updating code...') -% Get the path to the Git exe +% Get the path to the Git exe (can we assume this is unnecessary)? gitexepath = getOr(dat.paths, 'gitExe'); if isempty(gitexepath) - [~,gitexepath] = system('where git'); + [~,gitexepath] = system('where git'); % this doesn't always work end gitexepath = ['"', strtrim(gitexepath), '"']; -% Temporarily change directory into Rigbox +% Temporarily change directory into Rigbox -> isn't it safe to assume we're +% already in Rigbox? (since we're running this file, presumably we are?) origDir = pwd; cd(root) @@ -36,12 +37,17 @@ function update(fatalOnError, scheduled) % return % end -%%% Check that the submodules are initialized -cmdstr = strjoin({gitexepath, 'submodule', 'update', '--init'}); -[status, cmdout] = system(cmdstr, '-echo'); +% Check that the submodules are initialized +cmdstrInit = [gitexepath, ' ', 'submodule update --init']; +[status, cmdout] = system(cmdstrInit, '-echo'); -cmdstr = strjoin({gitexepath, 'pull --recurse-submodules'}); -[status, cmdout] = system(cmdstr, '-echo'); +% Stash any WIP changes +cmdstrStash = [gitexepath ' ' 'stash push -m "stash working changes before scheduled git update"']; +[status, cmdout] = system(cmdstrStash, '-echo'); + +%Pull submodules using "theirs" merge strategy +cmdstrPull = [gitexepath, ' ', 'pull --recurse-submodules --strategy-option=theirs']; +[status, cmdout] = system(cmdstrPull, '-echo'); if status ~= 0 if fatalOnError cd(origDir) From 2a3b473ffcdf3009d47d80f73548f05347621364 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 7 Dec 2018 12:45:07 +0000 Subject: [PATCH 232/507] hw-info ext bugfix; new commits to alyx-matlab; added test paths --- +dat/expFilePath.m | 2 +- alyx-matlab | 2 +- tests/+dat/paths.m | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 tests/+dat/paths.m diff --git a/+dat/expFilePath.m b/+dat/expFilePath.m index b9053047..89133929 100644 --- a/+dat/expFilePath.m +++ b/+dat/expFilePath.m @@ -53,7 +53,7 @@ case 'block' % MAT-file with info about each set of trials suff = '_Block.mat'; case 'hw-info' % MAT-file with info about the hardware used for an experiment - suff = '_hardwareInfo.mat'; + suff = '_hardwareInfo.json'; case '2p-raw' % TIFF with 2-photon raw fluorescence movies suff = '_2P.tif'; case 'calcium-preview' diff --git a/alyx-matlab b/alyx-matlab index 9f75b53a..6a4f2863 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 9f75b53a7fbbce7f80caf674157c592e19d91db2 +Subproject commit 6a4f2863281801627090a69693665a62f10e5aae diff --git a/tests/+dat/paths.m b/tests/+dat/paths.m new file mode 100644 index 00000000..95e39049 --- /dev/null +++ b/tests/+dat/paths.m @@ -0,0 +1,66 @@ +function p = paths(rig) +%DAT.PATHS Returns struct containing important paths for testing +% p = DAT.PATHS([RIG]) +% TODO: +% - Clean up expDefinitions directory +% Part of Rigbox + +% 2013-03 CB created + +thishost = 'dummyRig'; + +if nargin < 1 || isempty(rig) + rig = thishost; +end + +%% defaults +% path containing rigbox config folders +p.rigbox = fileparts(which('addRigboxPaths')); +% Repository for local copy of everything generated on this rig +p.localRepository = 'C:\LocalExpData'; +p.localAlyxQueue = fullfile(p.rigbox, 'tests', 'data', 'alyx'); +p.databaseURL = 'https://alyx-dev.cortexlab.net'; +% p.databaseURL = 'https://dev.alyx.internationalbrainlab.org/'; +p.gitExe = 'C:\Program Files\Git\cmd\git.exe'; + +% Under the new system of having data grouped by mouse +% rather than data type, all experimental data are saved here. +p.mainRepository = fullfile(p.rigbox, 'tests', 'data', 'Subjects'); + +% directory for organisation-wide configuration files, for now these should +% all remain on zserver +p.globalConfig = fullfile(p.rigbox, 'tests', 'data', 'config'); +% directory for rig-specific configuration files +p.rigConfig = fullfile(p.globalConfig, rig); +% repository for all experiment definitions +p.expDefinitions = fullfile(p.rigbox, 'tests', 'data', 'expdefs'); + +% repository for working analyses that are not meant to be stored +% permanently +p.workingAnalysisRepository = fullfile(p.rigbox, 'tests', 'data'); + +% for tape backups, first files go here: +p.tapeStagingRepository = fullfile(p.rigbox, 'tests', 'staging'); + +% then they go here: +p.tapeArchiveRepository = fullfile(p.rigbox, 'tests', 'toarchive'); + + +%% 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 + if isfield(customPaths, 'expInfoRepository') + % 'expInfo' is deprecated, change to 'main' + p.mainRepository = customPaths.expInfoRepository; + customPaths = rmfield(customPaths, 'expInfoRepository'); + end + % merge paths structures, with precedence on the loaded custom paths + p = mergeStructs(customPaths, p); +end + +end From 3f764de7e92c821fdb9104cdc1a070a2edb2dcce Mon Sep 17 00:00:00 2001 From: Jai Bhagat Date: Fri, 7 Dec 2018 14:39:33 +0000 Subject: [PATCH 233/507] Removed questioning directory comments --- cortexlab/+git/update.m | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index ee2fee72..cd5f0cb9 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -17,15 +17,14 @@ function update(fatalOnError, scheduled) end disp('Updating code...') -% Get the path to the Git exe (can we assume this is unnecessary)? +% Get the path to the Git exe gitexepath = getOr(dat.paths, 'gitExe'); if isempty(gitexepath) [~,gitexepath] = system('where git'); % this doesn't always work end gitexepath = ['"', strtrim(gitexepath), '"']; -% Temporarily change directory into Rigbox -> isn't it safe to assume we're -% already in Rigbox? (since we're running this file, presumably we are?) +% Temporarily change directory into Rigbox to git pull origDir = pwd; cd(root) @@ -65,4 +64,4 @@ function update(fatalOnError, scheduled) end cd(origDir) -end \ No newline at end of file +end From 6e8e9de9a2f8ef144715d067e1633b2c95cf034e Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 7 Dec 2018 17:56:43 +0000 Subject: [PATCH 234/507] Give different water types in the future. Today's recorded weigth now displayed --- +eui/AlyxPanel.m | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index eeb477be..db481a67 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -309,8 +309,10 @@ function giveFutureWater(obj) % 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] \n',... + 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 = inputdlg(prompt,'Future Amounts', [1 50]); if isempty(amtStr)||~obj.AlyxInstance.IsLoggedIn @@ -332,7 +334,7 @@ function giveFutureWater(obj) 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), 'Water'); + 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')); @@ -371,7 +373,7 @@ function dispWaterReq(obj, src, ~) else record = struct(); end - weight = getOr(record, 'weight', NaN); % Get today's measured weight + weight = iff(isempty(record.weighing_at), NaN, record.weight); % Get today's measured weight water = getOr(record, 'given_water_liquid', 0); % Get total water given gel = getOr(record, 'given_water_hydrogel', 0); % Get total gel given expected_weight = getOr(record, 'expected_weight', NaN); From 5d31c5acba003d1d52d22bae12a83499076875fd Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 7 Dec 2018 19:17:12 +0000 Subject: [PATCH 235/507] Update to alyx-matlab submodule --- alyx-matlab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx-matlab b/alyx-matlab index 6a4f2863..87cd195c 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 6a4f2863281801627090a69693665a62f10e5aae +Subproject commit 87cd195c4745d74b56e01357255a34984675c89e From 05f723e667427fdc1e67eb670b4eb3ede5575987 Mon Sep 17 00:00:00 2001 From: Jai Bhagat Date: Fri, 7 Dec 2018 22:02:40 +0000 Subject: [PATCH 236/507] Update readme.md --- readme.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/readme.md b/readme.md index 120f73ae..6f6e5470 100644 --- a/readme.md +++ b/readme.md @@ -10,8 +10,8 @@ The following is a brief description of how to install Rigbox on your experiment ## Prerequisites Rigbox has the following software dependencies: -* Windows Operating System (7 or later) -* MATLAB (2016a or later) +* Windows Operating System (7 or later, 64-bit) +* MATLAB (2016b or later) * The following MathWorks MATLAB toolboxes: * Data Acquisition Toolbox * Signal Processing Toolbox @@ -117,7 +117,7 @@ The 'StimulusControl' class is used by the mc computer to manage the stimulus se ### cb-tools/burgbox -Burgbox contains many simple helper functions that are used by the main packages. Within this directory are additional packages: +"Burgbox" contains many simple helper functions that are used by the main packages. Within this directory are additional packages: * +bui --- Classes for managing graphics objects such as axes * +aud --- Functions for interacting with PsychoPortAudio @@ -131,7 +131,11 @@ Burgbox contains many simple helper functions that are used by the main packages ### cortexlab -The cortexlab directory is intended for functions and classes that are rig or cortexlab specific, for instance code that allows compatibility with other stimulus presentation packages used by cortexlab (e.g. MPEP) +The "cortexlab" directory is intended for functions and classes that are rig or cortexlab specific, for instance code that allows compatibility with other stimulus presentation packages used by cortexlab (e.g. MPEP) + +### tests + +The "tests" directory contains code for running unit tests within Rigbox. ### submodules From ad6571b27a368ea7649005c0982d6307e1b9e112 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 10 Dec 2018 14:18:41 +0000 Subject: [PATCH 237/507] Update to signals --- signals | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/signals b/signals index c33a2791..5dd71e46 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit c33a2791b7d346f1be97433679fadc5467eedfdb +Subproject commit 5dd71e4618ef7738a01f7ae6ab8ce79a3505ed63 From 8978915f389f23c935e2efbb20a7d193695548fe Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 10 Dec 2018 15:22:47 +0000 Subject: [PATCH 238/507] Update to signals --- signals | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/signals b/signals index 5dd71e46..83a1cc17 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 5dd71e4618ef7738a01f7ae6ab8ce79a3505ed63 +Subproject commit 83a1cc17e47b98b2aa0a285dd9fe959c2253d5b3 From 89ce3fbb5ad1c0107049221f9a9ddd19820a79cb Mon Sep 17 00:00:00 2001 From: Jai Bhagat Date: Mon, 10 Dec 2018 18:20:53 +0000 Subject: [PATCH 239/507] Update git.update --- cortexlab/+git/update.m | 46 ++++++++++++++--------------------------- 1 file changed, 16 insertions(+), 30 deletions(-) diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index cd5f0cb9..5aa86c58 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -1,4 +1,4 @@ -function update(fatalOnError, scheduled) +function update(scheduled) % GIT.UPDATE Pull latest Rigbox code % Pulls the latest code from the remote repository. If scheduled is a % value in the range [1 7] corresponding to the days of the week, the @@ -6,8 +6,7 @@ function update(fatalOnError, scheduled) % a week ago. % TODO Find quicker way to check for changes % See also -if nargin == 0; fatalOnError = true; end -if nargin < 2; scheduled = 0; end +if nargin < 1; scheduled = 0; end root = fileparts(which('addRigboxPaths')); lastFetch = getOr(dir(fullfile(root, '.git', 'FETCH_HEAD')), 'datenum'); @@ -28,32 +27,20 @@ function update(fatalOnError, scheduled) origDir = pwd; cd(root) -% Check if there are changes before pulling -% cmdstr = strjoin({gitexepath, 'fetch'}); -% system(cmdstr, '-echo'); -% if isempty(cmdout) -% cd(origDir) -% return -% end - -% Check that the submodules are initialized -cmdstrInit = [gitexepath, ' ', 'submodule update --init']; -[status, cmdout] = system(cmdstrInit, '-echo'); - -% Stash any WIP changes -cmdstrStash = [gitexepath ' ' 'stash push -m "stash working changes before scheduled git update"']; -[status, cmdout] = system(cmdstrStash, '-echo'); - -%Pull submodules using "theirs" merge strategy -cmdstrPull = [gitexepath, ' ', 'pull --recurse-submodules --strategy-option=theirs']; -[status, cmdout] = system(cmdstrPull, '-echo'); -if status ~= 0 - if fatalOnError - cd(origDir) - error('gitUpdate:pull:pullFailed', 'Failed to pull latest changes:, %s', cmdout) - else - warning('gitUpdate:pull:pullFailed', 'Failed to pull latest changes:, %s', cmdout) - end +cmdstrStash = [gitexepath, ' stash push -m "stash Rigbox working changes before scheduled git update"']; +cmdstrStashSubs = [gitexepath, ' submodule foreach "git stash push"']; +cmdstrInit = [gitexepath, ' submodule update --init']; +cmdstrPull = [gitexepath, ' pull --recurse-submodules --strategy-option=theirs']; + +% Stash any WIP, check submodules are initialized, pull +try + [status, cmdout] = system(cmdstrStash, '-echo'); + [status, cmdout] = system(cmdstrStashSubs, '-echo'); + [status, cmdout] = system(cmdstrInit, '-echo'); + [status, cmdout] = system(cmdstrPull, '-echo'); +catch ex + cd(origDir) + error('gitUpdate:pull:pullFailed', 'Failed to pull latest changes:, %s', cmdout) end % Run any new tasks @@ -62,6 +49,5 @@ function update(fatalOnError, scheduled) git.changes; delete(changesPath); end - cd(origDir) end From 329bd47d66b75934b0e8502546c9559285efc6c8 Mon Sep 17 00:00:00 2001 From: Jai Bhagat Date: Mon, 10 Dec 2018 18:21:38 +0000 Subject: [PATCH 240/507] corresponding 'git.update' update to 'mc' --- mc.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mc.m b/mc.m index 071f4656..8297142f 100644 --- a/mc.m +++ b/mc.m @@ -6,7 +6,7 @@ % 2013-06 CB created % Pull latest changes from remote -git.update(true); +git.update; f = figure('Name', 'MC',... 'MenuBar', 'none',... 'Toolbar', 'none',... From 5facbea0047b13001dfaa94437b1a300ebbba112 Mon Sep 17 00:00:00 2001 From: Jai Bhagat Date: Mon, 10 Dec 2018 18:22:25 +0000 Subject: [PATCH 241/507] corresponding 'git.update' update to 'srv.expServer' --- +srv/expServer.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+srv/expServer.m b/+srv/expServer.m index 586f03b3..4e8d2bcc 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -20,7 +20,7 @@ function expServer(useTimelineOverride, bgColour) %% Initialisation % Pull latest changes from remote -git.update(true); +git.update; % random seed random number generator rng('shuffle'); % communicator for receiving commands from clients From 71c4ade2106a1c56d68be7208954dabcdcaa2ce8 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 27 Nov 2018 19:57:48 +0000 Subject: [PATCH 242/507] Scheduled update day taken from paths file --- +dat/paths.m | 5 +++-- +srv/expServer.m | 2 +- cortexlab/+git/update.m | 2 +- mc.m | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/+dat/paths.m b/+dat/paths.m index 37f96514..52c1145c 100644 --- a/+dat/paths.m +++ b/+dat/paths.m @@ -25,9 +25,10 @@ % Repository for local copy of everything generated on this rig p.localRepository = 'C:\LocalExpData'; p.localAlyxQueue = 'C:\localAlyxQueue'; -p.databaseURL = 'https://alyx.cortexlab.net'; -% p.databaseURL = 'https://dev.alyx.internationalbrainlab.org/'; +p.databaseURL = 'https://alyx.cortexlab.net'; % 'https://dev.alyx.internationalbrainlab.org/'; p.gitExe = 'C:\Program Files\Git\cmd\git.exe'; +% Day on which to update code (0 = Everyday, 1 = Sunday, etc.) +p.updateSchedule = 0; % Under the new system of having data grouped by mouse % rather than data type, all experimental data are saved here. diff --git a/+srv/expServer.m b/+srv/expServer.m index 4e8d2bcc..235fd286 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -20,7 +20,7 @@ function expServer(useTimelineOverride, bgColour) %% Initialisation % Pull latest changes from remote -git.update; +git.update(); % random seed random number generator rng('shuffle'); % communicator for receiving commands from clients diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index 5aa86c58..86cb25e9 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -6,7 +6,7 @@ function update(scheduled) % a week ago. % TODO Find quicker way to check for changes % See also -if nargin < 1; scheduled = 0; end +if nargin < 1; scheduled = getOr(dat.paths, 'updateSchedule', 0); end root = fileparts(which('addRigboxPaths')); lastFetch = getOr(dir(fullfile(root, '.git', 'FETCH_HEAD')), 'datenum'); diff --git a/mc.m b/mc.m index 8297142f..76379578 100644 --- a/mc.m +++ b/mc.m @@ -6,7 +6,7 @@ % 2013-06 CB created % Pull latest changes from remote -git.update; +git.update(); f = figure('Name', 'MC',... 'MenuBar', 'none',... 'Toolbar', 'none',... From 44090664d7aabbbf15f9014ca9bf685a9fb6ac32 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 14 Dec 2018 16:28:20 +0000 Subject: [PATCH 243/507] Update to submodules --- alyx-matlab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx-matlab b/alyx-matlab index 87cd195c..8eeb2f5f 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 87cd195c4745d74b56e01357255a34984675c89e +Subproject commit 8eeb2f5f9336b701ced0bb9a9c4ce579e4623105 From d23f6fb5b3b90ccac56c056ae03f7ef03f5d9e57 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 14 Dec 2018 18:50:19 +0000 Subject: [PATCH 244/507] Fixed bug with hw-info saved as '.jjson' file --- +dat/expFilePath.m | 2 +- +eui/AlyxPanel.m | 2 +- +srv/expServer.m | 6 +++--- alyx-matlab | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/+dat/expFilePath.m b/+dat/expFilePath.m index 89133929..79736854 100644 --- a/+dat/expFilePath.m +++ b/+dat/expFilePath.m @@ -52,7 +52,7 @@ switch lower(type) case 'block' % MAT-file with info about each set of trials suff = '_Block.mat'; - case 'hw-info' % MAT-file with info about the hardware used for an experiment + case 'hw-info' % JSON-file with info about the hardware used for an experiment suff = '_hardwareInfo.json'; case '2p-raw' % TIFF with 2-photon raw fluorescence movies suff = '_2P.tif'; diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index db481a67..458293e0 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -711,7 +711,7 @@ function log(obj, varargin) case 'down' A = ceil(a*c)/c; otherwise - A = round(a*c)/c; + A = round(a, sigFigures, 'significant'); end end end diff --git a/+srv/expServer.m b/+srv/expServer.m index 586f03b3..501e170f 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -260,13 +260,13 @@ function handleMessage(id, data, host) rig.stimWindow.flip(); % clear the screen after % save a copy of the hardware in JSON - name = dat.expFilePath(expRef, 'hw-info', 'master'); - fid = fopen([name(1:end-3) 'json'], 'w'); + hwInfo = dat.expFilePath(expRef, 'hw-info', 'master'); + fid = fopen(hwInfo, 'w'); fprintf(fid, '%s', obj2json(rig)); fclose(fid); if ~strcmp(dat.parseExpRef(expRef), 'default') try - Alyx.registerFile([name(1:end-3) 'json']); + Alyx.registerFile(hwInfo); catch ex warning(ex.identifier, 'Failed to register hardware info: %s', ex.message); end diff --git a/alyx-matlab b/alyx-matlab index 87cd195c..11038d22 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 87cd195c4745d74b56e01357255a34984675c89e +Subproject commit 11038d22c42f96c0408027c7bbe3f0939f79028a From 8135874475deb241bb3db49613ff9e8990d58732 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 14 Dec 2018 19:01:07 +0000 Subject: [PATCH 245/507] Reverting changes; .mat hw-nfo still used by tlvs. Using strrep to deal with ext in expServer --- +dat/expFilePath.m | 4 ++-- +srv/expServer.m | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/+dat/expFilePath.m b/+dat/expFilePath.m index 79736854..b9053047 100644 --- a/+dat/expFilePath.m +++ b/+dat/expFilePath.m @@ -52,8 +52,8 @@ switch lower(type) case 'block' % MAT-file with info about each set of trials suff = '_Block.mat'; - case 'hw-info' % JSON-file with info about the hardware used for an experiment - suff = '_hardwareInfo.json'; + case 'hw-info' % MAT-file with info about the hardware used for an experiment + suff = '_hardwareInfo.mat'; case '2p-raw' % TIFF with 2-photon raw fluorescence movies suff = '_2P.tif'; case 'calcium-preview' diff --git a/+srv/expServer.m b/+srv/expServer.m index dff9a879..4748aa69 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -260,7 +260,7 @@ function handleMessage(id, data, host) rig.stimWindow.flip(); % clear the screen after % save a copy of the hardware in JSON - hwInfo = dat.expFilePath(expRef, 'hw-info', 'master'); + hwInfo = strrep(dat.expFilePath(expRef, 'hw-info', 'master'), '.mat', '.json'); fid = fopen(hwInfo, 'w'); fprintf(fid, '%s', obj2json(rig)); fclose(fid); From c9d7a00b4c30fc1663057563cd45b86098d21857 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 17 Dec 2018 14:24:31 +0000 Subject: [PATCH 246/507] Wheel assignment back in useRig --- +exp/SignalsExp.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index bcf93e6d..eae1c04e 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -153,8 +153,6 @@ obj.Events.newTrial = net.origin('newTrial'); obj.Events.expStop = net.origin('expStop'); obj.Inputs.wheel = net.origin('wheel'); - obj.Wheel = rig.mouseInput; - obj.Wheel.zero(); obj.Inputs.wheelMM = obj.Inputs.wheel.map(@... (x)obj.Wheel.MillimetresFactor*(x-obj.Wheel.ZeroOffset)).skipRepeats(); obj.Inputs.wheelDeg = obj.Inputs.wheel.map(... @@ -221,6 +219,8 @@ function useRig(obj, rig) warning('squeak:hw', 'No screen configuration specified. Visual locations will be wrong.'); end obj.DaqController = rig.daqController; + obj.Wheel = rig.mouseInput; + obj.Wheel.zero(); if isfield(rig, 'lickDetector') obj.LickDetector = rig.lickDetector; obj.LickDetector.zero(); From 77a3c2c113f77b6fc544ce348b394084916a104d Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 18 Dec 2018 14:09:49 +0000 Subject: [PATCH 247/507] Rolled back DaqController due to issues with DigitalTTL output --- +hw/DaqController.m | 140 ++++++++++++-------------------------------- 1 file changed, 37 insertions(+), 103 deletions(-) diff --git a/+hw/DaqController.m b/+hw/DaqController.m index 524047e4..a9835d40 100644 --- a/+hw/DaqController.m +++ b/+hw/DaqController.m @@ -15,7 +15,7 @@ % %Define the channel ID to output on % daqController.DaqChannelIds = {'ai0'}; % %As it is an analogue output, set the AnalogueChannelsIdx to true - % daqController.AnalogueChannelsIdx(1) = true; + % daqController.AnalogueChannelIdx(1) = true; % %Add a signal generator that will return the correct samples for % %delivering a reward of a specified volume % daqController.SignalGenerators(1) = hw.RewardValveControl; @@ -37,11 +37,8 @@ % See also HW.CONTROLSIGNALGENERATOR, HW.DAQROTARYENCODER % 2013 CB created % 2017-07 MW added digital output support - % 2018-11 JB added output reward as clock channel - % 2018-11 JB added simultaneous analog/digital and digital/digital output properties - ChannelNames = {} % name to refer to each channel %Signal generator for each channel. Each should be an object of class %hw.ControlSignalGenerator, for generating command waveforms. @@ -51,75 +48,53 @@ SampleRate = 1000 % output sample rate ("scans/sec") of the daq device % 1000 is also the default of the ni daq devices themselves, so if % you don't change this, it doesn't actually do anything. - end properties (Transient) - DaqSession % should be a DAQ session containing at least one analogue output channel DigitalDaqSession % a DAQ session containing only digital output channels - ClockDaqSession % a DAQ session for implementing output to a clock channel - end properties (Dependent) - Value %The current voltage on each DAQ channel NumChannels %Number of channels controlled AnalogueChannelsIdx %Logical array of analogue channel IDs - end properties (Access = private, Transient) - CurrValue - end methods - function createDaqChannels(obj) - if isempty(obj.DaqSession)&&any(strncmp('ao',(obj.DaqChannelIds),2)) + if isempty(obj.DaqSession) obj.DaqSession = daq.createSession('ni'); obj.DaqSession.Rate = obj.SampleRate; end - if isempty(obj.DigitalDaqSession)&&any(strncmp('port',(obj.DaqChannelIds),4)) + if isempty(obj.DigitalDaqSession)&&any(~obj.AnalogueChannelsIdx) obj.DigitalDaqSession = daq.createSession('ni'); end - if isempty(obj.ClockDaqSession)&&any(strncmp('ctr',(obj.DaqChannelIds),3)) - obj.ClockDaqSession = daq.createSession('ni'); - end n = obj.NumChannels; if n > 0 - for i = 1:n + for ii = 1:n if iscell(obj.DaqIds) - daqid = obj.DaqIds{i}; + daqid = obj.DaqIds{ii}; else daqid = obj.DaqIds; end - % is channel analogue? - if strncmp('ao',(obj.DaqChannelIds{i}),2) + if obj.AnalogueChannelsIdx(ii) % is channal analogue? obj.DaqSession.addAnalogOutputChannel(... - daqid, obj.DaqChannelIds{i}, 'Voltage'); - % is channel clock output? - elseif strncmp('ctr',(obj.DaqChannelIds{i}),3) - obj.ClockDaqSession.addCounterOutputChannel(... - daqid, obj.DaqChannelIds{i}, 'PulseGeneration'); - else % assume digital, always 'OutputOnly' + daqid, obj.DaqChannelIds{ii}, 'Voltage'); + else % assume digital, always output only obj.DigitalDaqSession.addDigitalChannel(... - daqid, obj.DaqChannelIds{i}, 'OutputOnly'); + daqid, obj.DaqChannelIds{ii}, 'OutputOnly'); end end - - % what are these lines doing? why outputSingleScan? why a/d separate? - v = [obj.SignalGenerators.DefaultValue]; -% obj.DaqSession.outputSingleScan(v(obj.AnalogueChannelsIdx)); -% % digital (non-clock) channel only -% nonClockDigis = strncmp('port',(obj.DaqChannelIds),4); -% if any(nonClockDigis) -% obj.DigitalDaqSession.outputSingleScan(v(nonClockDigis)); -% end + obj.DaqSession.outputSingleScan(v(obj.AnalogueChannelsIdx)); + if any(~obj.AnalogueChannelsIdx) + obj.DigitalDaqSession.outputSingleScan(v(~obj.AnalogueChannelsIdx)); + end obj.CurrValue = v; else obj.CurrValue = []; @@ -130,14 +105,12 @@ function command(obj, varargin) % Sends command signals to each channel % % command(channels, values) - % sends command signal to a channel with the corresponding value - % (i.e. there is a channel-value pair for each command signal) - % 'channels' is a cell array of strings with each channel name, and - % 'value' is a cell array of values? + % sends command signals to each channel carrying each value. + % 'channels' is a cell array of strings with each channel name, and + % value is % % command(values) - % for length of values, sends command signals to the corresponding - % ordered channels + % sends command signals to all channels carrying each value % % [CHANNEL,INDEX] = addAnalogInputChannel(...) % addAnalogInputChannel optionally returns CHANNEL, which is an @@ -170,13 +143,13 @@ function command(obj, varargin) gen = obj.SignalGenerators(1:n); rate = obj.DaqSession.Rate; waveforms = cell(1, n); - for i = 1:n + for ii = 1:n if iscell(values) - v = values{i}; + v = values{ii}; else - v = values(:,i); + v = values(:,ii); end - waveforms{i} = gen(i).waveform(rate, v); + waveforms{ii} = gen(ii).waveform(rate, v); end if obj.DaqSession.IsRunning % if a daq operation is in progress, stop it, and set its output @@ -185,8 +158,7 @@ function command(obj, varargin) end channelNames = obj.ChannelNames(1:n); analogueChannelsIdx = obj.AnalogueChannelsIdx(1:n); - % for all analogue channel outputs - if any(analogueChannelsIdx)&&any(values(:,analogueChannelsIdx)~=0) + if any(analogueChannelsIdx)&&any(any(values(:,analogueChannelsIdx)~=0)) queue(obj, channelNames(analogueChannelsIdx), waveforms(analogueChannelsIdx)); if foreground startForeground(obj.DaqSession); @@ -195,43 +167,18 @@ function command(obj, varargin) end readyWait(obj); obj.DaqSession.release; - end - % for all digital or clock outputs (why does this have to be an else?) - if any(~analogueChannelsIdx)&&any(values(:,~analogueChannelsIdx)~=0) - maxLnWaveform = max(cellfun(@length, waveforms)); - % pad shorter waveforms - for i = 1:length(waveforms) - waveforms{i}(end:maxLnWaveform) = waveforms{i}(end); - end - waveformsMtx = cell2mat(waveforms); - %if iscolumn(waveformsMtx), waveformsMtx = waveformsMtx'; end - % output columns of waveformsMtx (values for each channel) - for n = 1:size(waveformsMtx,2) - %if we have some value to output - if any(waveformsMtx(:,n)) - % for clock output channels with a valid value to output - if strncmp('ctr',(obj.DaqChannelIds{n}),3) - obj.ClockDaqSession.DurationInSeconds = length(waveformsMtx) / obj.SampleRate; - %Duty Cycle must be b/w 0-1, so set to 'n' and scale frequency by 1/n - obj.ClockDaqSession.Channels.DutyCycle = 0.99; - obj.ClockDaqSession.Channels.Frequency = 1/obj.ClockDaqSession.DurationInSeconds/0.99; - startBackground(obj.ClockDaqSession); - %for digital output channels - elseif strncmp('port',(obj.DaqChannelIds{n}),4) - obj.DigitalDaqSession.outputSingleScan(waveformsMtx(:,n)); + elseif any(~analogueChannelsIdx) + waveforms = waveforms(~analogueChannelsIdx); + for n = 1:length(waveforms) + digitalValues = waveforms{n}; + for m = 1:length(digitalValues) + obj.DigitalDaqSession.outputSingleScan(digitalValues(m)); end end - end end end end - function clearSessions(obj) - obj.DaqSession = []; - obj.DigitalDaqSession = []; - obj.ClockDaqSession = []; - end - function v = get.NumChannels(obj) v = numel(obj.DaqChannelIds); end @@ -246,9 +193,7 @@ function clearSessions(obj) function set.Value(obj, v) readyWait(obj); - if any (obj.AnalogueChannelsIdx) - obj.DaqSession.outputSingleScan(v(obj.AnalogueChannelsIdx)); - end + obj.DaqSession.outputSingleScan(v(obj.AnalogueChannelsIdx)); if any(~obj.AnalogueChannelsIdx) obj.DigitalDaqSession.outputSingleScan(v(~obj.AnalogueChannelsIdx)); end @@ -256,26 +201,20 @@ function clearSessions(obj) end function reset(obj) - if ~isempty(obj.DaqSession) - stop(obj.DaqSession); - end + stop(obj.DaqSession); if ~isempty(obj.DigitalDaqSession) stop(obj.DigitalDaqSession); end v = [obj.SignalGenerators.DefaultValue]; - if any(obj.AnalogueChannelsIdx) - outputSingleScan(obj.DaqSession, v(obj.AnalogueChannelsIdx)); - end + outputSingleScan(obj.DaqSession, v(obj.AnalogueChannelsIdx)); if any(~obj.AnalogueChannelsIdx) outputSingleScan(obj.DigitalDaqSession, v(~obj.AnalogueChannelsIdx)); end obj.CurrValue = v; end - end methods (Access = protected) - function queue(obj, names, waveforms) names = ensureCell(names); waveforms = ensureCell(waveforms); @@ -284,13 +223,12 @@ function queue(obj, names, waveforms) len = cellfun(@numel, waveforms); defaultValues = [obj.SignalGenerators.DefaultValue]; samples = repmat(defaultValues(obj.AnalogueChannelsIdx), max(len), 1); - for i = 1:numel(waveforms) - % cidx = strcmp(names{i}, obj.ChannelNames); - % assert(sum(cidx) == 1, 'Channel name mismatch'); - % samples(1:len(i),cidx) = waveforms{i}; - samples(1:len(i),i) = waveforms{i}; + for ii = 1:numel(waveforms) + cidx = strcmp(names{ii}, obj.ChannelNames); + assert(sum(cidx) == 1, 'Channel name mismatch'); + samples(1:len(ii),cidx) = waveforms{ii}; end - %readyWait(obj); + readyWait(obj); % plot(samples,'-x'), xlim([-1 300]) obj.DaqSession.queueOutputData(samples); % samplelen = size(samples,1)/1000 @@ -298,17 +236,13 @@ function queue(obj, names, waveforms) end function readyWait(obj) - if ~isempty(obj.DaqSession)&&obj.DaqSession.IsRunning + if obj.DaqSession.IsRunning obj.DaqSession.wait(); end if ~isempty(obj.DigitalDaqSession)&&obj.DigitalDaqSession.IsRunning obj.DigitalDaqSession.wait(); end - if ~isempty(obj.ClockDaqSession)&&obj.ClockDaqSession.IsRunning - obj.ClockDaqSession.wait(); - end end - end end From 0e5e4004e1daecdfefc2486994016899249a1990 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 18 Dec 2018 18:22:41 +0000 Subject: [PATCH 248/507] Logs no longer subtract reference time: already done by obj.Clock.now method --- +exp/SignalsExp.m | 8 ++++---- signals | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index eae1c04e..2eb9d192 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -600,17 +600,17 @@ function cleanup(obj) % collate the logs %events - obj.Data.events = logs(obj.Events, obj.Clock.ReferenceTime); + obj.Data.events = logs(obj.Events); %params parsLog = obj.ParamsLog.Node.CurrValue; obj.Data.paramsValues = [parsLog.value]; obj.Data.paramsTimes = [parsLog.time]; %inputs - obj.Data.inputs = logs(obj.Inputs, obj.Clock.ReferenceTime); + obj.Data.inputs = logs(obj.Inputs); %outputs - obj.Data.outputs = logs(obj.Outputs, obj.Clock.ReferenceTime); + obj.Data.outputs = logs(obj.Outputs); %audio -% obj.Data.audio = logs(audio, clockZeroTime); +% obj.Data.audio = logs(audio); % MATLAB time stamp for ending the experiment obj.Data.endDateTime = stopdatetime; diff --git a/signals b/signals index 83a1cc17..1c9127de 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 83a1cc17e47b98b2aa0a285dd9fe959c2253d5b3 +Subproject commit 1c9127de66f3edc4c2b0063019b3e9045929a21c From b55ea8e46cecd03d87e7b75fcd9a9c286a1c19a8 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 18 Dec 2018 19:41:27 +0000 Subject: [PATCH 249/507] Releasing hardware and ports properly after experiment end --- +srv/expServer.m | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/+srv/expServer.m b/+srv/expServer.m index 4748aa69..36ab8078 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -44,7 +44,20 @@ function expServer(useTimelineOverride, bgColour) KbQueueStart(); % get rig hardware -rig = hw.devices; +try + rig = hw.devices; +catch ME + fun.applyForce({ + @() communicator.close(),... + @() delete(listener),... + @KbQueueRelease,... + @() Screen('CloseAll'),... + @() PsychPortAudio('Close'),... + @() Priority(0),... %set back to normal priority level + @() PsychPortAudio('Verbosity', oldPpaVerbosity)... + }); + rethrow(ME) +end cleanup = onCleanup(@() fun.applyForce({ @() communicator.close(),... @@ -255,6 +268,7 @@ function handleMessage(id, data, host) communicator.EventMode = false; % back to pull message mode aborted = strcmp(experiment.Data.endStatus, 'aborted'); % clear the active experiment var + experiment.delete() experiment = []; rig.stimWindow.BackgroundColour = bgColour; rig.stimWindow.flip(); % clear the screen after From e5ed26f0cc6876b27203944e0ed79b235a8d85db Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 19 Dec 2018 16:16:10 +0000 Subject: [PATCH 250/507] Removed fieldOrDefault; same functionality as getOr --- +eui/MControl.m | 2 +- cb-tools/burgbox/+img/ImageArray.m | 4 ++-- cb-tools/burgbox/fieldOrDefault.m | 25 ------------------------- signals | 2 +- 4 files changed, 4 insertions(+), 29 deletions(-) delete mode 100644 cb-tools/burgbox/fieldOrDefault.m diff --git a/+eui/MControl.m b/+eui/MControl.m index 3ca11966..5b25075d 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -88,7 +88,7 @@ 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. diff --git a/cb-tools/burgbox/+img/ImageArray.m b/cb-tools/burgbox/+img/ImageArray.m index 8c0ae1e1..71cb986d 100644 --- a/cb-tools/burgbox/+img/ImageArray.m +++ b/cb-tools/burgbox/+img/ImageArray.m @@ -130,8 +130,8 @@ else %TODO: generate it end - obj.BaseGenerator = fieldOrDefault(s, 'baseGenerator', []); - obj.Transforms = fieldOrDefault(s, 'transforms', []); + obj.BaseGenerator = getOr(s, 'baseGenerator', []); + obj.Transforms = getOr(s, 'transforms', []); end end diff --git a/cb-tools/burgbox/fieldOrDefault.m b/cb-tools/burgbox/fieldOrDefault.m deleted file mode 100644 index f349ba37..00000000 --- a/cb-tools/burgbox/fieldOrDefault.m +++ /dev/null @@ -1,25 +0,0 @@ -function value = fieldOrDefault(v, name, default) -%FIELDORDEFAULT Returns value of a field or a default if non-existent -% V = FIELDORDEFAULT(s, name, [default]) returns the value of the field -% 'name' in 's', or if no such field exists, returns 'default'. If no -% default is passed, [] is used. -% -% This works on structures or class objects (in which case it is the -% named property). -% -% Part of Burgbox - -% 2013-02 CB created - -if nargin < 3 - default = []; -end - -if ~isempty(v) && isfield(v, name) - value = v.(name); -else - value = default; -end - -end - diff --git a/signals b/signals index 1c9127de..dd82909b 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 1c9127de66f3edc4c2b0063019b3e9045929a21c +Subproject commit dd82909b1f101d94c577890059eb9bba25a9857d From 3b6a32270963c9dc92cc79858dbd3ab2f5d23e58 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 19 Dec 2018 16:27:33 +0000 Subject: [PATCH 251/507] Added ext input to expFilePath --- +dat/expFilePath.m | 84 ++++++++++++++++++++++++++++++---------------- +hw/Timeline.m | 10 +++--- +srv/expServer.m | 3 +- 3 files changed, 62 insertions(+), 35 deletions(-) diff --git a/+dat/expFilePath.m b/+dat/expFilePath.m index b9053047..37f9add7 100644 --- a/+dat/expFilePath.m +++ b/+dat/expFilePath.m @@ -7,88 +7,117 @@ % 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' % 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, 'Error: Not enough arguments supplied.') + +parsed = catStructs(regexp(varargin{1}, dat.expRefRegExp, 'names')); +if isempty(parsed) % Subject, not ref + if nargin > 4 + location = varargin{5}; + varargin(5) = []; + else + location = {}; + end + typeIdx = 4; +else % Ref, not subject + typeIdx = 2; + if nargin > 2 + location = varargin{3}; + varargin(3) = []; + else + location = {}; + end 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). 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 - suff = '_Block.mat'; + suff = '_Block'; case 'hw-info' % MAT-file with info about the hardware used for an experiment - suff = '_hardwareInfo.mat'; + suff = '_hardwareInfo'; case '2p-raw' % TIFF with 2-photon raw fluorescence movies suff = '_2P.tif'; + ext = '.tif'; case 'calcium-preview' - suff = '_2P_CalciumPreview.tif'; + suff = '_2P_CalciumPreview'; + ext = '.tif'; case 'calcium-reg' suff = '_2P_CalciumReg'; + ext = ''; case 'calcium-regframe' - suff = '_2P_CalciumRegFrame.tif'; + suff = '_2P_CalciumRegFrame'; + ext = '.tif'; case 'timeline' % MAT-file with acquired timing information - suff = '_Timeline.mat'; + suff = '_Timeline'; case 'calcium-roi' - suff = '_ROI.mat'; + suff = '_ROI'; case 'calcium-fc' % minimally filtered fractional change frames suff = '_2P_CalciumFC'; + ext = ''; case 'calcium-ffc' % ROI filtered fractional change frames suff = '_2P_CalciumFFC'; + ext = ''; case 'calcium-widefield-svd' suff = '_SVD'; + ext = ''; case 'eyetracking' suff = '_eye'; + ext = ''; case 'parameters' % MAT-file with parameters used for experiment - suff = '_parameters.mat'; + suff = '_parameters'; case 'lasermanip' - suff = '_laserManip.mat'; + suff = '_laserManip'; case 'img-info' - suff = '_imgInfo.mat'; + suff = '_imgInfo'; case 'tmaze' - suff = '_TMaze.mat'; + suff = '_TMaze'; case 'expdeffun' - suff = '_expDef.m'; + suff = '_expDef'; + ext = '.m'; case 'svdspatialcomps' dateLevel = true; 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 @@ -103,5 +132,4 @@ filename = filename{1}; end -end - +end \ No newline at end of file diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 020a96c2..59cee5ba 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -57,6 +57,7 @@ % - In future could implement option to only write to disk to avoid % memory limitations when aquiring a lot of data % - Delete local binary files once timeline has successfully saved to zserver? +% - save par file in json instead % % See also HW.TIMELINECLOCK, HW.TLOUTPUT % @@ -83,7 +84,7 @@ AquiredDataType = 'double' % default data type for the acquired data array (i.e. Data.rawDAQData) UseTimeline = false % used by expServer. If true, timeline is started by default (otherwise can be toggled with the t key) LivePlot = false % if true the data are plotted as the data are aquired - FigureScale = []; % figure position in normalized units, default is [0 0 1 1] (full screen) + FigureScale = [0 0 1 1]; % figure position in normalized units, default is full screen WriteBufferToDisk = false % if true the data buffer is written to disk as they're aquired NB: in the future this will happen by default end @@ -155,9 +156,9 @@ function start(obj, expRef, ai) %find the local path to save the data to file during aquisition if obj.WriteBufferToDisk fprintf(1, 'opening binary file for writing\n'); - localPath = dat.expFilePath(expRef, 'timeline', 'local'); % get the local exp data path + localPath = dat.expFilePath(expRef, 'timeline', 'local', 'dat'); % get the local exp data path if ~dat.expExists(expRef); mkdir(fileparts(localPath)); end % if the folder doesn't exist, create it - obj.DataFID = fopen([localPath(1:end-4) '.dat'], 'w'); % open a binary data file + obj.DataFID = fopen(localPath, 'w'); % open a binary data file % save params now so if things crash later you at least have this record of the data type and size so you can load the dat parfid = fopen([localPath(1:end-4) '.par'], 'w'); % open a parameter file fprintf(parfid, 'type = %s\n', obj.AquiredDataType); % record the data type @@ -604,7 +605,6 @@ function livePlot(obj, data) % TL.LIVEPLOT(source, event) plots the data aquired by the % DAQ while the PlotLive property is true. if isempty(obj.Axes) - %f = figure('Units', 'Normalized', 'Position', [0 0 1 1]); % create a figure for plotting aquired data f = figure('Units', 'Normalized'); obj.Axes = gca; % store a handle to the axes if isprop(obj, 'FigurePosition') && ~isempty(obj.FigurePosition) @@ -665,4 +665,4 @@ function livePlot(obj, data) end end end -end \ No newline at end of file +end diff --git a/+srv/expServer.m b/+srv/expServer.m index 4748aa69..79878279 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -260,8 +260,7 @@ function handleMessage(id, data, host) rig.stimWindow.flip(); % clear the screen after % save a copy of the hardware in JSON - hwInfo = strrep(dat.expFilePath(expRef, 'hw-info', 'master'), '.mat', '.json'); - fid = fopen(hwInfo, 'w'); + fid = fopen(dat.expFilePath(expRef, 'hw-info', 'master', 'json'), 'w'); fprintf(fid, '%s', obj2json(rig)); fclose(fid); if ~strcmp(dat.parseExpRef(expRef), 'default') From 3169f66a0bec0e1a72d7b67d5dd1f10485ad5231 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 19 Dec 2018 18:46:05 +0000 Subject: [PATCH 252/507] bug fix: variable required transpose --- +dat/expFilePath.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+dat/expFilePath.m b/+dat/expFilePath.m index 37f9add7..20310348 100644 --- a/+dat/expFilePath.m +++ b/+dat/expFilePath.m @@ -50,7 +50,7 @@ if any(numel(varargin) == [3,5]); varargin(end) = []; end % convert file types to file suffixes -[repos, suffix, dateLevel] = mapToCell(@typeInfo, fileType, extention); +[repos, suffix, dateLevel] = mapToCell(@typeInfo, fileType(:), extention(:)); reposArgs = cat(2, {repos}, location); From fcd6dcb0fc03cadcd65d29503a20c666e92c0ffc Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 9 Jan 2019 15:12:09 +0000 Subject: [PATCH 253/507] Checks for local folder rather than remote when creating new dir for buffer --- +hw/Timeline.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+hw/Timeline.m b/+hw/Timeline.m index 59cee5ba..c97756c2 100644 --- a/+hw/Timeline.m +++ b/+hw/Timeline.m @@ -157,7 +157,7 @@ function start(obj, expRef, ai) if obj.WriteBufferToDisk fprintf(1, 'opening binary file for writing\n'); localPath = dat.expFilePath(expRef, 'timeline', 'local', 'dat'); % get the local exp data path - if ~dat.expExists(expRef); mkdir(fileparts(localPath)); end % if the folder doesn't exist, create it + if ~exist(fileparts(localPath),'dir'); mkdir(fileparts(localPath)); end % if the folder doesn't exist, create it obj.DataFID = fopen(localPath, 'w'); % open a binary data file % save params now so if things crash later you at least have this record of the data type and size so you can load the dat parfid = fopen([localPath(1:end-4) '.par'], 'w'); % open a parameter file From ddd772bf8336ec711ef225091e7b05af1a2e2a8e Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 10 Jan 2019 21:51:22 +0200 Subject: [PATCH 254/507] Auto cleanup of WeekendWater profile; changes to comments --- +eui/AlyxPanel.m | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 458293e0..8ed2241a 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -142,14 +142,14 @@ '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 water in future', ... 'Enable', 'off',... 'Callback', @(~,~)obj.giveFutureWater); - % Check box to indicate whether water was gel or liquid + % Dropdown to indicate water type (sucrose, gel, etc.) obj.WaterType = uicontrol('Parent', waterbox,... 'Style', 'popupmenu', ... 'String', {'Water'}, ... @@ -328,6 +328,11 @@ function giveFutureWater(obj) 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 futWtrDates = futDates(amt > 0); % future water giving dates From 24ce0fde5c354dbfb21769059cf0bae953b9ea29 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 11 Jan 2019 13:13:19 +0000 Subject: [PATCH 255/507] Clearer plots and tables in view subject history --- +eui/AlyxPanel.m | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 8ed2241a..f8b6da14 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -456,11 +456,11 @@ function recordWeight(obj, weight, subject) try w = postWeight(ai, weight, subject); obj.log('Alyx weight posting succeeded: %.2f for %s', w.weight, w.subject); - catch + 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!'); + obj.log('Warning: Alyx weight posting failed! %s', ex.message); end end % Update weight and refresh login timer @@ -554,7 +554,7 @@ function viewSubjectHistory(obj, ax) return end expected = [records.expected_weight]; - expected(expected==0|cellfun('isempty',{records.weighing_at})) = nan; + expected(expected==0|isnan([records.weighing_at])) = nan; dates = cellfun(@(x)datenum(x), {records.date}); % build the figure to show it @@ -567,13 +567,18 @@ function viewSubjectHistory(obj, ax) ax = axes('Parent', plotBox); end - plot(ax, dates, [records.weight], '.-'); + plot(ax, dates, [records.weighing_at], '.-'); 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 - if numel(dates) > 1; xlim(ax, [min(dates) max(dates)]); end + maxDate = max(dates([records.is_water_restricted]|~isnan([records.weighing_at]))); + if numel(dates) > 1 && ~isempty(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)) else @@ -584,23 +589,23 @@ function viewSubjectHistory(obj, ax) if nargin==1 ax = axes('Parent', plotBox); - plot(ax, dates, ([records.weight]-iw)./(expected-iw), '.-'); + plot(ax, dates, ([records.weighing_at]-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) max(dates)]); + xlim(ax, [min(dates) maxDate]); set(ax, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false)) ylabel(ax, 'weight as pct (%)'); axWater = axes('Parent',plotBox); - plot(axWater, dates, obj.round([records.given_water_liquid]+[records.given_water_hydrogel], 'up'), '.-'); + plot(axWater, dates, obj.round([records.given_water_total], 'up'), '.-'); hold(axWater, 'on'); plot(axWater, dates, obj.round([records.given_water_hydrogel], 'down'), '.-'); plot(axWater, dates, obj.round([records.given_water_liquid], 'down'), '.-'); plot(axWater, dates, obj.round([records.expected_water], 'up'), 'r', 'LineWidth', 2.0); box(axWater, 'off'); - xlim(axWater, [min(dates) max(dates)]); + xlim(axWater, [min(dates) maxDate]); set(axWater, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(axWater, 'XTick'), 'uni', false)) ylabel(axWater, 'water/hydrogel (mL)'); @@ -609,23 +614,24 @@ function viewSubjectHistory(obj, ax) histTable = uitable('Parent', histbox,... 'FontName', 'Consolas',... 'RowName', []); - weightsByDate = num2cell([records.weight]); + weightsByDate = num2cell([records.weighing_at]); weightsByDate = cellfun(@(x)sprintf('%.1f', x), weightsByDate, 'uni', false); - weightsByDate(isnan([records.weight])) = {[]}; - weightPctByDate = num2cell(([records.weight]-iw)./(expected-iw)); + weightsByDate(isnan([records.weighing_at])) = {[]}; + weightPctByDate = num2cell(([records.weighing_at]-iw)./(expected-iw)); weightPctByDate = cellfun(@(x)sprintf('%.1f', x*100), weightPctByDate, 'uni', false); - weightPctByDate(isnan([records.weight])) = {[]}; + weightPctByDate(isnan([records.weighing_at])|~[records.is_water_restricted]) = {[]}; dat = horzcat(... arrayfun(@(x)datestr(x), dates', 'uni', false), ... weightsByDate', ... - arrayfun(@(x)sprintf('%.1f', 0.8*(x-iw)+iw), [records.expected_weight]', 'uni', false), ... + arrayfun(@(x)iff(isnan(x), [], @()sprintf('%.1f', 0.8*(x-iw)+iw)), expected', 'uni', false), ... weightPctByDate'); waterDat = (... num2cell(horzcat([records.given_water_liquid]', [records.given_water_hydrogel]', ... - [records.given_water_liquid]'+[records.given_water_hydrogel]', [records.expected_water]',... - [records.given_water_liquid]'+[records.given_water_hydrogel]'-[records.expected_water]'))); + [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'}, ... From b64f90bd4eccfd08ebd917d757c54922cea10457 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 11 Jan 2019 13:13:51 +0000 Subject: [PATCH 256/507] Update to submodule; can't post zero weights --- alyx-matlab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx-matlab b/alyx-matlab index 11038d22..df90cdae 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 11038d22c42f96c0408027c7bbe3f0939f79028a +Subproject commit df90cdae00175b43aa769a3d24fc6f8b87828e03 From 77febac0f851b3a9ebc88f9cbac7e578bbfe006c Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 11 Jan 2019 13:13:19 +0000 Subject: [PATCH 257/507] Clearer plots and tables in view subject history --- +eui/AlyxPanel.m | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 9f2e83dc..5fa174f7 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -449,11 +449,11 @@ function recordWeight(obj, weight, subject) try w = postWeight(ai, weight, subject); obj.log('Alyx weight posting succeeded: %.2f for %s', w.weight, w.subject); - catch + 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!'); + obj.log('Warning: Alyx weight posting failed! %s', ex.message); end end % Update weight and refresh login timer @@ -548,7 +548,7 @@ function viewSubjectHistory(obj, ax) return end expected = [records.expected_weight]; - expected(expected==0) = nan; + expected(expected==0|isnan([records.weighing_at])) = nan; dates = cellfun(@(x)datenum(x), {records.date}); % build the figure to show it @@ -561,13 +561,18 @@ function viewSubjectHistory(obj, ax) ax = axes('Parent', plotBox); end - plot(ax, dates, [records.weight], '.-'); + plot(ax, dates, [records.weighing_at], '.-'); 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 - if numel(dates) > 1; xlim(ax, [min(dates) max(dates)]); end + maxDate = max(dates([records.is_water_restricted]|~isnan([records.weighing_at]))); + if numel(dates) > 1 && ~isempty(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)) else @@ -578,23 +583,23 @@ function viewSubjectHistory(obj, ax) if nargin==1 ax = axes('Parent', plotBox); - plot(ax, dates, ([records.weight]-iw)./(expected-iw), '.-'); + plot(ax, dates, ([records.weighing_at]-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) max(dates)]); + xlim(ax, [min(dates) maxDate]); set(ax, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(ax, 'XTick'), 'uni', false)) ylabel(ax, 'weight as pct (%)'); axWater = axes('Parent',plotBox); - plot(axWater, dates, obj.round([records.given_water_liquid]+[records.given_water_hydrogel], 'up'), '.-'); + plot(axWater, dates, obj.round([records.given_water_total], 'up'), '.-'); hold(axWater, 'on'); plot(axWater, dates, obj.round([records.given_water_hydrogel], 'down'), '.-'); plot(axWater, dates, obj.round([records.given_water_liquid], 'down'), '.-'); plot(axWater, dates, obj.round([records.expected_water], 'up'), 'r', 'LineWidth', 2.0); box(axWater, 'off'); - xlim(axWater, [min(dates) max(dates)]); + xlim(axWater, [min(dates) maxDate]); set(axWater, 'XTickLabel', arrayfun(@(x)datestr(x, 'dd-mmm'), get(axWater, 'XTick'), 'uni', false)) ylabel(axWater, 'water/hydrogel (mL)'); @@ -603,23 +608,24 @@ function viewSubjectHistory(obj, ax) histTable = uitable('Parent', histbox,... 'FontName', 'Consolas',... 'RowName', []); - weightsByDate = num2cell([records.weight]); + weightsByDate = num2cell([records.weighing_at]); weightsByDate = cellfun(@(x)sprintf('%.1f', x), weightsByDate, 'uni', false); - weightsByDate(isnan([records.weight])) = {[]}; - weightPctByDate = num2cell(([records.weight]-iw)./(expected-iw)); + weightsByDate(isnan([records.weighing_at])) = {[]}; + weightPctByDate = num2cell(([records.weighing_at]-iw)./(expected-iw)); weightPctByDate = cellfun(@(x)sprintf('%.1f', x*100), weightPctByDate, 'uni', false); - weightPctByDate(isnan([records.weight])) = {[]}; + weightPctByDate(isnan([records.weighing_at])|~[records.is_water_restricted]) = {[]}; dat = horzcat(... arrayfun(@(x)datestr(x), dates', 'uni', false), ... weightsByDate', ... - arrayfun(@(x)sprintf('%.1f', 0.8*(x-iw)+iw), [records.expected_weight]', 'uni', false), ... + arrayfun(@(x)iff(isnan(x), [], @()sprintf('%.1f', 0.8*(x-iw)+iw)), expected', 'uni', false), ... weightPctByDate'); waterDat = (... num2cell(horzcat([records.given_water_liquid]', [records.given_water_hydrogel]', ... - [records.given_water_liquid]'+[records.given_water_hydrogel]', [records.expected_water]',... - [records.given_water_liquid]'+[records.given_water_hydrogel]'-[records.expected_water]'))); + [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'}, ... From c7e758a3b316448935bc69a7bf7d15b0630f6835 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 11 Jan 2019 15:18:44 +0000 Subject: [PATCH 258/507] Update to submodules: added params to subsession JSON field --- alyx-matlab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx-matlab b/alyx-matlab index df90cdae..6fc933b9 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit df90cdae00175b43aa769a3d24fc6f8b87828e03 +Subproject commit 6fc933b99bec09689a83b024284e8023d2c5793d From c193c609b199455316c839ec2e828395f130cb4b Mon Sep 17 00:00:00 2001 From: ArminLak Date: Tue, 15 Jan 2019 14:07:18 +0000 Subject: [PATCH 259/507] updated submodule signals --- signals | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/signals b/signals index dd82909b..897da00e 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit dd82909b1f101d94c577890059eb9bba25a9857d +Subproject commit 897da00ee217874db2f271b9dbad42886eb4ff7d From 56dd8b954888e2343ca938da3b72dadb222bb165 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Sun, 20 Jan 2019 18:04:03 +0200 Subject: [PATCH 260/507] Moved Signals vis functions from cortexlab directory --- cortexlab/+vis/checker4.m | 126 ---------------------------------- cortexlab/+vis/checker5.m | 126 ---------------------------------- cortexlab/+vis/checker6.m | 126 ---------------------------------- cortexlab/+vis/checkerLeft.m | 126 ---------------------------------- cortexlab/+vis/checkerRight.m | 126 ---------------------------------- signals | 2 +- 6 files changed, 1 insertion(+), 631 deletions(-) delete mode 100644 cortexlab/+vis/checker4.m delete mode 100644 cortexlab/+vis/checker5.m delete mode 100644 cortexlab/+vis/checker6.m delete mode 100644 cortexlab/+vis/checkerLeft.m delete mode 100644 cortexlab/+vis/checkerRight.m diff --git a/cortexlab/+vis/checker4.m b/cortexlab/+vis/checker4.m deleted file mode 100644 index be837609..00000000 --- a/cortexlab/+vis/checker4.m +++ /dev/null @@ -1,126 +0,0 @@ -function elem = checker3(t) -%vis.checker A grid of rectangles -% Detailed explanation goes here - -elem = t.Node.Net.subscriptableOrigin('checker'); - -%% make initial layers to be used as templates -maskTemplate = vis.emptyLayer(); -maskTemplate.isPeriodic = false; -maskTemplate.interpolation = 'nearest'; -maskTemplate.show = true; -maskTemplate.colourMask = [false false false true]; - -maskTemplate.textureId = 'checkerMaskPixel'; -[maskTemplate.rgba, maskTemplate.rgbaSize] = vis.rgba(0, 0); -maskTemplate.blending = '1-source'; % allows us to lay down our zero alpha value - -stencilTemplate = maskTemplate; -stencilTemplate.textureId = 'checkerStencilPixel'; -[stencilTemplate.rgba, stencilTemplate.rgbaSize] = vis.rgba(1, 1); -stencilTemplate.blending = 'none'; - -% pattern layer uses the alpha values laid down by mask layers -patternLayer = vis.emptyLayer(); -patternLayer.textureId = sprintf('~checker%i', randi(2^32)); -patternLayer.isPeriodic = false; -patternLayer.interpolation = 'nearest'; -patternLayer.blending = 'destination'; % use the alpha mask gets laid down before this - -%% construct signals used to assemble layers -% N rows by cols signal is derived from the size of the pattern array but -% we skip repeats so that pattern changes don't update the mask layers -% unless the size has acutally changed -nRowsByCols = elem.pattern.flatten().map(@size).skipRepeats(); -aziRange = elem.azimuthRange.flatten(); -altRange = elem.altitudeRange.flatten(); -sizeFrac = elem.rectSizeFrac.flatten(); -% signal containing the masking layers -gridMaskLayers = mapn(nRowsByCols, aziRange, altRange, sizeFrac, ... - maskTemplate, stencilTemplate, @gridMask); -% signal contain the checker layer -checkerLayer = scan(elem.pattern.flatten(), @updatePattern,... - elem.colour.flatten(), @updateColour,... - elem.azimuthRange.flatten(), @updateAzi,... - elem.altitudeRange.flatten(), @updateAlt,... - elem.show.flatten(), @updateShow,... - patternLayer); % initial value -%% set default attribute values -elem.layers = [gridMaskLayers checkerLayer]; -elem.azimuthRange = [-132 132]; -elem.altitudeRange = [-36 36]; -elem.rectSizeFrac = [1 1]; % horizontal and vertical size of each rectangle -elem.pattern = [ - 1 -1 1 -1 - -1 0 0 0 - 1 0 0 0 - -1 1 -1 1]; - elem.show = true; -end - -%% helper functions -function layer = updatePattern(layer, pattern) -% map pattern from -1 -> 1 range to 0->255, cast to 8 bit integers, then -% convert to RGBA texture format. -[layer.rgba, layer.rgbaSize] = vis.rgbaFromUint8(uint8(127.5*(1 + pattern)), 1); -end - -function layer = updateColour(layer, colour) -layer.maxColour = [colour 1]; -end - -function layer = updateAzi(layer, aziRange) -layer.size(1) = abs(diff(aziRange)); -layer.texOffset(1) = mean(aziRange); -end - -function layer = updateAlt(layer, altRange) -layer.size(2) = abs(diff(altRange)); -layer.texOffset(2) = mean(altRange); -end - -function layer = updateShow(layer, show) -layer.show = show; -end - -function layers = gridMask(nRowsByCols, aziRange, altRange, sizeFrac, mask, stencil) -gridDims = [abs(diff(aziRange)) abs(diff(altRange))]; -cellSize = gridDims./flip(nRowsByCols); -nCols = nRowsByCols(2) + 1; -nRows = nRowsByCols(1) + 1; -midAzi = mean(aziRange); -midAlt = mean(altRange); -%% base layer to imprint area the checker can draw on (by applying an alpha mask) -stencil.texOffset = [midAzi midAlt]; -stencil.size = gridDims; -if any(sizeFrac < 1) - %% layers for lines making up mask grid - masks out margins around each square - % make layers for vertical lines - if nCols > 1 - azi = linspace(aziRange(1), aziRange(2), nCols); - else - azi = midAzi; - end - collayers = repmat(mask, 1, nCols); - for vi = 1:nCols - collayers(vi).texOffset = [azi(vi) midAlt]; - end - [collayers.size] = deal([(1 - sizeFrac(1))*cellSize(1) gridDims(2)]); - % make layers for horizontal lines - if nRows > 1 - alt = linspace(altRange(1), altRange(2), nRows); - else - alt = midAlt; - end - rowlayers = repmat(mask, 1, nRows); - for hi = 1:nRows - rowlayers(hi).texOffset = [midAzi alt(hi)]; - end - [rowlayers.size] = deal([gridDims(1) (1 - sizeFrac(2))*cellSize(2)]); - %% combine the layers and return - layers = [stencil collayers rowlayers]; -else % no mask grid needed as each cell is full size - layers = stencil; -end - -end \ No newline at end of file diff --git a/cortexlab/+vis/checker5.m b/cortexlab/+vis/checker5.m deleted file mode 100644 index be37a1f4..00000000 --- a/cortexlab/+vis/checker5.m +++ /dev/null @@ -1,126 +0,0 @@ -function elem = checker5(t) -%vis.checker A grid of rectangles -% Detailed explanation goes here - -elem = t.Node.Net.subscriptableOrigin('checker'); - -%% make initial layers to be used as templates -maskTemplate = vis.emptyLayer(); -maskTemplate.isPeriodic = false; -maskTemplate.interpolation = 'nearest'; -maskTemplate.show = true; -maskTemplate.colourMask = [false false false true]; - -maskTemplate.textureId = 'checkerMaskPixel'; -[maskTemplate.rgba, maskTemplate.rgbaSize] = vis.rgba(0, 0); -maskTemplate.blending = '1-source'; % allows us to lay down our zero alpha value - -stencilTemplate = maskTemplate; -stencilTemplate.textureId = 'checkerStencilPixel'; -[stencilTemplate.rgba, stencilTemplate.rgbaSize] = vis.rgba(1, 1); -stencilTemplate.blending = 'none'; - -% pattern layer uses the alpha values laid down by mask layers -patternLayer = vis.emptyLayer(); -patternLayer.textureId = sprintf('~checker%i', randi(2^32)); -patternLayer.isPeriodic = false; -patternLayer.interpolation = 'nearest'; -patternLayer.blending = 'destination'; % use the alpha mask gets laid down before this - -%% construct signals used to assemble layers -% N rows by cols signal is derived from the size of the pattern array but -% we skip repeats so that pattern changes don't update the mask layers -% unless the size has acutally changed -nRowsByCols = elem.pattern.flatten().map(@size).skipRepeats(); -aziRange = elem.azimuthRange.flatten(); -altRange = elem.altitudeRange.flatten(); -sizeFrac = elem.rectSizeFrac.flatten(); -% signal containing the masking layers -gridMaskLayers = mapn(nRowsByCols, aziRange, altRange, sizeFrac, ... - maskTemplate, stencilTemplate, @gridMask); -% signal contain the checker layer -checkerLayer = scan(elem.pattern.flatten(), @updatePattern,... - elem.colour.flatten(), @updateColour,... - elem.azimuthRange.flatten(), @updateAzi,... - elem.altitudeRange.flatten(), @updateAlt,... - elem.show.flatten(), @updateShow,... - patternLayer); % initial value -%% set default attribute values -elem.layers = [gridMaskLayers checkerLayer]; -elem.azimuthRange = [-132 132]; -elem.altitudeRange = [-36 36]; -elem.rectSizeFrac = [1 1]; % horizontal and vertical size of each rectangle -elem.pattern = [ - 1 -1 1 -1 - -1 0 0 0 - 1 0 0 0 - -1 1 -1 1]; - elem.show = true; -end - -%% helper functions -function layer = updatePattern(layer, pattern) -% map pattern from -1 -> 1 range to 0->255, cast to 8 bit integers, then -% convert to RGBA texture format. -[layer.rgba, layer.rgbaSize] = vis.rgbaFromUint8_PC(uint8(127.5*(1 + pattern)), 1); -end - -function layer = updateColour(layer, colour) -layer.maxColour = [colour 1]; -end - -function layer = updateAzi(layer, aziRange) -layer.size(1) = abs(diff(aziRange)); -layer.texOffset(1) = mean(aziRange); -end - -function layer = updateAlt(layer, altRange) -layer.size(2) = abs(diff(altRange)); -layer.texOffset(2) = mean(altRange); -end - -function layer = updateShow(layer, show) -layer.show = show; -end - -function layers = gridMask(nRowsByCols, aziRange, altRange, sizeFrac, mask, stencil) -gridDims = [abs(diff(aziRange)) abs(diff(altRange))]; -cellSize = gridDims./fliplr(nRowsByCols); -nCols = nRowsByCols(2) + 1; -nRows = nRowsByCols(1) + 1; -midAzi = mean(aziRange); -midAlt = mean(altRange); -%% base layer to imprint area the checker can draw on (by applying an alpha mask) -stencil.texOffset = [midAzi midAlt]; -stencil.size = gridDims; -if any(sizeFrac < 1) - %% layers for lines making up mask grid - masks out margins around each square - % make layers for vertical lines - if nCols > 1 - azi = linspace(aziRange(1), aziRange(2), nCols); - else - azi = midAzi; - end - collayers = repmat(mask, 1, nCols); - for vi = 1:nCols - collayers(vi).texOffset = [azi(vi) midAlt]; - end - [collayers.size] = deal([(1 - sizeFrac(1))*cellSize(1) gridDims(2)]); - % make layers for horizontal lines - if nRows > 1 - alt = linspace(altRange(1), altRange(2), nRows); - else - alt = midAlt; - end - rowlayers = repmat(mask, 1, nRows); - for hi = 1:nRows - rowlayers(hi).texOffset = [midAzi alt(hi)]; - end - [rowlayers.size] = deal([gridDims(1) (1 - sizeFrac(2))*cellSize(2)]); - %% combine the layers and return - layers = [stencil collayers rowlayers]; -else % no mask grid needed as each cell is full size - layers = stencil; -end - -end \ No newline at end of file diff --git a/cortexlab/+vis/checker6.m b/cortexlab/+vis/checker6.m deleted file mode 100644 index 1733d8f7..00000000 --- a/cortexlab/+vis/checker6.m +++ /dev/null @@ -1,126 +0,0 @@ -function elem = checker6(t) -%vis.checker A grid of rectangles -% Detailed explanation goes here - -elem = t.Node.Net.subscriptableOrigin('checker'); - -%% make initial layers to be used as templates -maskTemplate = vis.emptyLayer(); -maskTemplate.isPeriodic = false; -maskTemplate.interpolation = 'nearest'; -maskTemplate.show = true; -maskTemplate.colourMask = [false false false true]; - -maskTemplate.textureId = 'checkerMaskPixel'; -[maskTemplate.rgba, maskTemplate.rgbaSize] = vis.rgba(0, 0); -maskTemplate.blending = '1-source'; % allows us to lay down our zero alpha value - -stencilTemplate = maskTemplate; -stencilTemplate.textureId = 'checkerStencilPixel'; -[stencilTemplate.rgba, stencilTemplate.rgbaSize] = vis.rgba(1, 1); -stencilTemplate.blending = 'none'; - -% pattern layer uses the alpha values laid down by mask layers -patternLayer = vis.emptyLayer(); -patternLayer.textureId = sprintf('~checker%i', randi(2^32)); -patternLayer.isPeriodic = false; -patternLayer.interpolation = 'nearest'; -patternLayer.blending = 'destination'; % use the alpha mask gets laid down before this - -%% construct signals used to assemble layers -% N rows by cols signal is derived from the size of the pattern array but -% we skip repeats so that pattern changes don't update the mask layers -% unless the size has acutally changed -nRowsByCols = elem.pattern.flatten().map(@size).skipRepeats(); -aziRange = elem.azimuthRange.flatten(); -altRange = elem.altitudeRange.flatten(); -sizeFrac = elem.rectSizeFrac.flatten(); -% signal containing the masking layers -gridMaskLayers = mapn(nRowsByCols, aziRange, altRange, sizeFrac, ... - maskTemplate, stencilTemplate, @gridMask); -% signal contain the checker layer -checkerLayer = scan(elem.pattern.flatten(), @updatePattern,... - elem.colour.flatten(), @updateColour,... - elem.azimuthRange.flatten(), @updateAzi,... - elem.altitudeRange.flatten(), @updateAlt,... - elem.show.flatten(), @updateShow,... - patternLayer); % initial value -%% set default attribute values -elem.layers = [gridMaskLayers checkerLayer]; -elem.azimuthRange = [-135 135]; -elem.altitudeRange = [-37.5 37.5]; -elem.rectSizeFrac = [1 1]; % horizontal and vertical size of each rectangle -elem.pattern = [ - 1 -1 1 -1 - -1 0 0 0 - 1 0 0 0 - -1 1 -1 1]; - elem.show = true; -end - -%% helper functions -function layer = updatePattern(layer, pattern) -% map pattern from -1 -> 1 range to 0->255, cast to 8 bit integers, then -% convert to RGBA texture format. -[layer.rgba, layer.rgbaSize] = vis.rgbaFromUint8(uint8(127.5*(1 + pattern)), 1); -end - -function layer = updateColour(layer, colour) -layer.maxColour = [colour 1]; -end - -function layer = updateAzi(layer, aziRange) -layer.size(1) = abs(diff(aziRange)); -layer.texOffset(1) = mean(aziRange); -end - -function layer = updateAlt(layer, altRange) -layer.size(2) = abs(diff(altRange)); -layer.texOffset(2) = mean(altRange); -end - -function layer = updateShow(layer, show) -layer.show = show; -end - -function layers = gridMask(nRowsByCols, aziRange, altRange, sizeFrac, mask, stencil) -gridDims = [abs(diff(aziRange)) abs(diff(altRange))]; -cellSize = gridDims./flip(nRowsByCols); -nCols = nRowsByCols(2) + 1; -nRows = nRowsByCols(1) + 1; -midAzi = mean(aziRange); -midAlt = mean(altRange); -%% base layer to imprint area the checker can draw on (by applying an alpha mask) -stencil.texOffset = [midAzi midAlt]; -stencil.size = gridDims; -if any(sizeFrac < 1) - %% layers for lines making up mask grid - masks out margins around each square - % make layers for vertical lines - if nCols > 1 - azi = linspace(aziRange(1), aziRange(2), nCols); - else - azi = midAzi; - end - collayers = repmat(mask, 1, nCols); - for vi = 1:nCols - collayers(vi).texOffset = [azi(vi) midAlt]; - end - [collayers.size] = deal([(1 - sizeFrac(1))*cellSize(1) gridDims(2)]); - % make layers for horizontal lines - if nRows > 1 - alt = linspace(altRange(1), altRange(2), nRows); - else - alt = midAlt; - end - rowlayers = repmat(mask, 1, nRows); - for hi = 1:nRows - rowlayers(hi).texOffset = [midAzi alt(hi)]; - end - [rowlayers.size] = deal([gridDims(1) (1 - sizeFrac(2))*cellSize(2)]); - %% combine the layers and return - layers = [stencil collayers rowlayers]; -else % no mask grid needed as each cell is full size - layers = stencil; -end - -end \ No newline at end of file diff --git a/cortexlab/+vis/checkerLeft.m b/cortexlab/+vis/checkerLeft.m deleted file mode 100644 index cf1122d7..00000000 --- a/cortexlab/+vis/checkerLeft.m +++ /dev/null @@ -1,126 +0,0 @@ -function elem = checkerLeft(t) -%vis.checker A grid of rectangles -% Detailed explanation goes here - -elem = t.Node.Net.subscriptableOrigin('checker'); - -%% make initial layers to be used as templates -maskTemplate = vis.emptyLayer(); -maskTemplate.isPeriodic = false; -maskTemplate.interpolation = 'nearest'; -maskTemplate.show = true; -maskTemplate.colourMask = [false false false true]; - -maskTemplate.textureId = 'checkerMaskPixel'; -[maskTemplate.rgba, maskTemplate.rgbaSize] = vis.rgba(0, 0); -maskTemplate.blending = '1-source'; % allows us to lay down our zero alpha value - -stencilTemplate = maskTemplate; -stencilTemplate.textureId = 'checkerStencilPixel'; -[stencilTemplate.rgba, stencilTemplate.rgbaSize] = vis.rgba(1, 1); -stencilTemplate.blending = 'none'; - -% pattern layer uses the alpha values laid down by mask layers -patternLayer = vis.emptyLayer(); -patternLayer.textureId = sprintf('~checker%i', randi(2^32)); -patternLayer.isPeriodic = false; -patternLayer.interpolation = 'nearest'; -patternLayer.blending = 'destination'; % use the alpha mask gets laid down before this - -%% construct signals used to assemble layers -% N rows by cols signal is derived from the size of the pattern array but -% we skip repeats so that pattern changes don't update the mask layers -% unless the size has acutally changed -nRowsByCols = elem.pattern.flatten().map(@size).skipRepeats(); -aziRange = elem.azimuthRange.flatten(); -altRange = elem.altitudeRange.flatten(); -sizeFrac = elem.rectSizeFrac.flatten(); -% signal containing the masking layers -gridMaskLayers = mapn(nRowsByCols, aziRange, altRange, sizeFrac, ... - maskTemplate, stencilTemplate, @gridMask); -% signal contain the checker layer -checkerLayer = scan(elem.pattern.flatten(), @updatePattern,... - elem.colour.flatten(), @updateColour,... - elem.azimuthRange.flatten(), @updateAzi,... - elem.altitudeRange.flatten(), @updateAlt,... - elem.show.flatten(), @updateShow,... - patternLayer); % initial value -%% set default attribute values -elem.layers = [gridMaskLayers checkerLayer]; -elem.azimuthRange = [-135 0]; -elem.altitudeRange = [-37.5 37.5]; -elem.rectSizeFrac = [1 1]; % horizontal and vertical size of each rectangle -elem.pattern = [ - 1 -1 1 -1 - -1 0 0 0 - 1 0 0 0 - -1 1 -1 1]; - elem.show = true; -end - -%% helper functions -function layer = updatePattern(layer, pattern) -% map pattern from -1 -> 1 range to 0->255, cast to 8 bit integers, then -% convert to RGBA texture format. -[layer.rgba, layer.rgbaSize] = vis.rgbaFromUint8(uint8(127.5*(1 + pattern)), 1); -end - -function layer = updateColour(layer, colour) -layer.maxColour = [colour 1]; -end - -function layer = updateAzi(layer, aziRange) -layer.size(1) = abs(diff(aziRange)); -layer.texOffset(1) = mean(aziRange); -end - -function layer = updateAlt(layer, altRange) -layer.size(2) = abs(diff(altRange)); -layer.texOffset(2) = mean(altRange); -end - -function layer = updateShow(layer, show) -layer.show = show; -end - -function layers = gridMask(nRowsByCols, aziRange, altRange, sizeFrac, mask, stencil) -gridDims = [abs(diff(aziRange)) abs(diff(altRange))]; -cellSize = gridDims./flip(nRowsByCols); -nCols = nRowsByCols(2) + 1; -nRows = nRowsByCols(1) + 1; -midAzi = mean(aziRange); -midAlt = mean(altRange); -%% base layer to imprint area the checker can draw on (by applying an alpha mask) -stencil.texOffset = [midAzi midAlt]; -stencil.size = gridDims; -if any(sizeFrac < 1) - %% layers for lines making up mask grid - masks out margins around each square - % make layers for vertical lines - if nCols > 1 - azi = linspace(aziRange(1), aziRange(2), nCols); - else - azi = midAzi; - end - collayers = repmat(mask, 1, nCols); - for vi = 1:nCols - collayers(vi).texOffset = [azi(vi) midAlt]; - end - [collayers.size] = deal([(1 - sizeFrac(1))*cellSize(1) gridDims(2)]); - % make layers for horizontal lines - if nRows > 1 - alt = linspace(altRange(1), altRange(2), nRows); - else - alt = midAlt; - end - rowlayers = repmat(mask, 1, nRows); - for hi = 1:nRows - rowlayers(hi).texOffset = [midAzi alt(hi)]; - end - [rowlayers.size] = deal([gridDims(1) (1 - sizeFrac(2))*cellSize(2)]); - %% combine the layers and return - layers = [stencil collayers rowlayers]; -else % no mask grid needed as each cell is full size - layers = stencil; -end - -end \ No newline at end of file diff --git a/cortexlab/+vis/checkerRight.m b/cortexlab/+vis/checkerRight.m deleted file mode 100644 index 78b03545..00000000 --- a/cortexlab/+vis/checkerRight.m +++ /dev/null @@ -1,126 +0,0 @@ -function elem = checkerRight(t) -%vis.checker A grid of rectangles -% Detailed explanation goes here - -elem = t.Node.Net.subscriptableOrigin('checker'); - -%% make initial layers to be used as templates -maskTemplate = vis.emptyLayer(); -maskTemplate.isPeriodic = false; -maskTemplate.interpolation = 'nearest'; -maskTemplate.show = true; -maskTemplate.colourMask = [false false false true]; - -maskTemplate.textureId = 'checkerMaskPixel'; -[maskTemplate.rgba, maskTemplate.rgbaSize] = vis.rgba(0, 0); -maskTemplate.blending = '1-source'; % allows us to lay down our zero alpha value - -stencilTemplate = maskTemplate; -stencilTemplate.textureId = 'checkerStencilPixel'; -[stencilTemplate.rgba, stencilTemplate.rgbaSize] = vis.rgba(1, 1); -stencilTemplate.blending = 'none'; - -% pattern layer uses the alpha values laid down by mask layers -patternLayer = vis.emptyLayer(); -patternLayer.textureId = sprintf('~checker%i', randi(2^32)); -patternLayer.isPeriodic = false; -patternLayer.interpolation = 'nearest'; -patternLayer.blending = 'destination'; % use the alpha mask gets laid down before this - -%% construct signals used to assemble layers -% N rows by cols signal is derived from the size of the pattern array but -% we skip repeats so that pattern changes don't update the mask layers -% unless the size has acutally changed -nRowsByCols = elem.pattern.flatten().map(@size).skipRepeats(); -aziRange = elem.azimuthRange.flatten(); -altRange = elem.altitudeRange.flatten(); -sizeFrac = elem.rectSizeFrac.flatten(); -% signal containing the masking layers -gridMaskLayers = mapn(nRowsByCols, aziRange, altRange, sizeFrac, ... - maskTemplate, stencilTemplate, @gridMask); -% signal contain the checker layer -checkerLayer = scan(elem.pattern.flatten(), @updatePattern,... - elem.colour.flatten(), @updateColour,... - elem.azimuthRange.flatten(), @updateAzi,... - elem.altitudeRange.flatten(), @updateAlt,... - elem.show.flatten(), @updateShow,... - patternLayer); % initial value -%% set default attribute values -elem.layers = [gridMaskLayers checkerLayer]; -elem.azimuthRange = [0 135]; -elem.altitudeRange = [-37.5 37.5]; -elem.rectSizeFrac = [1 1]; % horizontal and vertical size of each rectangle -elem.pattern = [ - 1 -1 1 -1 - -1 0 0 0 - 1 0 0 0 - -1 1 -1 1]; - elem.show = true; -end - -%% helper functions -function layer = updatePattern(layer, pattern) -% map pattern from -1 -> 1 range to 0->255, cast to 8 bit integers, then -% convert to RGBA texture format. -[layer.rgba, layer.rgbaSize] = vis.rgbaFromUint8(uint8(127.5*(1 + pattern)), 1); -end - -function layer = updateColour(layer, colour) -layer.maxColour = [colour 1]; -end - -function layer = updateAzi(layer, aziRange) -layer.size(1) = abs(diff(aziRange)); -layer.texOffset(1) = mean(aziRange); -end - -function layer = updateAlt(layer, altRange) -layer.size(2) = abs(diff(altRange)); -layer.texOffset(2) = mean(altRange); -end - -function layer = updateShow(layer, show) -layer.show = show; -end - -function layers = gridMask(nRowsByCols, aziRange, altRange, sizeFrac, mask, stencil) -gridDims = [abs(diff(aziRange)) abs(diff(altRange))]; -cellSize = gridDims./flip(nRowsByCols); -nCols = nRowsByCols(2) + 1; -nRows = nRowsByCols(1) + 1; -midAzi = mean(aziRange); -midAlt = mean(altRange); -%% base layer to imprint area the checker can draw on (by applying an alpha mask) -stencil.texOffset = [midAzi midAlt]; -stencil.size = gridDims; -if any(sizeFrac < 1) - %% layers for lines making up mask grid - masks out margins around each square - % make layers for vertical lines - if nCols > 1 - azi = linspace(aziRange(1), aziRange(2), nCols); - else - azi = midAzi; - end - collayers = repmat(mask, 1, nCols); - for vi = 1:nCols - collayers(vi).texOffset = [azi(vi) midAlt]; - end - [collayers.size] = deal([(1 - sizeFrac(1))*cellSize(1) gridDims(2)]); - % make layers for horizontal lines - if nRows > 1 - alt = linspace(altRange(1), altRange(2), nRows); - else - alt = midAlt; - end - rowlayers = repmat(mask, 1, nRows); - for hi = 1:nRows - rowlayers(hi).texOffset = [midAzi alt(hi)]; - end - [rowlayers.size] = deal([gridDims(1) (1 - sizeFrac(2))*cellSize(2)]); - %% combine the layers and return - layers = [stencil collayers rowlayers]; -else % no mask grid needed as each cell is full size - layers = stencil; -end - -end \ No newline at end of file diff --git a/signals b/signals index 897da00e..924de6a5 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 897da00ee217874db2f271b9dbad42886eb4ff7d +Subproject commit 924de6a5b850e1f797a5ad6b24e64644fb5b1f00 From 770499b4e9479c0cedb058bff2142f8c3b86e4d0 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 23 Jan 2019 01:34:36 +0200 Subject: [PATCH 261/507] cellFlat now works with arrays of Signals objects --- cb-tools/burgbox/cellflat.m | 2 +- signals | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cb-tools/burgbox/cellflat.m b/cb-tools/burgbox/cellflat.m index 532b011f..37a5252d 100644 --- a/cb-tools/burgbox/cellflat.m +++ b/cb-tools/burgbox/cellflat.m @@ -18,7 +18,7 @@ if isempty(elem) elem = {elem}; end - flat = [flat; elem]; + flat = [flat; ensureCell(elem)]; end end diff --git a/signals b/signals index 924de6a5..ebea3f63 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 924de6a5b850e1f797a5ad6b24e64644fb5b1f00 +Subproject commit ebea3f63a892edf25fc8b4d81156a219283c4a4c From 72acc9009c325197e5e20b6247488d24d7bca20f Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 24 Jan 2019 18:30:19 +0200 Subject: [PATCH 262/507] Comments still saved loaclly upon failure to post to Alyx --- +dat/updateLogEntry.m | 6 +++++- signals | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/+dat/updateLogEntry.m b/+dat/updateLogEntry.m index 0882ba2a..47678185 100644 --- a/+dat/updateLogEntry.m +++ b/+dat/updateLogEntry.m @@ -13,7 +13,11 @@ function updateLogEntry(subject, id, newEntry) if isfield(newEntry, 'AlyxInstance') % Update session narrative on Alyx if ~isempty(newEntry.comments) && ~strcmp(subject, 'default') - newEntry.comments = newEntry.AlyxInstance.updateNarrative(newEntry.comments); + 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/signals b/signals index ebea3f63..e05d9537 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit ebea3f63a892edf25fc8b4d81156a219283c4a4c +Subproject commit e05d95376fdc1d36b2dac8e55ed9484c3e6edded From 9ad2c06d3d6a02f7624c23fa781dc83b158024b2 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 24 Jan 2019 18:48:15 +0200 Subject: [PATCH 263/507] Updates to session done with PATCH --- +exp/SignalsExp.m | 4 ++-- alyx-matlab | 2 +- cortexlab/+exp/ChoiceWorld.m | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 2eb9d192..f616ea70 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -906,10 +906,10 @@ function saveData(obj) numCorrect = 0; end % Update Alyx session with end time, trial counts and water tye - sessionData = struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject); + sessionData = struct('end_time', obj.AlyxInstance.datestr(now)); if ~isempty(numTrials); sessionData.n_trials = numTrials; end if ~isempty(numCorrect); sessionData.n_correct_trials = numCorrect; end - obj.AlyxInstance.postData(url, sessionData, 'put'); + obj.AlyxInstance.postData(url, sessionData, 'patch'); else % Retrieve session from endpoint % subsessions = obj.AlyxInstance.getData(... diff --git a/alyx-matlab b/alyx-matlab index 6fc933b9..dd2ab36d 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 6fc933b99bec09689a83b024284e8023d2c5793d +Subproject commit dd2ab36de59843edc94c15956967731d81173b74 diff --git a/cortexlab/+exp/ChoiceWorld.m b/cortexlab/+exp/ChoiceWorld.m index 607d6d06..60ad59ec 100644 --- a/cortexlab/+exp/ChoiceWorld.m +++ b/cortexlab/+exp/ChoiceWorld.m @@ -194,8 +194,8 @@ function saveData(obj) numCorrect = 0; end sessionData = struct('end_time', obj.AlyxInstance.datestr(now), ... - 'subject', subject, 'n_trials', numTrials, 'n_correct_trials', numCorrect); - obj.AlyxInstance.postData(obj.AlyxInstance.SessionURL, sessionData, 'put'); + 'n_trials', numTrials, 'n_correct_trials', numCorrect); + obj.AlyxInstance.postData(obj.AlyxInstance.SessionURL, sessionData, 'patch'); else % Infer from date session and retrieve using expFilePath end From eb4cb395a7c58d637e793e88795c4238fb0a5f58 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 24 Jan 2019 18:48:15 +0200 Subject: [PATCH 264/507] Updates to session done with PATCH --- +exp/SignalsExp.m | 4 ++-- alyx-matlab | 2 +- cortexlab/+exp/ChoiceWorld.m | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index 2eb9d192..f616ea70 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -906,10 +906,10 @@ function saveData(obj) numCorrect = 0; end % Update Alyx session with end time, trial counts and water tye - sessionData = struct('end_time', obj.AlyxInstance.datestr(now), 'subject', subject); + sessionData = struct('end_time', obj.AlyxInstance.datestr(now)); if ~isempty(numTrials); sessionData.n_trials = numTrials; end if ~isempty(numCorrect); sessionData.n_correct_trials = numCorrect; end - obj.AlyxInstance.postData(url, sessionData, 'put'); + obj.AlyxInstance.postData(url, sessionData, 'patch'); else % Retrieve session from endpoint % subsessions = obj.AlyxInstance.getData(... diff --git a/alyx-matlab b/alyx-matlab index 7a860739..1f29e306 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 7a860739766fa1a08fd3cb084c7a0b2ec3a7ac37 +Subproject commit 1f29e306747a39cc76d99bb3ac51175309ecbda4 diff --git a/cortexlab/+exp/ChoiceWorld.m b/cortexlab/+exp/ChoiceWorld.m index 607d6d06..60ad59ec 100644 --- a/cortexlab/+exp/ChoiceWorld.m +++ b/cortexlab/+exp/ChoiceWorld.m @@ -194,8 +194,8 @@ function saveData(obj) numCorrect = 0; end sessionData = struct('end_time', obj.AlyxInstance.datestr(now), ... - 'subject', subject, 'n_trials', numTrials, 'n_correct_trials', numCorrect); - obj.AlyxInstance.postData(obj.AlyxInstance.SessionURL, sessionData, 'put'); + 'n_trials', numTrials, 'n_correct_trials', numCorrect); + obj.AlyxInstance.postData(obj.AlyxInstance.SessionURL, sessionData, 'patch'); else % Infer from date session and retrieve using expFilePath end From d54582b72d6a57957a7a2719e2df5b6fbb6bc2ae Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 24 Jan 2019 19:18:28 +0200 Subject: [PATCH 265/507] Added change to patch cached put files to Alyx --- cortexlab/+git/changes.m | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 cortexlab/+git/changes.m diff --git a/cortexlab/+git/changes.m b/cortexlab/+git/changes.m new file mode 100644 index 00000000..d9e9f013 --- /dev/null +++ b/cortexlab/+git/changes.m @@ -0,0 +1,6 @@ +disp('Updating queued Alyx posts...') +posts = dirPlus(getOr(dat.paths, 'localAlyxQueue', 'C:/localAlyxQueue')); +posts = posts(endsWith(posts, 'put')); +newPosts = cellfun(@(str)[str(1:end-3) 'patch'], posts, 'uni', 0); +status = cellfun(@movefile, posts, newPosts); +assert(all(status), 'Unable to rename queued Alyx files, please do this manually') \ No newline at end of file From e4a0325f029e039a08f4ea9c9ac113a87eef9a91 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 24 Jan 2019 19:18:28 +0200 Subject: [PATCH 266/507] Added change to patch cached put files to Alyx --- cortexlab/+git/changes.m | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 cortexlab/+git/changes.m diff --git a/cortexlab/+git/changes.m b/cortexlab/+git/changes.m new file mode 100644 index 00000000..d9e9f013 --- /dev/null +++ b/cortexlab/+git/changes.m @@ -0,0 +1,6 @@ +disp('Updating queued Alyx posts...') +posts = dirPlus(getOr(dat.paths, 'localAlyxQueue', 'C:/localAlyxQueue')); +posts = posts(endsWith(posts, 'put')); +newPosts = cellfun(@(str)[str(1:end-3) 'patch'], posts, 'uni', 0); +status = cellfun(@movefile, posts, newPosts); +assert(all(status), 'Unable to rename queued Alyx files, please do this manually') \ No newline at end of file From 7ad3bab93386a26c83cbd9b4757eedbf09b96a8c Mon Sep 17 00:00:00 2001 From: k1o0 Date: Sat, 26 Jan 2019 16:45:25 +0200 Subject: [PATCH 267/507] Fix bug for when code never fetched --- cortexlab/+git/update.m | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index 86cb25e9..aaf67546 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -9,7 +9,12 @@ function update(scheduled) if nargin < 1; scheduled = getOr(dat.paths, 'updateSchedule', 0); end root = fileparts(which('addRigboxPaths')); -lastFetch = getOr(dir(fullfile(root, '.git', 'FETCH_HEAD')), 'datenum'); +% Attempt to find date of last fetch +fetch_head = fullfile(root, '.git', 'FETCH_HEAD'); +lastFetch = iff(exist(fetch_head,'file')==2, ... % If FETCH_HEAD file exists + @()getOr(dir(fetch_head), 'datenum'), 0); % Retrieve date modified +% If the code has not been fetched in over a week, force and update, +% otherwise return if (scheduled && weekday(now) ~= scheduled && now - lastFetch < 7) || ... (~scheduled && now - lastFetch < 1/24) return From 7ae881309bc8413506b13e1449cf546f97f7266c Mon Sep 17 00:00:00 2001 From: k1o0 Date: Sun, 27 Jan 2019 17:03:20 +0200 Subject: [PATCH 268/507] Bug fix for timeplot --- signals | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/signals b/signals index e05d9537..93520307 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit e05d95376fdc1d36b2dac8e55ed9484c3e6edded +Subproject commit 93520307c1cb1a9dc12fa8bda685a01b9ab80401 From 5dcb8285193ccce8c7b816d3848511402ef89c46 Mon Sep 17 00:00:00 2001 From: jaib1 Date: Mon, 28 Jan 2019 08:16:59 +0000 Subject: [PATCH 269/507] add signals 'tutorials' folder to paths --- addRigboxPaths.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addRigboxPaths.m b/addRigboxPaths.m index b705367e..0364b06f 100644 --- a/addRigboxPaths.m +++ b/addRigboxPaths.m @@ -94,8 +94,8 @@ function addRigboxPaths(savePaths) % Add signals paths, this includes all the core code for running signals % experiments. This submodule is maintained by Chris Burgess. addpath(fullfile(root, 'signals'),... - fullfile(root, 'signals', 'mexnet'),... - fullfile(root, 'signals', 'util')); + fullfile(root, 'signals', 'mexnet'), fullfile(root, 'signals', 'util'),... + fullfile(root, 'signals', 'tutorials')); % Add the Java paths for signals jcp = fullfile(root, 'signals', 'java'); if ~any(strcmp(javaclasspath, jcp)); javaaddpath(jcp); end From 808d565bab222827b60f410842868d09c873b960 Mon Sep 17 00:00:00 2001 From: jaib1 Date: Mon, 28 Jan 2019 12:27:38 +0000 Subject: [PATCH 270/507] Revert "add signals 'tutorials' folder to paths" This reverts commit 5dcb8285193ccce8c7b816d3848511402ef89c46. --- addRigboxPaths.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addRigboxPaths.m b/addRigboxPaths.m index 0364b06f..b705367e 100644 --- a/addRigboxPaths.m +++ b/addRigboxPaths.m @@ -94,8 +94,8 @@ function addRigboxPaths(savePaths) % Add signals paths, this includes all the core code for running signals % experiments. This submodule is maintained by Chris Burgess. addpath(fullfile(root, 'signals'),... - fullfile(root, 'signals', 'mexnet'), fullfile(root, 'signals', 'util'),... - fullfile(root, 'signals', 'tutorials')); + fullfile(root, 'signals', 'mexnet'),... + fullfile(root, 'signals', 'util')); % Add the Java paths for signals jcp = fullfile(root, 'signals', 'java'); if ~any(strcmp(javaclasspath, jcp)); javaaddpath(jcp); end From 3d90407b2353337a7e5d790537a0fc791a9abbf5 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 28 Jan 2019 17:43:37 +0200 Subject: [PATCH 271/507] Update only occurs once on scheduled day --- cortexlab/+git/update.m | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index aaf67546..fd6a7e0b 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -1,11 +1,15 @@ function update(scheduled) % GIT.UPDATE Pull latest Rigbox code % Pulls the latest code from the remote repository. If scheduled is a -% value in the range [1 7] corresponding to the days of the week, the -% function will only continue on that day, or if the last fetch was over -% a week ago. +% value in the range [1 7] corresponding to the days of the week starting +% Sunday, the function will only continue on that day, provided the last +% fetch was over a day ago. If it is not the scheduled day, but the last +% fetch was over a week ago, the function will pull changes. If +% scheduled is false, the function will pull changes provided the last +% fetch was over an hour ago. +% % TODO Find quicker way to check for changes -% See also +% See also DAT.PATHS if nargin < 1; scheduled = getOr(dat.paths, 'updateSchedule', 0); end root = fileparts(which('addRigboxPaths')); @@ -13,9 +17,15 @@ function update(scheduled) fetch_head = fullfile(root, '.git', 'FETCH_HEAD'); lastFetch = iff(exist(fetch_head,'file')==2, ... % If FETCH_HEAD file exists @()getOr(dir(fetch_head), 'datenum'), 0); % Retrieve date modified -% If the code has not been fetched in over a week, force and update, -% otherwise return + +% Don't pull changes if the following conditions are met: +% 1. The updates are scheduled for a different day and the last fetch was less +% than a week ago. +% 2. The updates are scheduled for today and the last fetch was today. +% 3. The updates are scheduled for every day and the last fetch was less +% than an hour ago. if (scheduled && weekday(now) ~= scheduled && now - lastFetch < 7) || ... + (scheduled && weekday(now) == scheduled && now - lastFetch < 1) || ... (~scheduled && now - lastFetch < 1/24) return end @@ -39,10 +49,10 @@ function update(scheduled) % Stash any WIP, check submodules are initialized, pull try - [status, cmdout] = system(cmdstrStash, '-echo'); - [status, cmdout] = system(cmdstrStashSubs, '-echo'); - [status, cmdout] = system(cmdstrInit, '-echo'); - [status, cmdout] = system(cmdstrPull, '-echo'); + [~, cmdout] = system(cmdstrStash, '-echo'); + [~, cmdout] = system(cmdstrStashSubs, '-echo'); + [~, cmdout] = system(cmdstrInit, '-echo'); + [~, cmdout] = system(cmdstrPull, '-echo'); %#ok catch ex cd(origDir) error('gitUpdate:pull:pullFailed', 'Failed to pull latest changes:, %s', cmdout) @@ -55,4 +65,4 @@ function update(scheduled) delete(changesPath); end cd(origDir) -end +end \ No newline at end of file From 0ff74273ede9adfe8ec372ae13d9a41997cad8a7 Mon Sep 17 00:00:00 2001 From: Jai Bhagat Date: Mon, 28 Jan 2019 16:22:31 +0000 Subject: [PATCH 272/507] additional comments for git.update --- cortexlab/+git/update.m | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index fd6a7e0b..a04a9b62 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -1,32 +1,34 @@ function update(scheduled) % GIT.UPDATE Pull latest Rigbox code -% Pulls the latest code from the remote repository. If scheduled is a -% value in the range [1 7] corresponding to the days of the week starting -% Sunday, the function will only continue on that day, provided the last -% fetch was over a day ago. If it is not the scheduled day, but the last -% fetch was over a week ago, the function will pull changes. If -% scheduled is false, the function will pull changes provided the last -% fetch was over an hour ago. +% Pulls the latest code from the remote Github repository. If 'scheduled' +% is a value in the range [1 7] - corresponding to the days of the week, +% with Sunday=1 - code will be pulled only on the 'scheduled' day, +% provided the last fetch was over a day ago. Code will also be pulled if +% it is not the scheduled day, but the last fetch was over a week ago. If +% scheduled is 0, the function will pull changes provided the last fetch +% was over an hour ago. % % TODO Find quicker way to check for changes % See also DAT.PATHS -if nargin < 1; scheduled = getOr(dat.paths, 'updateSchedule', 0); end -root = fileparts(which('addRigboxPaths')); +% If not given as input argument, find 'scheduled' in 'dat.paths'. If not +% found, set 'scheduled' to 0. +if nargin < 1; scheduled = getOr(dat.paths, 'updateSchedule', 0); end +root = fileparts(which('addRigboxPaths')); % Rigbox root directory % Attempt to find date of last fetch fetch_head = fullfile(root, '.git', 'FETCH_HEAD'); lastFetch = iff(exist(fetch_head,'file')==2, ... % If FETCH_HEAD file exists @()getOr(dir(fetch_head), 'datenum'), 0); % Retrieve date modified % Don't pull changes if the following conditions are met: -% 1. The updates are scheduled for a different day and the last fetch was less -% than a week ago. +% 1. The updates are scheduled for a different day and the last fetch was +% less than a week ago. % 2. The updates are scheduled for today and the last fetch was today. % 3. The updates are scheduled for every day and the last fetch was less % than an hour ago. -if (scheduled && weekday(now) ~= scheduled && now - lastFetch < 7) || ... - (scheduled && weekday(now) == scheduled && now - lastFetch < 1) || ... - (~scheduled && now - lastFetch < 1/24) +if ((scheduled && weekday(now)) ~= (scheduled && (now - lastFetch < 7))) || ... + ((scheduled && weekday(now)) == (scheduled && (now - lastFetch < 1))) || ... + (~scheduled && (now - lastFetch < 1/24)) return end disp('Updating code...') @@ -34,7 +36,7 @@ function update(scheduled) % Get the path to the Git exe gitexepath = getOr(dat.paths, 'gitExe'); if isempty(gitexepath) - [~,gitexepath] = system('where git'); % this doesn't always work + [~,gitexepath] = system('where git'); % todo: this doesn't always work end gitexepath = ['"', strtrim(gitexepath), '"']; @@ -42,6 +44,8 @@ function update(scheduled) origDir = pwd; cd(root) +% Create Windows system commands for git stashing, initializing submodules, +% and pulling cmdstrStash = [gitexepath, ' stash push -m "stash Rigbox working changes before scheduled git update"']; cmdstrStashSubs = [gitexepath, ' submodule foreach "git stash push"']; cmdstrInit = [gitexepath, ' submodule update --init']; @@ -65,4 +69,4 @@ function update(scheduled) delete(changesPath); end cd(origDir) -end \ No newline at end of file +end From 2a5185d259499e250caf8f05a8d6641098ab287e Mon Sep 17 00:00:00 2001 From: Jai Bhagat Date: Mon, 28 Jan 2019 16:25:34 +0000 Subject: [PATCH 273/507] updated git.update from commit 0ff742 in 'dev' --- cortexlab/+git/update.m | 47 +++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index 86cb25e9..a04a9b62 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -1,17 +1,34 @@ function update(scheduled) % GIT.UPDATE Pull latest Rigbox code -% Pulls the latest code from the remote repository. If scheduled is a -% value in the range [1 7] corresponding to the days of the week, the -% function will only continue on that day, or if the last fetch was over -% a week ago. +% Pulls the latest code from the remote Github repository. If 'scheduled' +% is a value in the range [1 7] - corresponding to the days of the week, +% with Sunday=1 - code will be pulled only on the 'scheduled' day, +% provided the last fetch was over a day ago. Code will also be pulled if +% it is not the scheduled day, but the last fetch was over a week ago. If +% scheduled is 0, the function will pull changes provided the last fetch +% was over an hour ago. +% % TODO Find quicker way to check for changes -% See also +% See also DAT.PATHS + +% If not given as input argument, find 'scheduled' in 'dat.paths'. If not +% found, set 'scheduled' to 0. if nargin < 1; scheduled = getOr(dat.paths, 'updateSchedule', 0); end +root = fileparts(which('addRigboxPaths')); % Rigbox root directory +% Attempt to find date of last fetch +fetch_head = fullfile(root, '.git', 'FETCH_HEAD'); +lastFetch = iff(exist(fetch_head,'file')==2, ... % If FETCH_HEAD file exists + @()getOr(dir(fetch_head), 'datenum'), 0); % Retrieve date modified -root = fileparts(which('addRigboxPaths')); -lastFetch = getOr(dir(fullfile(root, '.git', 'FETCH_HEAD')), 'datenum'); -if (scheduled && weekday(now) ~= scheduled && now - lastFetch < 7) || ... - (~scheduled && now - lastFetch < 1/24) +% Don't pull changes if the following conditions are met: +% 1. The updates are scheduled for a different day and the last fetch was +% less than a week ago. +% 2. The updates are scheduled for today and the last fetch was today. +% 3. The updates are scheduled for every day and the last fetch was less +% than an hour ago. +if ((scheduled && weekday(now)) ~= (scheduled && (now - lastFetch < 7))) || ... + ((scheduled && weekday(now)) == (scheduled && (now - lastFetch < 1))) || ... + (~scheduled && (now - lastFetch < 1/24)) return end disp('Updating code...') @@ -19,7 +36,7 @@ function update(scheduled) % Get the path to the Git exe gitexepath = getOr(dat.paths, 'gitExe'); if isempty(gitexepath) - [~,gitexepath] = system('where git'); % this doesn't always work + [~,gitexepath] = system('where git'); % todo: this doesn't always work end gitexepath = ['"', strtrim(gitexepath), '"']; @@ -27,6 +44,8 @@ function update(scheduled) origDir = pwd; cd(root) +% Create Windows system commands for git stashing, initializing submodules, +% and pulling cmdstrStash = [gitexepath, ' stash push -m "stash Rigbox working changes before scheduled git update"']; cmdstrStashSubs = [gitexepath, ' submodule foreach "git stash push"']; cmdstrInit = [gitexepath, ' submodule update --init']; @@ -34,10 +53,10 @@ function update(scheduled) % Stash any WIP, check submodules are initialized, pull try - [status, cmdout] = system(cmdstrStash, '-echo'); - [status, cmdout] = system(cmdstrStashSubs, '-echo'); - [status, cmdout] = system(cmdstrInit, '-echo'); - [status, cmdout] = system(cmdstrPull, '-echo'); + [~, cmdout] = system(cmdstrStash, '-echo'); + [~, cmdout] = system(cmdstrStashSubs, '-echo'); + [~, cmdout] = system(cmdstrInit, '-echo'); + [~, cmdout] = system(cmdstrPull, '-echo'); %#ok catch ex cd(origDir) error('gitUpdate:pull:pullFailed', 'Failed to pull latest changes:, %s', cmdout) From c9c27a22749d09111754585a90a2528d261598f3 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 28 Jan 2019 19:53:47 +0200 Subject: [PATCH 274/507] Fix'd incorrect brackets --- cortexlab/+git/update.m | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index a04a9b62..200ba269 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -11,8 +11,8 @@ function update(scheduled) % TODO Find quicker way to check for changes % See also DAT.PATHS -% If not given as input argument, find 'scheduled' in 'dat.paths'. If not -% found, set 'scheduled' to 0. +% If not given as input argument, use 'updateSchedule' in 'dat.paths'. If +% not found, set 'scheduled' to 0. if nargin < 1; scheduled = getOr(dat.paths, 'updateSchedule', 0); end root = fileparts(which('addRigboxPaths')); % Rigbox root directory % Attempt to find date of last fetch @@ -26,9 +26,9 @@ function update(scheduled) % 2. The updates are scheduled for today and the last fetch was today. % 3. The updates are scheduled for every day and the last fetch was less % than an hour ago. -if ((scheduled && weekday(now)) ~= (scheduled && (now - lastFetch < 7))) || ... - ((scheduled && weekday(now)) == (scheduled && (now - lastFetch < 1))) || ... - (~scheduled && (now - lastFetch < 1/24)) +if (scheduled && (weekday(now) ~= scheduled) && now - lastFetch < 7) || ... + (scheduled && (weekday(now) == scheduled) && now - lastFetch < 1) || ... + (~scheduled && now - lastFetch < 1/24) return end disp('Updating code...') From cbb131337a85a5b1a25c56b1254db16cf3cf3a44 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 28 Jan 2019 19:53:47 +0200 Subject: [PATCH 275/507] Fix'd incorrect brackets --- cortexlab/+git/update.m | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index a04a9b62..200ba269 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -11,8 +11,8 @@ function update(scheduled) % TODO Find quicker way to check for changes % See also DAT.PATHS -% If not given as input argument, find 'scheduled' in 'dat.paths'. If not -% found, set 'scheduled' to 0. +% If not given as input argument, use 'updateSchedule' in 'dat.paths'. If +% not found, set 'scheduled' to 0. if nargin < 1; scheduled = getOr(dat.paths, 'updateSchedule', 0); end root = fileparts(which('addRigboxPaths')); % Rigbox root directory % Attempt to find date of last fetch @@ -26,9 +26,9 @@ function update(scheduled) % 2. The updates are scheduled for today and the last fetch was today. % 3. The updates are scheduled for every day and the last fetch was less % than an hour ago. -if ((scheduled && weekday(now)) ~= (scheduled && (now - lastFetch < 7))) || ... - ((scheduled && weekday(now)) == (scheduled && (now - lastFetch < 1))) || ... - (~scheduled && (now - lastFetch < 1/24)) +if (scheduled && (weekday(now) ~= scheduled) && now - lastFetch < 7) || ... + (scheduled && (weekday(now) == scheduled) && now - lastFetch < 1) || ... + (~scheduled && now - lastFetch < 1/24) return end disp('Updating code...') From b36cadb46f9b9deec89de5d6edf93aec00237bb8 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 31 Jan 2019 00:38:11 +0200 Subject: [PATCH 276/507] Attempt to integrate new Parameter Editor into mc --- +eui/ConditionPanel.m | 252 ++++++++++++++++ +eui/FieldPanel.m | 164 +++++++++++ +eui/MControl.m | 24 +- +eui/ParamEditor.m | 630 +++++++++++++--------------------------- +eui/ParamEditor_old.m | 516 ++++++++++++++++++++++++++++++++ +exp/Parameters.m | 10 +- cortexlab/+git/update.m | 2 +- signals | 2 +- 8 files changed, 1160 insertions(+), 440 deletions(-) create mode 100644 +eui/ConditionPanel.m create mode 100644 +eui/FieldPanel.m create mode 100644 +eui/ParamEditor_old.m diff --git a/+eui/ConditionPanel.m b/+eui/ConditionPanel.m new file mode 100644 index 00000000..7c4b89fe --- /dev/null +++ b/+eui/ConditionPanel.m @@ -0,0 +1,252 @@ +classdef ConditionPanel < handle + %UNTITLED Summary of this class goes here + % Detailed explanation goes here + % TODO Document + % TODO Add sort by column + % TODO Add set condition idx + % TODO Use tags for menu items + + properties + ConditionTable + MinWidth = 80 +% MaxWidth = 140 +% Margin = 4 + UIPanel + ButtonPanel + ContextMenus + end + + properties %(Access = protected) + ParamEditor + Listener + NewConditionButton + DeleteConditionButton + MakeGlobalButton + SetValuesButton + SelectedCells %[row, column;...] of each selected cell + end + + methods + function obj = ConditionPanel(f, ParamEditor, varargin) + obj.ParamEditor = ParamEditor; + obj.UIPanel = uipanel('Parent', f, 'BorderType', 'none',... + 'BackgroundColor', 'white', 'Position', [0.5 0.05 0.5 0.95]); + % Create a child menu for the uiContextMenus + c = uicontextmenu; + obj.UIPanel.UIContextMenu = c; + obj.ContextMenus = uimenu(c, 'Label', 'Make Global', 'MenuSelectedFcn', @(~,~)obj.makeGlobal); + 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', @(~,~)disp('feature not yet implemented'), 'Tag', 'sort by'); + % Create condition table + obj.ConditionTable = uitable('Parent', obj.UIPanel,... + 'FontName', 'Consolas',... + 'RowName', [],... + 'RearrangeableColumns', true,... + '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 = uipanel('BackgroundColor', 'white',... + 'Position', [0.5 0 0.5 0.05], 'BorderType', 'none'); + % Create callback so that width of button panel is slave to width of + % conditional UIPanel + b = obj.ButtonPanel; + fcn = @(s)set(obj.ButtonPanel, 'Position', ... + [s.Position(1) b.Position(2) s.Position(3) b.Position(4)]); + obj.Listener = event.listener(obj.UIPanel, 'SizeChanged', @(s,~)fcn(s)); + % Define some common properties + props.BackgroundColor = 'white'; + props.Style = 'pushbutton'; + props.Units = 'normalized'; + props.Parent = obj.ButtonPanel; + % Create out four buttons + obj.NewConditionButton = uicontrol(props,... + 'String', 'New condition',... + 'Position',[0 0 1/4 1],... + 'TooltipString', 'Add a new condition',... + 'Callback', @(~, ~) obj.newCondition()); + obj.DeleteConditionButton = uicontrol(props,... + 'String', 'Delete condition',... + 'Position',[1/4 0 1/4 1],... + 'TooltipString', 'Delete the selected condition',... + 'Enable', 'off',... + 'Callback', @(~, ~) obj.deleteSelectedConditions()); + obj.MakeGlobalButton = uicontrol(props,... + 'String', 'Globalise parameter',... + 'Position',[2/4 0 1/4 1],... + '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',... + 'Position',[3/4 0 1/4 1],... + 'TooltipString', 'Set selected values to specified value, range or function',... + 'Enable', 'off',... + 'Callback', @(~, ~) obj.setSelectedValues()); + end + + function onEdit(obj, src, eventData) + disp('updating table cell'); + row = eventData.Indices(1); + col = eventData.Indices(2); + paramName = obj.ConditionTable.ColumnName{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) + set(obj.ConditionTable, 'ColumnName', [], ... + 'Data', [], 'ColumnEditable', false); + end + + function delete(obj) + disp('delete called'); + delete(obj.UIPanel); + end + + function onSelect(obj, ~, 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'); + set(obj.ContextMenus(1), 'Enable', 'on'); + set(obj.ContextMenus(3), 'Enable', 'on'); + else + % nothing selected, disable buttons + set(obj.MakeGlobalButton, 'Enable', 'off'); + set(obj.DeleteConditionButton, 'Enable', 'off'); + set(obj.SetValuesButton, 'Enable', 'off'); + set(obj.ContextMenus(1), 'Enable', 'off'); + set(obj.ContextMenus(3), 'Enable', 'off'); + end + end + + function makeGlobal(obj) + if isempty(obj.SelectedCells) + disp('nothing selected') + return + end + [cols, iu] = unique(obj.SelectedCells(:,2)); + names = obj.ConditionTable.ColumnName(cols); + rows = num2cell(obj.SelectedCells(iu,1)); %get rows of unique selected cols + PE = obj.ParamEditor; + cellfun(@PE.globaliseParamAtCell, names, rows); + 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 + % + % See also EXP.PARAMETERS, GLOBALISESELECTEDPARAMETERS + rows = unique(obj.SelectedCells(:,1)); + names = obj.ConditionTable.ColumnName; + numConditions = size(obj.ConditionTable.Data,2); + % 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); %FIXME: Should be in ParamEditor + end + % Refresh the table of conditions FIXME: Should be in ParamEditor + obj.ParamEditor.fillConditionTable(); + 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.ConditionTable.ColumnName(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) 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/MControl.m b/+eui/MControl.m index 5b25075d..5bad701e 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -247,10 +247,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 + % TODO end factory = obj.NewExpFactory; % Find which 'world' we are in @@ -305,12 +305,18 @@ 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. - 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); - if strcmp(obj.RemoteRigs.Selected.Status, 'idle') - set(obj.BeginExpButton, 'Enable', 'on') % Re-enable start button - end + if isempty(paramStruct); return; end + % Now parameters are loaded, pass to ParamEditor for display, etc. + if isempty(obj.ParamEditor) + panel = uipanel('Parent', obj.ParamPanel, 'Position', [0 0 1 1]); +% panel = uiextras.Panel('Parent', obj.ParamPanel); + obj.ParamEditor = eui.ParamEditor(obj.Parameters, panel); % Build parameter list in Global panel by calling eui.ParamEditor + 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 diff --git a/+eui/ParamEditor.m b/+eui/ParamEditor.m index 75aca882..74fa16e4 100644 --- a/+eui/ParamEditor.m +++ b/+eui/ParamEditor.m @@ -1,34 +1,20 @@ classdef ParamEditor < handle - %EUI.PARAMEDITOR UI control for configuring experiment parameters - % TODO. See also EXP.PARAMETERS. - % - % 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 + %UNTITLED2 Summary of this class goes here + % Detailed explanation goes here properties - GlobalVSpacing = 20 Parameters end - properties (Dependent) - Enable + properties %(Access = private) + GlobalUI + ConditionalUI + Parent + Listener end - properties (Access = private) - Root - GlobalGrid - ConditionTable - TableColumnParamNames = {} - NewConditionButton - DeleteConditionButton - MakeGlobalButton - SetValuesButton - SelectedCells %[row, column;...] of each selected cell - GlobalControls + properties (Dependent) + Enable end events @@ -36,120 +22,103 @@ end methods - function obj = ParamEditor(params, parent) - if nargin < 2 % Can call this function to display parameters is new window - parent = figure('Name', 'Parameters', 'NumberTitle', 'off',... - 'Toolbar', 'none', 'Menubar', 'none'); + function obj = ParamEditor(pars, f) + if nargin == 0; pars = []; end + if nargin < 2 + f = figure('Name', 'Parameters', 'NumberTitle', 'off',... + 'Toolbar', 'none', 'Menubar', 'none', 'DeleteFcn', @(~,~)obj.delete); end - obj.Parameters = params; - obj.build(parent); + obj.Parent = f; + obj.Listener = event.listener(f, 'SizeChanged', @(~,~)obj.onResize); + obj.GlobalUI = eui.FieldPanel(f, obj); + obj.ConditionalUI = eui.ConditionPanel(f, obj); + obj.buildUI(pars); end function delete(obj) - disp('ParamEditor destructor called'); - if obj.Root.isvalid - obj.Root.delete(); - end - end - - function value = get.Enable(obj) - value = obj.Root.Enable; + delete(obj.GlobalUI); + delete(obj.ConditionalUI); end - + function set.Enable(obj, value) - obj.Root.Enable = value; + cUI = obj.ConditionalUI; + fig = obj.Parent; + if value == true + arrayfun(@(prop) set(prop, 'Enable', 'on'), findobj(fig,'Enable','off')); + if isempty(cUI.SelectedCells) + set(cUI.MakeGlobalButton, 'Enable', 'off'); + set(cUI.DeleteConditionButton, 'Enable', 'off'); + set(cUI.SetValuesButton, 'Enable', 'off'); + end + obj.Enable = true; + else + arrayfun(@(prop) set(prop, 'Enable', 'off'), findobj(fig,'Enable','on')); + obj.Enable = false; + 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); + + function buildUI(obj, pars) + obj.Parameters = pars; + clear(obj.GlobalUI); + clear(obj.ConditionalUI); + c = obj.GlobalUI; + names = pars.GlobalNames; + for nm = names' + if strcmp(nm, 'randomiseConditions'); continue; end + if islogical(pars.Struct.(nm{:})) % If parameter is logical, make checkbox + ctrl = uicontrol('Parent', c.UIPanel, 'Style', 'checkbox', ... + 'Value', pars.Struct.(nm{:}), 'BackgroundColor', 'white'); + addField(c, nm{:}, ctrl); + else + [~, ctrl] = addField(c, nm{:}); + ctrl.String = obj.paramValue2Control(pars.Struct.(nm{:})); + end + end 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', 'Set selected values to specified value, range or function',... - 'Enable', 'off',... - 'Callback', @(~, ~) obj.setSelectedValues()); - - obj.Root.Sizes = [sum(obj.GlobalGrid.ColumnSizes) + 32, -1]; + obj.GlobalUI.onResize(); + %%% Special parameters + if ismember('randomiseConditions', obj.Parameters.Names) && ~pars.Struct.randomiseConditions + obj.ConditionalUI.ConditionTable.RowName = 'numbered'; + set(obj.ConditionalUI.ContextMenus(2), 'Checked', 'off'); + end 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 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 - % 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)); end -% function swapConditions(obj, idx1, idx2) % Function started, never -% finished - MW 2017-02-15 -% % params = obj.Parameters.trial -% end + function fillConditionTable(obj) + % Build the condition table + titles = obj.Parameters.TrialSpecificNames; + [~, trialParams] = obj.Parameters.assortForExperiment; + if isempty(titles) + obj.ConditionalUI.ButtonPanel.Visible = 'off'; + obj.ConditionalUI.UIPanel.Visible = 'off'; + obj.GlobalUI.UIPanel.Position(3) = 1; + else + obj.ConditionalUI.ButtonPanel.Visible = 'on'; + obj.ConditionalUI.UIPanel.Visible = 'on'; + data = reshape(struct2cell(trialParams), numel(titles), [])'; + data = mapToCell(@(e) obj.paramValue2Control(e), data); + set(obj.ConditionalUI.ConditionTable, 'ColumnName', titles, 'Data', data,... + 'ColumnEditable', true(1, numel(titles))); + end + end function addEmptyConditionToParam(obj, name) assert(obj.Parameters.isTrialSpecific(name),... @@ -183,217 +152,134 @@ 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) + % FIXME change name to updateGlobal + 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); + obj.Parameters.Struct.(name)(:,row) = newValue; 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 + % Refresh the table of conditions + obj.fillConditionTable; + % Add new global parameter to field panel + if islogical(value) % If parameter is logical, make checkbox + ctrl = uicontrol('Parent', obj.GlobalUI.UIPanel, 'Style', 'checkbox', ... + 'Value', value, 'BackgroundColor', 'white'); + addField(obj.GlobalUI, name, ctrl); + else + [~, ctrl] = addField(obj.GlobalUI, name); + ctrl.String = obj.paramValue2Control(value); 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) gUIExtent && cUIExtent > obj.ConditionalUI.MinWidth + % If global UI controls are cut off and there is no dead space in + % the table but the minimum table width hasn't been reached, reduce + % the conditional UI width: table has scroll bar and global panel + % does not + % FIXME calculate how much space required for min control width +% obj.GlobalUI.MinCtrlWidth + % Calculate conditional UI width in normalized units + requiredWidth = (cUI.Position(3) / cUIExtent) * (colExtent - gUIExtent); + minConditionalWidth = (cUI.Position(3) / cUIExtent) * obj.ConditionalUI.MinWidth; + if requiredWidth < minConditionalWidth + % If the required width is smaller that the minimum table width, + % use minimum table width + cUI.Position(3) = minConditionalWidth; + else % Otherwise use this width + cUI.Position(3) = requiredWidth; + end + cUI.Position(1) = 1-cUI.Position(3); + gUI.Position(3) = 1-cUI.Position(3); + elseif extent(3) < 1 && colWidth < obj.GlobalUI.MaxCtrlWidth + % If there is dead table space and the global UI columns are cut + % off or squashed, reduce the conditional panel + cUI.Position(3) = cUI.Position(3) - (panelWidth - (panelWidth * extent(3))); + cUI.Position(1) = cUI.Position(1) + (panelWidth - (panelWidth * extent(3))); + gUI.Position(3) = cUI.Position(1); + elseif extent(3) < 1 && colExtent < gUIExtent + % Plenty of space! Increase conditional UI a bit + deadspace = gUIExtent - colExtent; % Spece between panels in pixels + % Convert global UI pixels to relative units + gUI.Position(3) = (gUI.Position(3) / gUIExtent) * (gUIExtent - (deadspace/2)); + cUI.Position(1) = gUI.Position(3); + cUI.Position(3) = 1-gUI.Position(3); + elseif extent(3) >= 1 && colExtent < gUIExtent + % If the table space is cut off and there is dead space in the + % global UI panel, reduce the global UI panel + % If the extra space is minimum, return + if floor(gUIExtent - colExtent) <= 2; return; end + deadspace = gUIExtent - colExtent; % Spece between panels in pixels + gUI.Position(3) = (gUI.Position(3) / gUIExtent) * (gUIExtent - deadspace); + cUI.Position(3) = 1-gUI.Position(3); + cUI.Position(1) = gUI.Position(3); else - newParam = obj.controlValue2Param(currValue, eventData.NewData); - obj.Parameters.Struct.(paramName)(:,row) = newParam; - end - % if successful update the cell with default formatting - data = get(src, 'Data'); - reformed = obj.paramValue2Control(newParam); - 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 + % Compromise by having both panels take up half the figure +% [cUI.Position([1,3]), gUI.Position(3)] = deal(0.5); end - data{row,col} = reformed; - set(src, 'Data', data); - %notify listeners of change - notify(obj, 'Changed'); + notify(obj.ConditionalUI.ButtonPanel, 'SizeChanged'); end - - function updateGlobal(obj, param, src) - currParamValue = obj.Parameters.Struct.(param); - switch get(src, 'style') - case 'checkbox' - newValue = logical(get(src, 'value')); - obj.Parameters.Struct.(param) = newValue; - case 'edit' - newValue = obj.controlValue2Param(currParamValue, get(src, 'string')); - obj.Parameters.Struct.(param) = newValue; - % if successful update the control with default formatting and - % modified colour - set(src, 'String', obj.paramValue2Control(newValue),... - 'ForegroundColor', [1 0 0]); %red indicating it has changed - %notify listeners of change - notify(obj, 'Changed'); + end + + methods (Static) + function data = paramValue2Control(data) + % convert from parameter value to control value, i.e. a value class + % that can be easily displayed and edited by the user. Everything + % except logicals are converted to charecter arrays. + switch class(data) + case 'function_handle' + % convert a function handle to it's string name + data = func2str(data); + case 'logical' + data = data ~= 0; % If logical do nothing, basically. + case 'string' + data = char(data); % Strings not allowed in condition table data + otherwise + if isnumeric(data) + % format numeric types as string number list + strlist = mapToCell(@num2str, data); + data = strJoin(strlist, ', '); + elseif iscellstr(data) + data = strJoin(data, ', '); + end end - end - - function [data, paramNames, titles] = tableData(obj) - [~, trialParams] = obj.Parameters.assortForExperiment; - paramNames = fieldnames(trialParams); - titles = obj.Parameters.title(paramNames); - data = reshape(struct2cell(trialParams), numel(paramNames), [])'; - data = mapToCell(@(e) obj.paramValue2Control(e), data); + % all other data types stay as they are end - function data = controlValue2Param(obj, currParam, data, allowTypeChange) + function data = controlValue2Param(currParam, data, allowTypeChange) % Convert the values displayed in the UI ('control values') to % parameter values. String representations of numrical arrays and % functions are converted back to their 'native' classes. @@ -432,111 +318,7 @@ function updateGlobal(obj, param, src) end end end - - function data = paramValue2Control(obj, data) - % convert from parameter value to control value, i.e. a value class - % that can be easily displayed and edited by the user. Everything - % except logicals are converted to charecter arrays. - switch class(data) - case 'function_handle' - % convert a function handle to it's string name - data = func2str(data); - case 'logical' - data = data ~= 0; % If logical do nothing, basically. - case 'string' - data = char(data); % Strings not allowed in condition table data - otherwise - if isnumeric(data) - % format numeric types as string number list - strlist = mapToCell(@num2str, data); - data = strJoin(strlist, ', '); - elseif iscellstr(data) - data = strJoin(data, ', '); - end - end - % all other data types stay as they are - end - - function fillConditionTable(obj) - [data, params, titles] = obj.tableData; - set(obj.ConditionTable, 'ColumnName', titles, 'Data', data,... - 'ColumnEditable', true(1, numel(titles))); - obj.TableColumnParamNames = params; - end - - function makeTrialSpecific(obj, paramName, ctrls) - [uirow, ~] = find(obj.GlobalControls == ctrls{1}); - assert(numel(uirow) == 1, 'Unexpected number of matching global controls'); - cellfun(@(c) delete(c), ctrls); - obj.GlobalControls(uirow,:) = []; - obj.GlobalGrid.RowSizes(uirow) = []; - obj.Parameters.makeTrialSpecific(paramName); - obj.fillConditionTable(); - set(get(obj.GlobalGrid, 'Parent'),... - 'Heights', sum(obj.GlobalGrid.RowSizes)+45); % Reset height of globalPanel - end - - function [ctrl, label, buttons] = addParamUI(obj, name) % Adds ui element for each parameter - parent = obj.GlobalGrid; % Made by build function above - ctrl = []; - label = []; - buttons = []; - if iscell(name) % 2017-02-14 MW function now called with arrayFun (instead of cellFun) - name = name{1,1}; - end - value = obj.paramValue2Control(obj.Parameters.Struct.(name)); % convert from parameter value to control value (everything but logical values become strings) - title = obj.Parameters.title(name); - description = obj.Parameters.description(name); - - if islogical(value) % If parameter is logical, make checkbox - for i = 1:length(value) - ctrl(end+1) = uicontrol('Parent', parent,... - 'Style', 'checkbox',... - 'TooltipString', description,... - 'Value', value(i),... % Added 2017-02-15 MW set checkbox to what ever the parameter value is - 'Callback', @(src, e) obj.updateGlobal(name, src)); - end - elseif ischar(value) - ctrl = uicontrol('Parent', parent,... - 'BackgroundColor', [1 1 1],... - 'Style', 'edit',... - 'String', value,... - 'TooltipString', description,... - 'UserData', name,... % save the name of the parameter in userdata - 'HorizontalAlignment', 'left',... - 'Callback', @(src, e) obj.updateGlobal(name, src)); -% elseif iscellstr(value) -% lines = mkStr(value, [], sprintf('\n'), []); -% ctrl = uicontrol('Parent', parent,... -% 'BackgroundColor', [1 1 1],... -% 'Style', 'edit',... -% 'Max', 2,... %make it multiline -% 'String', lines,... -% 'TooltipString', description,... -% 'HorizontalAlignment', 'left',... -% 'UserData', name,... % save the name of the parameter in userdata -% 'Callback', @(src, e) obj.updateGlobal(name, src)); - end - if ~isempty(ctrl) % If control box is made, add label and conditional button - label = uicontrol('Parent', parent,... - 'Style', 'text', 'String', title, 'HorizontalAlignment', 'left',... - 'TooltipString', description); % Why not use bui.label? MW 2017-02-15 - bbox = uiextras.HBox('Parent', parent); % Make HBox for button - % UIContainer no longer present in GUILayoutToolbox, it used to - % call uipanel with the following args: - % 'Units', 'Normalized'; 'BorderType', 'none') -% buttons = bbox.UIContainer; - buttons = uicontrol('Parent', bbox, 'Style', 'pushbutton',... % Make 'conditional parameter' button - 'String', '[...]',... - 'TooltipString', sprintf(['Make this a condition parameter (i.e. vary by trial).\n'... - 'This will move it to the trial conditions table.']),... - 'FontSize', 7,... - 'Callback', @(~,~) obj.makeTrialSpecific(name, {ctrl, label, bbox})); - bbox.Sizes = 29; % Resize button height to 29px - end - end end - end diff --git a/+eui/ParamEditor_old.m b/+eui/ParamEditor_old.m new file mode 100644 index 00000000..05855aef --- /dev/null +++ b/+eui/ParamEditor_old.m @@ -0,0 +1,516 @@ +classdef ParamEditor < handle + %EUI.PARAMEDITOR UI control for configuring experiment parameters + % TODO. See also EXP.PARAMETERS. + % + % 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 + + properties + GlobalVSpacing = 20 + Parameters + end + + properties (Dependent) + Enable + end + + properties (Access = private) + Root + GlobalGrid + ConditionTable + TableColumnParamNames = {} + NewConditionButton + DeleteConditionButton + MakeGlobalButton + SetValuesButton + SelectedCells %[row, column;...] of each selected cell + GlobalControls + end + + events + Changed + end + + methods + function obj = ParamEditor(params, parent) + if nargin < 2 % Can call this function to display parameters is new window + parent = figure('Name', 'Parameters', 'NumberTitle', 'off',... + 'Toolbar', 'none', 'Menubar', 'none'); + end + obj.Parameters = params; + obj.build(parent); + end + + function delete(obj) + disp('ParamEditor destructor called'); + if obj.Root.isvalid + obj.Root.delete(); + end + end + + function value = get.Enable(obj) + value = obj.Root.Enable; + end + + function set.Enable(obj, value) + obj.Root.Enable = value; + 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', 'Set selected values to specified value, range or function',... + 'Enable', 'off',... + 'Callback', @(~, ~) obj.setSelectedValues()); + + obj.Root.Sizes = [sum(obj.GlobalGrid.ColumnSizes) + 32, -1]; + 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}); + end + + 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 + obj.GlobalGrid.Contents = child_handles; % Set children to new order + + obj.GlobalGrid.ColumnSizes = [180, 200, 40]; % Set column sizes + obj.GlobalGrid.Spacing = 1; + obj.GlobalGrid.RowSizes = repmat(obj.GlobalVSpacing, 1, size(obj.GlobalControls, 1)); + end + +% function swapConditions(obj, idx1, idx2) % Function started, never +% finished - MW 2017-02-15 +% % params = obj.Parameters.trial +% end + + function addEmptyConditionToParam(obj, name) + 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 + currValue = obj.Parameters.Struct.(name); + if isnumeric(currValue) + newValue = zeros(size(currValue, 1), 1, class(currValue)); + elseif islogical(currValue) + newValue = false(size(currValue, 1), 1); + elseif iscell(currValue) + if numel(currValue) > 0 + if iscellstr(currValue) + % if all elements are strings, default to a blank string + newValue = {''}; + elseif isa(currValue{1}, 'function_handle') + % first element is a function handle, so create with a @nop + % handle + newValue = {@nop}; + else + % misc cell case - default to empty element + newValue = {[]}; + end + else + % misc case - default to empty element + newValue = {[]}; + end + else + error('Adding empty condition for ''%s'' type not implemented', class(currValue)); + end + 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'); + else + %nothing selected, disable buttons + set(obj.MakeGlobalButton, 'Enable', 'off'); + set(obj.DeleteConditionButton, 'Enable', 'off'); + set(obj.SetValuesButton, 'Enable', 'off'); + end + 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 + % + % 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) + 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 + + 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}; + 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) 1) ||... % Number of rows > 1 for chars - (~ischar(obj.pStruct.(n)) && size(obj.pStruct.(n), 2) > 1); % Number of columns > 1 for all others + ~strcmp(n, 'randomiseConditions') &&... % randomiseConditions always global + ((ischar(obj.pStruct.(n)) && size(obj.pStruct.(n), 1) > 1) ||... % Number of rows > 1 for chars + (~ischar(obj.pStruct.(n)) && size(obj.pStruct.(n), 2) > 1)); % Number of columns > 1 for all others for i = 1:n name = obj.pNames{i}; obj.IsTrialSpecific.(name) = isTrialSpecificDefault(name); @@ -182,8 +182,8 @@ function makeGlobal(obj, name, newValue) 'UniformOutput', false); % concatenate trial parameter trialParamValues = cat(1, trialParamValues{:}); - if isempty(trialParamValues) - trialParamValues = {1}; + if isempty(trialParamValues) % Removed MW 30.01.19 + trialParamValues = {}; end trialParams = cell2struct(trialParamValues, trialParamNames, 1)'; globalParams = cell2struct(globalParamValues, globalParamNames, 1); diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index 200ba269..cd939a83 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -28,7 +28,7 @@ function update(scheduled) % than an hour ago. if (scheduled && (weekday(now) ~= scheduled) && now - lastFetch < 7) || ... (scheduled && (weekday(now) == scheduled) && now - lastFetch < 1) || ... - (~scheduled && now - lastFetch < 1/24) + (~scheduled && now - lastFetch < 1/24) return end disp('Updating code...') diff --git a/signals b/signals index 93520307..85072177 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 93520307c1cb1a9dc12fa8bda685a01b9ab80401 +Subproject commit 850721777f0f4cd27c6a5248cf83aa246b53c9a2 From 04a8a3d9b0971cc31d87e397beacc02b5c70efcb Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 5 Feb 2019 12:51:16 +0200 Subject: [PATCH 277/507] Updates for GUILT compatibility --- +eui/ConditionPanel.m | 28 +++++++++++++++------------- +eui/FieldPanel.m | 3 ++- +eui/MControl.m | 2 +- +eui/ParamEditor.m | 18 ++++++++++++------ signals | 2 +- 5 files changed, 31 insertions(+), 22 deletions(-) diff --git a/+eui/ConditionPanel.m b/+eui/ConditionPanel.m index 7c4b89fe..eacd317f 100644 --- a/+eui/ConditionPanel.m +++ b/+eui/ConditionPanel.m @@ -29,8 +29,7 @@ methods function obj = ConditionPanel(f, ParamEditor, varargin) obj.ParamEditor = ParamEditor; - obj.UIPanel = uipanel('Parent', f, 'BorderType', 'none',... - 'BackgroundColor', 'white', 'Position', [0.5 0.05 0.5 0.95]); + obj.UIPanel = uix.VBox('Parent', f, 'BackgroundColor', 'white'); % Create a child menu for the uiContextMenus c = uicontextmenu; obj.UIPanel.UIContextMenu = c; @@ -41,7 +40,8 @@ obj.ContextMenus(3) = uimenu(c, 'Label', 'Sort by selected column', ... 'MenuSelectedFcn', @(~,~)disp('feature not yet implemented'), 'Tag', 'sort by'); % Create condition table - obj.ConditionTable = uitable('Parent', obj.UIPanel,... + p = uix.Panel('Parent', obj.UIPanel); + obj.ConditionTable = uitable('Parent', p,... 'FontName', 'Consolas',... 'RowName', [],... 'RearrangeableColumns', true,... @@ -51,14 +51,14 @@ 'CellEditCallback', @obj.onEdit,... 'CellSelectionCallback', @obj.onSelect); % Create button panel to hold condition control buttons - obj.ButtonPanel = uipanel('BackgroundColor', 'white',... - 'Position', [0.5 0 0.5 0.05], 'BorderType', 'none'); + obj.ButtonPanel = uix.HBox('Parent', obj.UIPanel, ... + 'BackgroundColor', 'white'); % Create callback so that width of button panel is slave to width of % conditional UIPanel - b = obj.ButtonPanel; - fcn = @(s)set(obj.ButtonPanel, 'Position', ... - [s.Position(1) b.Position(2) s.Position(3) b.Position(4)]); - obj.Listener = event.listener(obj.UIPanel, 'SizeChanged', @(s,~)fcn(s)); +% b = obj.ButtonPanel; +% fcn = @(s)set(obj.ButtonPanel, 'Position', ... +% [s.Position(1) b.Position(2) s.Position(3) b.Position(4)]); +% obj.Listener = event.listener(obj.UIPanel, 'SizeChanged', @(s,~)fcn(s)); % Define some common properties props.BackgroundColor = 'white'; props.Style = 'pushbutton'; @@ -67,28 +67,30 @@ % Create out four buttons obj.NewConditionButton = uicontrol(props,... 'String', 'New condition',... - 'Position',[0 0 1/4 1],... + ...'Position',[0 0 1/4 1],... 'TooltipString', 'Add a new condition',... 'Callback', @(~, ~) obj.newCondition()); obj.DeleteConditionButton = uicontrol(props,... 'String', 'Delete condition',... - 'Position',[1/4 0 1/4 1],... + ...'Position',[1/4 0 1/4 1],... 'TooltipString', 'Delete the selected condition',... 'Enable', 'off',... 'Callback', @(~, ~) obj.deleteSelectedConditions()); obj.MakeGlobalButton = uicontrol(props,... 'String', 'Globalise parameter',... - 'Position',[2/4 0 1/4 1],... + ...'Position',[2/4 0 1/4 1],... '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',... - 'Position',[3/4 0 1/4 1],... + ...'Position',[3/4 0 1/4 1],... '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) diff --git a/+eui/FieldPanel.m b/+eui/FieldPanel.m index 33ae7ee1..10980cb6 100644 --- a/+eui/FieldPanel.m +++ b/+eui/FieldPanel.m @@ -28,7 +28,8 @@ methods function obj = FieldPanel(f, ParamEditor, varargin) obj.ParamEditor = ParamEditor; - obj.UIPanel = uipanel('Parent', f, 'BorderType', 'none',... + p = uix.Panel('Parent', f); + obj.UIPanel = uipanel('Parent', p, 'BorderType', 'none',... 'BackgroundColor', 'white', 'Position', [0 0 0.5 1]); obj.Listener = event.listener(obj.UIPanel, 'SizeChanged', @obj.onResize); end diff --git a/+eui/MControl.m b/+eui/MControl.m index 5bad701e..69c8628d 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -250,7 +250,7 @@ function loadParamProfile(obj, profile) set(obj.ParamProfileLabel, 'String', 'loading...', 'ForegroundColor', [1 0 0]); % Red 'Loading...' while new set loads if ~isempty(obj.ParamEditor) % Clear existing parameters control - % TODO + clear(obj.ParamEditor) end factory = obj.NewExpFactory; % Find which 'world' we are in diff --git a/+eui/ParamEditor.m b/+eui/ParamEditor.m index 74fa16e4..4c375b7e 100644 --- a/+eui/ParamEditor.m +++ b/+eui/ParamEditor.m @@ -7,6 +7,7 @@ end properties %(Access = private) + UIPanel GlobalUI ConditionalUI Parent @@ -27,11 +28,12 @@ if nargin < 2 f = figure('Name', 'Parameters', 'NumberTitle', 'off',... 'Toolbar', 'none', 'Menubar', 'none', 'DeleteFcn', @(~,~)obj.delete); + obj.Listener = event.listener(f, 'SizeChanged', @(~,~)obj.onResize); end obj.Parent = f; - obj.Listener = event.listener(f, 'SizeChanged', @(~,~)obj.onResize); - obj.GlobalUI = eui.FieldPanel(f, obj); - obj.ConditionalUI = eui.ConditionPanel(f, obj); + obj.UIPanel = uix.HBox('Parent', f); + obj.GlobalUI = eui.FieldPanel(obj.UIPanel, obj); + obj.ConditionalUI = eui.ConditionPanel(obj.UIPanel, obj); obj.buildUI(pars); end @@ -57,10 +59,14 @@ function delete(obj) end end - function buildUI(obj, pars) - obj.Parameters = pars; + function clear(obj) clear(obj.GlobalUI); clear(obj.ConditionalUI); + end + + function buildUI(obj, pars) + obj.Parameters = pars; + obj.clear() c = obj.GlobalUI; names = pars.GlobalNames; for nm = names' @@ -75,12 +81,12 @@ function buildUI(obj, pars) end end obj.fillConditionTable(); - obj.GlobalUI.onResize(); %%% 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 setRandomized(obj, value) diff --git a/signals b/signals index 85072177..23f93fb3 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 850721777f0f4cd27c6a5248cf83aa246b53c9a2 +Subproject commit 23f93fb365c441d803e7ff43b5d8f17801a409e9 From e47f4624469a9935d8c77e8372ad005447b6a597 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 8 Feb 2019 11:46:53 +0200 Subject: [PATCH 278/507] New infer params --- +exp/inferParameters.m | 33 ++++++--------------------------- signals | 2 +- 2 files changed, 7 insertions(+), 28 deletions(-) diff --git a/+exp/inferParameters.m b/+exp/inferParameters.m index 945845d1..50009901 100644 --- a/+exp/inferParameters.m +++ b/+exp/inferParameters.m @@ -18,34 +18,16 @@ expdef = which(func2str(expdef)); end -net = sig.Net; -e = struct; -e.t = net.origin('t'); -e.events = net.subscriptableOrigin('events'); -e.pars = net.subscriptableOrigin('pars'); -e.pars.CacheSubscripts = true; -e.visual = net.subscriptableOrigin('visual'); -e.audio.Devices = @dummyDev; -e.inputs = net.subscriptableOrigin('inputs'); -e.outputs = net.subscriptableOrigin('outputs'); +e = sig.void; +pars = sig.void(true); +audio.Devices = @dummyDev; try + expdeffun(e.t, e.events, pars, e.visual, e.inputs, e.outputs, audio); - expdeffun(e.t, e.events, e.pars, e.visual, e.inputs, e.outputs, e.audio); - - % paramNames will be the strings corresponding to the fields of e.pars + % paramNames will be the strings corresponding to the fields of pars % that the user tried to reference in her expdeffun. - paramNames = e.pars.Subscripts.keys'; - %The paramValues are signals corresponding to those parameters and they - %will all be empty, except when they've been given explicit numerical - %definitions right at the end of the function - and in that case, we'll - %take those values (extracted into matlab datatypes, from the signals, - %using .Node.CurrValue) to be the desired default values. - paramValues = e.pars.Subscripts.values'; - parsStruct = cell2struct(cell(size(paramNames)), paramNames); - for i = 1:size(paramNames,1) - parsStruct.(paramNames{i}) = paramValues{i}.Node.CurrValue; - end + parsStruct = pars.Subscripts; sz = iff(isempty(fieldnames(parsStruct)), 1,... % if there are no paramters sz = 1 structfun(@(a)size(a,2), parsStruct)); % otherwise get number of columns isChar = structfun(@ischar, parsStruct); % we disregard charecter arrays @@ -60,12 +42,9 @@ ExpPanel_fn = [path filesep ExpPanel_name ext]; if exist(ExpPanel_fn,'file'); parsStruct.expPanelFun = ExpPanel_name; end catch ex - net.delete(); rethrow(ex) end -net.delete(); - function dev = dummyDev(~) % Returns a dummy audio device structure, regardless of input % Returns a standard structure with values for generating tone diff --git a/signals b/signals index 93520307..c81b99e0 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 93520307c1cb1a9dc12fa8bda685a01b9ab80401 +Subproject commit c81b99e0922dbab9ab5d9a9770ff476c96fb6126 From 0f4bff25239bf0f9d96431f07f432b5e520d97e1 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 8 Feb 2019 12:44:53 +0200 Subject: [PATCH 279/507] Check for reserved params --- +exp/inferParameters.m | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/+exp/inferParameters.m b/+exp/inferParameters.m index 50009901..7e1a937e 100644 --- a/+exp/inferParameters.m +++ b/+exp/inferParameters.m @@ -5,12 +5,6 @@ % create some signals just to pass to the definition function and track % which parameter names are used -% if ischar(expdef) && file.exists(expdef) -% expdeffun = fileFunction(expdef); -% else -% expdeffun = expdef; -% expdef = which(func2str(expdef)); -% end if ischar(expdef) && file.exists(expdef) expdeffun = fileFunction(expdef); else @@ -28,6 +22,14 @@ % paramNames will be the strings corresponding to the fields of pars % that the user tried to reference in her expdeffun. parsStruct = pars.Subscripts; + + % Check for reserved fieldnames + reserved = {'randomiseConditions', 'services', 'expPanelFun', ... + 'numRepeats', 'defFunction', 'waterType', 'isPassive'}; + assert(~any(ismember(fieldnames(parsStruct), reserved)), ... + 'Lord have mercy, the following param names are reserved:\n%s', ... + strjoin(intersect(fieldnames(parsStruct), reserved), ', ')) + sz = iff(isempty(fieldnames(parsStruct)), 1,... % if there are no paramters sz = 1 structfun(@(a)size(a,2), parsStruct)); % otherwise get number of columns isChar = structfun(@ischar, parsStruct); % we disregard charecter arrays From c5a2e9821a77ad0a8750a196d04d46e3605e0a9e Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 8 Feb 2019 13:39:56 +0200 Subject: [PATCH 280/507] No more multiple default aud dev names --- +hw/devices.m | 4 ++-- signals | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/+hw/devices.m b/+hw/devices.m index 2734fe92..cdf5f493 100644 --- a/+hw/devices.m +++ b/+hw/devices.m @@ -69,8 +69,8 @@ % Get list of audio devices devs = getOr(rig, 'audioDevices', PsychPortAudio('GetDevices')); % Sanitize the names - names = matlab.lang.makeValidName([{'default'} {devs(2:end).DeviceName}],... - 'ReplacementStyle', 'delete'); + names = matlab.lang.makeValidName({devs.DeviceName}, 'ReplacementStyle', 'delete'); + names = iff(ismember('defaut', names), names, @()[{'default'} names(2:end)]); for i = 1:length(names); devs(i).DeviceName = names{i}; end rig.audioDevices = devs; end diff --git a/signals b/signals index 93520307..c81b99e0 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 93520307c1cb1a9dc12fa8bda685a01b9ab80401 +Subproject commit c81b99e0922dbab9ab5d9a9770ff476c96fb6126 From 571ee1844a46ed90b82835ab27930cbc7e5f15c7 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 8 Feb 2019 14:28:40 +0200 Subject: [PATCH 281/507] Bug fix for numRepeats when there's signal char param --- +exp/inferParameters.m | 7 +++---- signals | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/+exp/inferParameters.m b/+exp/inferParameters.m index 7e1a937e..3c44117c 100644 --- a/+exp/inferParameters.m +++ b/+exp/inferParameters.m @@ -30,10 +30,9 @@ 'Lord have mercy, the following param names are reserved:\n%s', ... strjoin(intersect(fieldnames(parsStruct), reserved), ', ')) + szFcn = @(a)iff(ischar(a), @()size(a,1), @()size(a,2)); sz = iff(isempty(fieldnames(parsStruct)), 1,... % if there are no paramters sz = 1 - structfun(@(a)size(a,2), parsStruct)); % otherwise get number of columns - isChar = structfun(@ischar, parsStruct); % we disregard charecter arrays - if any(isChar); sz = sz(~isChar); end + structfun(szFcn, parsStruct)); % otherwise get number of columns % add 'numRepeats' parameter, where total number of trials = 1000 parsStruct.numRepeats = ones(1,max(sz))*floor(1000/max(sz)); parsStruct.defFunction = expdef; @@ -56,4 +55,4 @@ 'DefaultSampleRate', 44100,... 'NrOutputChannels', 2); end -end \ No newline at end of file +end diff --git a/signals b/signals index c81b99e0..8a56f9e6 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit c81b99e0922dbab9ab5d9a9770ff476c96fb6126 +Subproject commit 8a56f9e6b3b5cf0d37c34e5b0b948c2e55bb45d8 From 6e19d6126595524949e0fe0c6e9d33e89a9ce884 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 8 Feb 2019 15:39:51 +0200 Subject: [PATCH 282/507] Subjects list disabled during login --- +eui/AlyxPanel.m | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index f8b6da14..7abcff6e 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -217,6 +217,8 @@ function login(obj) % 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? @@ -282,6 +284,8 @@ 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 From 8522c13d1c58d286acc1dd29c347444322b31621 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Sun, 10 Feb 2019 21:15:15 +0000 Subject: [PATCH 283/507] Field change to water-requirement endpoint --- +eui/AlyxPanel.m | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index f8b6da14..aade462f 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -601,13 +601,13 @@ function viewSubjectHistory(obj, ax) axWater = axes('Parent',plotBox); plot(axWater, dates, obj.round([records.given_water_total], 'up'), '.-'); hold(axWater, 'on'); - plot(axWater, dates, obj.round([records.given_water_hydrogel], 'down'), '.-'); - plot(axWater, dates, obj.round([records.given_water_liquid], 'down'), '.-'); + 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(axWater, 'water/hydrogel (mL)'); + ylabel(axWater, 'water (mL)'); % Create table of useful weight and water information, % sorted by date @@ -627,14 +627,14 @@ function viewSubjectHistory(obj, ax) arrayfun(@(x)iff(isnan(x), [], @()sprintf('%.1f', 0.8*(x-iw)+iw)), expected', 'uni', false), ... weightPctByDate'); waterDat = (... - num2cell(horzcat([records.given_water_liquid]', [records.given_water_hydrogel]', ... + 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]; From 210c8486bc26b96d5fe8ee1ebecca8262c0e789c Mon Sep 17 00:00:00 2001 From: k1o0 Date: Sun, 10 Feb 2019 22:39:48 +0000 Subject: [PATCH 284/507] Pagination support --- alyx-matlab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx-matlab b/alyx-matlab index 6fc933b9..13264858 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 6fc933b99bec09689a83b024284e8023d2c5793d +Subproject commit 132648581fe7ff7be9136baa00cdefe2cbb67c2f From 4ff9735bbf7b9147db107c99b95a6681ee8fab43 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Sun, 10 Feb 2019 21:15:15 +0000 Subject: [PATCH 285/507] Field change to water-requirement endpoint --- +eui/AlyxPanel.m | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index f8b6da14..aade462f 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -601,13 +601,13 @@ function viewSubjectHistory(obj, ax) axWater = axes('Parent',plotBox); plot(axWater, dates, obj.round([records.given_water_total], 'up'), '.-'); hold(axWater, 'on'); - plot(axWater, dates, obj.round([records.given_water_hydrogel], 'down'), '.-'); - plot(axWater, dates, obj.round([records.given_water_liquid], 'down'), '.-'); + 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(axWater, 'water/hydrogel (mL)'); + ylabel(axWater, 'water (mL)'); % Create table of useful weight and water information, % sorted by date @@ -627,14 +627,14 @@ function viewSubjectHistory(obj, ax) arrayfun(@(x)iff(isnan(x), [], @()sprintf('%.1f', 0.8*(x-iw)+iw)), expected', 'uni', false), ... weightPctByDate'); waterDat = (... - num2cell(horzcat([records.given_water_liquid]', [records.given_water_hydrogel]', ... + 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]; From 18452921d1047317eeb54c7c3b71df46245639f5 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 11 Feb 2019 13:12:59 +0000 Subject: [PATCH 286/507] Pagination support --- alyx-matlab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx-matlab b/alyx-matlab index 1f29e306..38d33275 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 1f29e306747a39cc76d99bb3ac51175309ecbda4 +Subproject commit 38d332754402aad2c608890bb50347d0ad969e0e From b5ea66e02e52d4025c7af63367be79be326d138a Mon Sep 17 00:00:00 2001 From: kevin-j-miller Date: Fri, 15 Feb 2019 15:57:28 +0000 Subject: [PATCH 287/507] Modify Alyx login to use newid instead of inputdlg. This makes the 'enter' key on the keyboard synonymous with the 'ok' button --- +eui/AlyxPanel.m | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 78f9c781..ef221f98 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -318,7 +318,7 @@ function giveFutureWater(obj) '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 = inputdlg(prompt,'Future Amounts', [1 50]); + amtStr = newid(prompt,'Future Amounts', [1 50]); if isempty(amtStr)||~obj.AlyxInstance.IsLoggedIn return % user pressed 'Close' or 'x' end @@ -450,10 +450,10 @@ 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}); From e9c2f5e3e7aa46c4f96a0ba501faa777624dd0df Mon Sep 17 00:00:00 2001 From: kevin-j-miller Date: Fri, 15 Feb 2019 15:57:40 +0000 Subject: [PATCH 288/507] Modify Alyx login to use newid instead of inputdlg. This makes the 'enter' key on the keyboard synonymous with the 'ok' button --- alyx-matlab | 2 +- cortexlab/+git/changes.m | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 cortexlab/+git/changes.m diff --git a/alyx-matlab b/alyx-matlab index 13264858..77a077ea 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 132648581fe7ff7be9136baa00cdefe2cbb67c2f +Subproject commit 77a077ead0c34ba3acdfb007e8da9ca40243547c diff --git a/cortexlab/+git/changes.m b/cortexlab/+git/changes.m deleted file mode 100644 index d9e9f013..00000000 --- a/cortexlab/+git/changes.m +++ /dev/null @@ -1,6 +0,0 @@ -disp('Updating queued Alyx posts...') -posts = dirPlus(getOr(dat.paths, 'localAlyxQueue', 'C:/localAlyxQueue')); -posts = posts(endsWith(posts, 'put')); -newPosts = cellfun(@(str)[str(1:end-3) 'patch'], posts, 'uni', 0); -status = cellfun(@movefile, posts, newPosts); -assert(all(status), 'Unable to rename queued Alyx files, please do this manually') \ No newline at end of file From 5f2fb584ee3c8fc9c70058ddf8fba4bbc5380cac Mon Sep 17 00:00:00 2001 From: kevin-j-miller Date: Fri, 15 Feb 2019 17:11:00 +0000 Subject: [PATCH 289/507] Expose the AlyxPanel property of the MControl object, so that the login method can be invoked from the command line. I want to do this so that I can put a batch file on the desktop of the computer that runs MC to help streamline the workflow. --- +eui/MControl.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+eui/MControl.m b/+eui/MControl.m index 5b25075d..9d5c201c 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -17,6 +17,7 @@ properties LoggingDisplay % control for showing log output + AlyxPanel % holds the AlyxPanel object (see buildUI(), eui.AlyxPanel()) end properties (SetAccess = private) @@ -33,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 From 9b56e4f80de5b49a3f686a2aa5066c00ebfd1155 Mon Sep 17 00:00:00 2001 From: kevin-j-miller Date: Mon, 18 Feb 2019 14:17:51 +0000 Subject: [PATCH 290/507] modified alyx-matlab with more convenient textboxes and desktop shortcuts for common use-cases --- alyx-matlab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx-matlab b/alyx-matlab index 77a077ea..c5c5c1ba 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit 77a077ead0c34ba3acdfb007e8da9ca40243547c +Subproject commit c5c5c1ba22dce86549ed56fd257c3852f5949391 From 64416dd363e42c0936d2d170c8672adba1945b78 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 19 Feb 2019 22:35:11 +0200 Subject: [PATCH 291/507] Bug fix for turning empty objects into struct --- cb-tools/obj2struct.m | 8 +++++--- tests/obj2json_test.m | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 tests/obj2json_test.m diff --git a/cb-tools/obj2struct.m b/cb-tools/obj2struct.m index 60e91eb6..67937832 100644 --- a/cb-tools/obj2struct.m +++ b/cb-tools/obj2struct.m @@ -23,12 +23,15 @@ end s = obj2struct(m); else % Normal object + s.ClassContructor = class(obj); % Supply class name for loading object names = fieldnames(obj); % Get list of public properties for i = 1:length(names) - if isobject(obj.(names{i})) % Property contains an object + if isempty(obj) % Object and therefore all properties are empty + s.(names{i}) = []; + elseif isobject(obj.(names{i})) % Property contains an object if startsWith(class(obj.(names{i})),'daq.ni.') % Do not attempt to save ni daq sessions of channels - s.(names{i}) = []; + s.(names{i}) = []; else % Recurse s.(names{i}) = obj2struct(obj.(names{i})); end @@ -55,7 +58,6 @@ s.(names{i}) = obj.(names{i}); end end - s.ClassContructor = class(obj); % Supply class name for loading object end elseif iscell(obj) % If dealing with cell array, recurse through elements diff --git a/tests/obj2json_test.m b/tests/obj2json_test.m new file mode 100644 index 00000000..e26dd2cf --- /dev/null +++ b/tests/obj2json_test.m @@ -0,0 +1,23 @@ +%% Test obj2struct with given data +data = struct; +data.A = struct(... % Scalar struct + 'field1', zeros(10), ... + 'field2', true(10), ... + 'field3', pi, ... + 'field4', single(10), ... + 'field5', '10'); +data.B = hw.DaqController(); % Obj containing empty obj +v = daq.getVendors(); +if v(strcmp({v.ID},'ni')).IsOperational + data.B.createDaqChannels(); % Add daq.ni obj +end +data.C = struct; % Non-scalar struct +data.C(1,1).a = 1; +data.C(2,1).a = 2; +data.C(1,2).a = 3; +data.C(2,2).a = 4; +data.D = @(a,b,c)zeros(c,b,a); % Function handle + +json = obj2json(data); +out = '{"A":{"field1":[[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0]],"field2":[[true,true,true,true,true,true,true,true,true,true],[true,true,true,true,true,true,true,true,true,true],[true,true,true,true,true,true,true,true,true,true],[true,true,true,true,true,true,true,true,true,true],[true,true,true,true,true,true,true,true,true,true],[true,true,true,true,true,true,true,true,true,true],[true,true,true,true,true,true,true,true,true,true],[true,true,true,true,true,true,true,true,true,true],[true,true,true,true,true,true,true,true,true,true],[true,true,true,true,true,true,true,true,true,true]],"field3":3.1415926535897931,"field4":10,"field5":"10"},"B":{"ClassContructor":"hw.DaqController","ChannelNames":[],"SignalGenerators":{"ClassContructor":"hw.PulseSwitcher","OpenValue":[],"ClosedValue":[],"ParamsFun":[],"DefaultCommand":[],"DefaultValue":[]},"DaqIds":"Dev1","DaqChannelIds":[],"SampleRate":1000,"DaqSession":[],"DigitalDaqSession":[],"Value":[],"NumChannels":0,"AnalogueChannelsIdx":[]},"C":[[{"a":1},{"a":3}],[{"a":2},{"a":4}]],"D":"@(a,b,c)zeros(c,b,a)"}'; +assert(strcmp(json,out), 'Test failed') \ No newline at end of file From 4fda5c090a63f98180a7c8752e81520333a58f18 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 20 Feb 2019 00:38:50 +0200 Subject: [PATCH 292/507] Added error msgID and tests --- +exp/inferParameters.m | 3 +- tests/expDefinitions/advancedChoiceWorld.m | 195 ++++++ .../advancedChoiceWorldExpPanel.m | 1 + .../advancedChoiceWorld_parameters.mat | Bin 0 -> 729 bytes tests/expDefinitions/choiceWorld.m | 636 ++++++++++++++++++ .../expDefinitions/choiceWorld_parameters.mat | Bin 0 -> 709 bytes tests/inferParamsPerformanceTest.m | 21 + tests/inferParamsTest.m | 65 ++ 8 files changed, 920 insertions(+), 1 deletion(-) create mode 100644 tests/expDefinitions/advancedChoiceWorld.m create mode 100644 tests/expDefinitions/advancedChoiceWorldExpPanel.m create mode 100644 tests/expDefinitions/advancedChoiceWorld_parameters.mat create mode 100644 tests/expDefinitions/choiceWorld.m create mode 100644 tests/expDefinitions/choiceWorld_parameters.mat create mode 100644 tests/inferParamsPerformanceTest.m create mode 100644 tests/inferParamsTest.m diff --git a/+exp/inferParameters.m b/+exp/inferParameters.m index 3c44117c..275c964f 100644 --- a/+exp/inferParameters.m +++ b/+exp/inferParameters.m @@ -27,7 +27,8 @@ reserved = {'randomiseConditions', 'services', 'expPanelFun', ... 'numRepeats', 'defFunction', 'waterType', 'isPassive'}; assert(~any(ismember(fieldnames(parsStruct), reserved)), ... - 'Lord have mercy, the following param names are reserved:\n%s', ... + 'exp:InferParameters:ReservedParameters', ... + 'The following param names are reserved:\n%s', ... strjoin(intersect(fieldnames(parsStruct), reserved), ', ')) szFcn = @(a)iff(ischar(a), @()size(a,1), @()size(a,2)); diff --git a/tests/expDefinitions/advancedChoiceWorld.m b/tests/expDefinitions/advancedChoiceWorld.m new file mode 100644 index 00000000..836ef546 --- /dev/null +++ b/tests/expDefinitions/advancedChoiceWorld.m @@ -0,0 +1,195 @@ +function advancedChoiceWorld(t, evts, p, vs, in, out, audio) +%% advancedChoiceWorld +% Burgess 2AUFC task with contrast discrimination and baited equal contrast +% trial conditions. +% 2017-03-25 Added contrast discrimination MW +% 2017-08 Added baited trials (thanks PZH) +% 2017-09-26 Added manual reward key presses +% 2017-10-26 p.wheelGain now in mm/deg units +% 2018-03-15 Added time sampler function for delays + +%% parameters +wheel = in.wheelMM; % The wheel input in mm turned tangential to the surface +rewardKey = p.rewardKey.at(evts.expStart); % get value of rewardKey at experiemnt start, otherwise it will take the same value each new trial +rewardKeyPressed = in.keyboard.strcmp(rewardKey); % true each time the reward key is pressed +contrastLeft = p.stimulusContrast(1); +contrastRight = p.stimulusContrast(2); + +%% when to present stimuli & allow visual stim to move +% stimulus should come on after the wheel has been held still for the +% duration of the preStimulusDelay. The quiescence threshold is a tenth of +% the rotary encoder resolution. +preStimulusDelay = p.preStimulusDelay.map(@timeSampler).at(evts.newTrial); % at(evts.newTrial) fix for rig pre-delay +stimulusOn = sig.quiescenceWatch(preStimulusDelay, t, wheel, 10); +interactiveDelay = p.interactiveDelay.map(@timeSampler); +interactiveOn = stimulusOn.delay(interactiveDelay); % the closed-loop period starts when the stimulus comes on, plus an 'interactive delay' + +audioDevice = audio.Devices('default'); +onsetToneSamples = p.onsetToneAmplitude*... + mapn(p.onsetToneFrequency, 0.1, audioDevice.DefaultSampleRate,... + 0.02, audioDevice.NrOutputChannels, @aud.pureTone); % aud.pureTone(freq, duration, samprate, "ramp duration", nAudChannels) +audio.default = onsetToneSamples.at(interactiveOn); % At the time of 'interative on', send samples to audio device and log as 'onsetTone' + +%% wheel position to stimulus displacement +% Here we define the multiplication factor for changing the wheel signal +% into mm/deg visual angle units. The Lego wheel used has a 31mm radius. +% The standard K�BLER rotary encoder uses X4 encoding; we record all edges +% (up and down) from both channels for maximum resolution. This means that +% e.g. a K�BLER 2400 with 100 pulses per revolution will actually generate +% *400* position ticks per full revolution. +wheelOrigin = wheel.at(interactiveOn); % wheel position sampled at 'interactiveOn' +stimulusDisplacement = p.wheelGain*(wheel - wheelOrigin); % yoke the stimulus displacment to the wheel movement during closed loop + +%% define response and response threshold +responseTimeOver = (t - t.at(interactiveOn)) > p.responseWindow; % p.responseWindow may be set to Inf +threshold = interactiveOn.setTrigger(... + abs(stimulusDisplacement) >= abs(p.stimulusAzimuth) | responseTimeOver); + +response = cond(... + responseTimeOver, 0,... % if the response time is over the response = 0 + true, -sign(stimulusDisplacement)); % otherwise it should be the inverse of the sign of the stimulusDisplacement + +response = response.at(threshold); % only update the response signal when the threshold has been crossed +stimulusOff = threshold.delay(1); % true a second after the threshold is crossed + +%% define correct response and feedback +% each trial randomly pick -1 or 1 value for use in baited (guess) trials +rndDraw = map(evts.newTrial, @(x) sign(rand(x)-0.5)); +correctResponse = cond(contrastLeft > contrastRight, -1,... % contrast left + contrastLeft < contrastRight, 1,... % contrast right + (contrastLeft + contrastRight == 0), 0,... % no-go (zero contrast) + (contrastLeft == contrastRight) & (rndDraw < 0), -1,... % equal contrast (baited) + (contrastLeft == contrastRight) & (rndDraw > 0), 1); % equal contrast (baited) +feedback = correctResponse == response; +% Only update the feedback signal at the time of the threshold being crossed +feedback = feedback.at(threshold).delay(0.1); + +noiseBurstSamples = p.noiseBurstAmp*... + mapn(audioDevice.NrOutputChannels, p.noiseBurstDur*audioDevice.DefaultSampleRate, @randn); +audio.default = noiseBurstSamples.at(feedback==0); % When the subject gives an incorrect response, send samples to audio device and log as 'noiseBurst' + +reward = merge(rewardKeyPressed, feedback > 0);% only update when feedback changes to greater than 0, or reward key is pressed +out.reward = p.rewardSize.at(reward); % output this signal to the reward controller + +%% stimulus azimuth +azimuth = cond(... + stimulusOn.to(interactiveOn), 0,... % Before the closed-loop condition, the stimulus is at it's starting azimuth + interactiveOn.to(threshold), stimulusDisplacement,... % Closed-loop condition, where the azimuth yoked to the wheel + threshold.to(stimulusOff), -response*abs(p.stimulusAzimuth)); % Once threshold is reached the stimulus is fixed again + +%% define the visual stimulus + +% Test stim left +leftStimulus = vis.grating(t, 'sinusoid', 'gaussian'); % create a Gabor grating +leftStimulus.orientation = p.stimulusOrientation(1); +leftStimulus.altitude = 0; +leftStimulus.sigma = [9,9]; % in visual degrees +leftStimulus.spatialFreq = p.spatialFrequency; % in cylces per degree +leftStimulus.phase = 2*pi*evts.newTrial.map(@(v)rand); % phase randomly changes each trial +leftStimulus.contrast = contrastLeft; +leftStimulus.azimuth = -p.stimulusAzimuth + azimuth; +% When show is true, the stimulus is visible +leftStimulus.show = stimulusOn.to(stimulusOff); + +vs.leftStimulus = leftStimulus; % store stimulus in visual stimuli set and log as 'leftStimulus' + +% Test stim right +rightStimulus = vis.grating(t, 'sinusoid', 'gaussian'); +rightStimulus.orientation = p.stimulusOrientation(2); +rightStimulus.altitude = 0; +rightStimulus.sigma = [9,9]; +rightStimulus.spatialFreq = p.spatialFrequency; +rightStimulus.phase = 2*pi*evts.newTrial.map(@(v)rand); +rightStimulus.contrast = contrastRight; +rightStimulus.azimuth = p.stimulusAzimuth + azimuth; +rightStimulus.show = stimulusOn.to(stimulusOff); + +vs.rightStimulus = rightStimulus; % store stimulus in visual stimuli set + +%% End trial and log events +% Let's use the next set of conditional paramters only if positive feedback +% was given, or if the parameter 'Repeat incorrect' was set to false. +nextCondition = feedback > 0 | p.repeatIncorrect == false; + +% we want to save these signals so we put them in events with appropriate +% names: +evts.stimulusOn = stimulusOn; +evts.preStimulusDelay = preStimulusDelay; +% save the contrasts as a difference between left and right +evts.contrast = p.stimulusContrast.map(@diff); +evts.contrastLeft = contrastLeft; +evts.contrastRight = contrastRight; +evts.azimuth = azimuth; +evts.response = response; +evts.feedback = feedback; +evts.interactiveOn = interactiveOn; +% Accumulate reward signals and append microlitre units +evts.totalReward = out.reward.scan(@plus, 0).map(fun.partial(@sprintf, '%.1f�l')); + +% Trial ends when evts.endTrial updates. +% If the value of evts.endTrial is false, the current set of conditional +% parameters are used for the next trial, if evts.endTrial updates to true, +% the next set of randowmly picked conditional parameters is used +evts.endTrial = nextCondition.at(stimulusOff).delay(p.interTrialDelay.map(@timeSampler)); + +%% Parameter defaults +% See timeSampler for full details on what values the *Delay paramters can +% take. Conditional perameters are defined as having ncols > 1, where each +% column is a condition. All conditional paramters must have the same +% number of columns. +try +%%% Contrast starting set +% C = [1 0;0 1;0.5 0;0 0.5]'; +%%% Contrast discrimination set +% c = [1 0.5 0.25 0.12 0.06 0]; +% c = combvec(c, c); +% C = unique([c, flipud(c)]', 'rows')'; +%%% Contrast detection set +c = [1 0.5 0.25 0.12 0.06 0]; +C = [c, zeros(1, numel(c)-1); zeros(1, numel(c)-1), c]; +%%% +p.stimulusContrast = C; + +p.repeatIncorrect = abs(diff(C,1)) > 0.25; % | all(C==0); +p.onsetToneFrequency = 5000; +p.interactiveDelay = 0.4; +p.onsetToneAmplitude = 0.15; +p.responseWindow = Inf; +p.stimulusAzimuth = 90; +p.noiseBurstAmp = 0.01; +p.noiseBurstDur = 0.5; +p.rewardSize = 3; +p.rewardKey = 'r'; +p.stimulusOrientation = [0, 0]'; +p.spatialFrequency = 0.19; % Prusky & Douglas, 2004 +p.interTrialDelay = 0.5; +p.wheelGain = 5; +p.preStimulusDelay = [0 0.1 0.09]'; +catch % ex +% disp(getReport(ex, 'extended', 'hyperlinks', 'on')) +end + +%% Helper functions +function duration = timeSampler(time) +% TIMESAMPLER Sample a time from some distribution +% If time is a single value, duration is that value. If time = [min max], +% then duration is sampled uniformally. If time = [min, max, time const], +% then duration is sampled from a exponential distribution, giving a flat +% hazard rate. If numel(time) > 3, duration is a randomly sampled value +% from time. +% +% See also exp.TimeSampler + if nargin == 0; duration = 0; return; end + switch length(time) + case 3 % A time sampled with a flat hazard function + duration = time(1) + exprnd(time(3)); + duration = iff(duration > time(2), time(2), duration); + case 2 % A time sampled from a uniform distribution + duration = time(1) + (time(2) - time(1))*rand; + case 1 % A fixed time + duration = time(1); + otherwise % Pick on of the values + duration = randsample(time, 1); + end +end +end \ No newline at end of file diff --git a/tests/expDefinitions/advancedChoiceWorldExpPanel.m b/tests/expDefinitions/advancedChoiceWorldExpPanel.m new file mode 100644 index 00000000..929b1a28 --- /dev/null +++ b/tests/expDefinitions/advancedChoiceWorldExpPanel.m @@ -0,0 +1 @@ +% --pass \ No newline at end of file diff --git a/tests/expDefinitions/advancedChoiceWorld_parameters.mat b/tests/expDefinitions/advancedChoiceWorld_parameters.mat new file mode 100644 index 0000000000000000000000000000000000000000..d84446fbc143b407ec1e2f38af5d21fca954679c GIT binary patch literal 729 zcmeZu4DoSvQZUssQ1EpO(M`+DN!3vZ$Vn_o%P-2cQV4Jk_w+L}(NSB|pf2Qo1*RLt2LxvAI(x(06ymPa!w(wF!>CwsOo`3(e@B6;*@BVD+5wP0Z6yWsyG~eTCW9scEAA%T5w?1FM|DfDpVOSiWlF2^nmZ}_slc}{U%5V1P zOc7+{7Zndu_uMdZ?b-^d^H;XHwi(3U4)$yC(&1d}(NcAM-_dgk&IdjjmP$T<;HKO0 z`tGw^rn=&RqF$!QJsrt;`qFc~mkaJ&<^SpL z*O%2nEvsK_U%I3|^~bTIZ@YZ%-SvJ_yS3`hPve^CUFSFU=-o*_TrDX2eWy`tw|Y_J zzP&FV{qOm)F7iiJs#Jdc`Iq1K8q_WD{+)zyT`rR=?j_VO@VU{Yb0q uvD)`#6W$-!k=rW6zj(KohuD$Q67CP?#TARsD{P+F^Z&$qdGps#)&T&{M@$$1 literal 0 HcmV?d00001 diff --git a/tests/expDefinitions/choiceWorld.m b/tests/expDefinitions/choiceWorld.m new file mode 100644 index 00000000..bcf265e3 --- /dev/null +++ b/tests/expDefinitions/choiceWorld.m @@ -0,0 +1,636 @@ +function choiceWorld(t, events, p, visStim, inputs, outputs, audio) +% ChoiceWorld(t, events, parameters, visStim, inputs, outputs, audio) +% +% A simple training protocol closely following that of our manual training. +% Contrasts are presented randomly (no staircase). The session is ended +% automatically if after a minimum number of trials either the median +% response time over the last 20 trials is over 5x (default) longer than +% that of the whole session, or if there is a greater than 50% (default) +% decrease in performance over the last 20 trials (compared to total +% performance over the whole session). +% +% The wheel gain changes only after the subject completes over 200 trials +% in a session. The gain only changes once and remains changed for all +% future sessions. +% +% There is no longer any change in reward volume, and there is no cue +% interactive delay. + +%% Fixed parameters +contrastSet = p.contrastSet.at(events.expStart); +startingContrasts = p.startingContrasts.at(events.expStart); +repeatOnMiss = p.repeatOnMiss.at(events.expStart); +trialsToBuffer = p.trialsToBuffer.at(events.expStart); +trialsToZeroContrast = p.trialsToZeroContrast.at(events.expStart); +rewardSize = p.rewardSize.at(events.expStart); +initialGain = p.initialGain.at(events.expStart); +normalGain = p.normalGain.at(events.expStart); +responseWindow = p.responseWindow.at(events.expStart); + +% Sounds +audioDevice = audio.Devices('default'); +onsetToneFreq = 5000; +onsetToneDuration = 0.1; +onsetToneRampDuration = 0.01; +toneSamples = p.onsetToneAmplitude*events.expStart.map(@(x) ... + aud.pureTone(onsetToneFreq, onsetToneDuration, audioDevice.DefaultSampleRate, ... + onsetToneRampDuration, audioDevice.NrOutputChannels)); +missNoiseDuration = 0.5; +missNoiseSamples = p.missNoiseAmplitude*events.expStart.map(@(x) ... + randn(audioDevice.NrOutputChannels, audioDevice.DefaultSampleRate*missNoiseDuration)); + +%% Initialize trial data +trialDataInit = events.expStart.mapn(... + contrastSet, startingContrasts, repeatOnMiss, ... + trialsToBuffer, trialsToZeroContrast, rewardSize,... + @initializeTrialData).subscriptable; + +%% Set up wheel +wheel = inputs.wheelMM; +quiescThreshold = 1000; +% millimetersFactor = events.newTrial.map2(31*2*pi/(p.encoderRes*4), @times); % convert the wheel gain to a value in mm/deg +gain = events.expStart.mapn(initialGain, normalGain, @initWheelGain); +enoughTrials = events.trialNum > 200; +wheelGain = iff(enoughTrials, normalGain, gain); + +%% Trial event times +% (this is set up to be independent of trial conditon, that way the trial +% condition can be chosen in a performance-dependent manner) + +% Resetting pre-stim quiescent period +prestimQuiescentPeriod = at(p.prestimQuiescentTime.map(@(A)rnd.exp(A(3),1,A(1:2))), events.newTrial); +preStimQuiescence = sig.quiescenceWatch(prestimQuiescentPeriod, t, wheel, quiescThreshold); +% Stimulus onset +stimOn = at(true, preStimQuiescence); % FIXME test whether at is needed here +% Play tone at interactive onset +audio.default = toneSamples.at(stimOn); +% The wheel displacement is zeroed at stimOn +stimDisplacement = wheelGain*(wheel - wheel.at(stimOn)); + +responseTimeOver = (t - t.at(stimOn)) > responseWindow; % p.responseWindow may be set to Inf +threshold = stimOn.setTrigger(... + abs(stimDisplacement) >= abs(p.responseDisplacement) | responseTimeOver); +response = cond(... + responseTimeOver, 3,... % if the response time is over the response = 0 + true, -sign(stimDisplacement)); % otherwise it should be the inverse of the sign of the stimulusDisplacement +response = response.at(stimOn.setTrigger(threshold)); % only update the response signal when the threshold has been crossed + +%% Bias +bias = merge(response.keepWhen(response~=3).bufferUpTo(10).map(@sum), ... + at(0, events.expStart)); % Initialize with 0 at expStart + +%% Update performance at response +responseData = vertcat(stimDisplacement, events.trialNum, response, bias); +trialData = responseData.at(response).scan(@updateTrialData, trialDataInit).subscriptable; +% trialData = response.scan(@updateTrialData, trialDataInit, 'pars', stimDisplacement, events.trialNum, bias).subscriptable; +% Set trial contrast (chosen when updating performance) +trialContrast = trialData.trialContrast.at(events.newTrial); +hit = trialData.hit.at(response); + +%% Task disengagement +% Response time = duration (seconds) between new trial and response +rt = t.at(stimOn).map2(t, @(a,b)diff([a,b])).at(response); +% The median response time over the last 20 trials +windowedRT = rt.buffer(20).map(@median); +% The median response time over all trials +baselineRT = rt.bufferUpTo(1000).map(@median); +% tooSlow is true when windowed rt is x times longer than median rt for the +% session, where x is the rtCriterion +tooSlow = windowedRT > baselineRT*p.rtCriterion; +% noResponse is true when mouse fails to respond for over x seconds, where +% x is maxRespWindow +% noResponse = t-t.at(events.newTrial) > p.maxRespWindow; + +% A rolloing buffer of performance (proportion of last 20 trials that were +% correct) - this includes repeat on incorrect trials +windowedPerf = hit.buffer(20).map(@(a)sum(a)/length(a)); +% Proportion of all trials that were correct +baselinePerf = hit.bufferUpTo(1000).map(@(a)sum(a)/length(a)); +% True when there is an x% decrease in performance over the last 20 trials +% compared to the session average, where x is pctPerfDecrease +poorPerformance = iff(trialData.proportionLeft == 0.5, ... + (baselinePerf - windowedPerf)/baselinePerf > p.pctPerfDecrease/100, false); +% poorPerformance = (baselinePerf - windowedPerf)/baselinePerf > p.pctPerfDecrease/100; + +% The subject is identified as disengaged from the task when, after +% minTrials have been completed, the subject is either too slow or exhibits +% a significant drop in performance. If the subject has not completed the +% minimum number of trials in 45 minutes it is also classed as disengaged. +disengaged = iff(events.trialNum > p.minTrials, tooSlow, ... + events.expStart.delay(60*45)); +% The session is finished when either the session has been running for x +% seconds, where x is trialDataInit.endAfter (20min on the first day, 40min +% on the seconds, Inf otherwise), or when the subject is disengaged +% finish = merge(at(true, disengaged),... +% at(true, events.expStart.delay(trialDataInit.endAfter))); +finish = cond(disengaged, true,... + events.expStart.delay(trialDataInit.endAfter), true); + +% When finish takes a value (it may only sample true), this is posted to +% events.expStop to trigger the end of the session +expStop = events.expStop; +expStop.Node.Listeners = [expStop.Node.Listeners, ... + into(finish, expStop)]; + +%% Give feedback and end trial +% Ensures reward size is not re-calculated at the response time +rewardSize = trialData.rewardSize.at(events.newTrial); +% NOTE: there is a 10ms delay for water output, because otherwise water and +% stim output compete and stim is delayed +outputs.reward = rewardSize.at(hit==true).delay(0.01); +% Play noise on miss +audio.default = missNoiseSamples.at(delay(hit==false, 0.01)); +% ITI defined by outcome +iti = iff(hit==1, p.itiHit, p.itiMiss); +% Stim stays on until the end of the ITI +stimOff = threshold.delay(iti); + +%% Visual stimulus +% Azimuth control +% 1) stim fixed in place until interactive on +% 2) wheel-conditional during interactive +% 3) fixed at response displacement azimuth after response +trialSide = trialData.trialSide.at(stimOn); +azimuth = cond( ... + stimOn.to(threshold), p.startingAzimuth*trialSide + stimDisplacement, ... + threshold.to(events.newTrial), ... + p.startingAzimuth*trialSide + ... + iff(response~=3, -response*abs(p.responseDisplacement), trialSide*abs(p.responseDisplacement))); + +% Stim flicker +% stimFlicker = sin((t - t.at(stimOn))*stimFlickerFrequency*2*pi) > 0; +stim = vis.grating(t, 'sine', 'gaussian'); +stim.sigma = p.sigma; +stim.spatialFreq = p.spatialFreq; +stim.phase = 2*pi*events.newTrial.map(@(v)rand); +stim.azimuth = azimuth; +%stim.contrast = trialContrast.at(stimOn)*stimFlicker; +stim.contrast = trialContrast; +stim.show = stimOn.to(stimOff); + +visStim.stim = stim; + +%% Display and save +% events.pPerf = (baselinePerf - windowedPerf)/baselinePerf > p.pctPerfDecrease/100; +% Wheel and stim +events.azimuth = azimuth; + +% Trial times +events.prestimQuiescentPeriod = prestimQuiescentPeriod; +events.stimulusOn = stimOn; +events.interactiveOn = stimOn; +events.stimulusOff = stimOff; +events.feedback = iff(hit==1, hit, -1); +events.threshold = threshold; +% End trial samples a false when the next trial is to be a repeat trial. +% NB: the identity function is used to ensure that stimOff takes a value +% before endTrial +events.endTrial = at(~trialData.repeatTrial, stimOff.identity); +% Used to identify what form of disengagement has occured +events.disengaged = skipRepeats(keepWhen(cond(... + tooSlow, 'long RT',... + true, 'false'), events.trialNum > p.minTrials)); +events.windowedRT = windowedRT.map(fun.partial(@sprintf, '%.1f sec')); +events.baselineRT = baselineRT.map(fun.partial(@sprintf, '%.1f sec')); +events.pctDecrease = map(((baselinePerf - windowedPerf)/baselinePerf)*100, fun.partial(@sprintf, '%.1f%%')); +events.endAfter = trialDataInit.endAfter/60; + +% Trial side probability +events.bias = bias; + +% Performance +events.contrastSet = trialData.contrastSet; +events.repeatOnMiss = trialData.repeatOnMiss; +events.contrastLeft = iff(trialData.trialSide == -1, trialData.trialContrast, trialData.trialContrast*0); +events.contrastRight = iff(trialData.trialSide == 1, trialData.trialContrast, trialData.trialContrast*0); +% events.trialSide = trialData.trialSide; +events.hit = hit; +events.response = at(iff(response==3, 0, response), threshold); +events.useContrasts = trialData.useContrasts; +events.trialsToZeroContrast = trialData.trialsToZeroContrast; +events.hitBuffer = trialData.hitBuffer; +events.wheelGain = wheelGain; +events.totalWater = outputs.reward.scan(@plus, 0).map(fun.partial(@sprintf, '%.1f�l')); + +%% Defaults +try +% The entire stimulus/target contrast set +p.contrastSet = [1,0.5,0.25,0.125,0.06,0]'; +% (which conrasts to use at the beginning of training) +p.startingContrasts = double([true,true,false,false,false,false]'); +% (which contrasts to repeat on incorrect) +p.repeatOnMiss = double([true,true,false,false,false,false]'); +% (number of trials to judge rolling performance) +p.trialsToBuffer = 50; +% (number of trials after introducing 12.5% contrast to introduce 0%) +p.trialsToZeroContrast = 200; +p.spatialFreq = 1/10; +p.sigma = [7, 7]'; +% stimFlickerFrequency = 5; % DISABLED BELOW +p.startingAzimuth = 35; % (degrees) +p.responseDisplacement = 35; % (degrees) +% Starting reward size (this value is ignored after the first session) +p.rewardSize = 3; % (microliters) +% Initial wheel gain +p.initialGain = 8; % ~= 20 @ 90 deg; +p.normalGain = 4; % ~= 10 @ 90 deg; + +% Timing +p.prestimQuiescentTime = [0.2, 0.5, 0.35]'; % (seconds) +% p.cueInteractiveDelay = 0.2; +% Inter-trial interval on correct response +p.itiHit = 1; % (seconds) +% Inter-trial interval on incorrect response +p.itiMiss = 2; % (seconds) +p.responseWindow = 60; % (seconds) + +% How many times slower the subject must become in order to be marked as +% disengaged +p.rtCriterion = 5; % (multiplier) +% The percent decrease in performance that subject must exhibit to be +% marked as disengaged +p.pctPerfDecrease = 50; % (percent) +% The minimum number of trials to be completed before the subject may be +% classified as disengaged +p.minTrials = 400; +% The maximum number of seconds the subject can take to give a response +% before being classified as disengaged +p.maxRespWindow = 60; % (seconds) + +% Audio +p.missNoiseAmplitude = 0.01; +p.onsetToneAmplitude = 0.15; +catch +end +end +function wheelGain = initWheelGain(expRef, initialGain, normalGain) +subject = dat.parseExpRef(expRef); +expRef = dat.listExps(subject); +wheelGain = initialGain; +if length(expRef) > 1 + % Loop through blocks from latest to oldest, if any have the relevant + % parameters then carry them over + for check_expt = length(expRef)-1:-1:1 + previousBlockFilename = dat.expFilePath(expRef{check_expt}, 'block', 'master'); + trialNum = []; + if exist(previousBlockFilename,'file') + previousBlock = load(previousBlockFilename); + if isfield(previousBlock.block,'events')&&isfield(previousBlock.block.events,'newTrialValues') + trialNum = previousBlock.block.events.newTrialValues; + end + end + % Check if the relevant fields exist + if length(trialNum) > 200 + % Break the loop and use these parameters + wheelGain = normalGain; + break + end + end +end +end + +function trialDataInit = initializeTrialData(expRef, ... + contrastSet,startingContrasts,repeatOnMiss,trialsToBuffer, ... + trialsToZeroContrast,rewardSize) + +%%%% Get the subject +% (from events.expStart - derive subject from expRef) +subject = dat.parseExpRef(expRef); + +startingContrasts = logical(startingContrasts)'; +repeatOnMiss = logical(repeatOnMiss)'; + +%%%% Initialize all of the session-independent performance values +trialDataInit = struct; + +% Store which trials are repeated on miss +trialDataInit.repeatOnMiss = repeatOnMiss; +% Set up the flag for repeating incorrect +trialDataInit.repeatTrial = false; +% Initialize hit/miss +trialDataInit.hit = nan; + +%%%% Load the last experiment for the subject if it exists +% (note: MC creates folder on initilization, so start search at 1-back) +expRef = dat.listExps(subject); +% Check how many days mouse has been trained +[~, dates] = dat.parseExpRef(expRef); +dayNum = find(floor(now) == unique(dates), 1, 'last'); +trialDataInit.endAfter = iff(dayNum<3, 60*20*dayNum, Inf); +trialDataInit.endAfter = Inf; + +useOldParams = false; +if length(expRef) > 1 + % Loop through blocks from latest to oldest, if any have the relevant + % parameters then carry them over + for check_expt = length(expRef)-1:-1:1 + learned = isLearned(expRef{check_expt}); + previousBlockFilename = dat.expFilePath(expRef{check_expt}, 'block', 'master'); + if exist(previousBlockFilename,'file') + previousBlock = load(previousBlockFilename); + if ~isfield(previousBlock.block, 'outputs')||... + ~isfield(previousBlock.block.outputs, 'rewardValues')||... + isempty(previousBlock.block.outputs.rewardValues) + lastRewardSize = rewardSize; + else + lastRewardSize = previousBlock.block.outputs.rewardValues(end); + end + + if isfield(previousBlock.block,'events') + previousBlock = previousBlock.block.events; + else + previousBlock = []; + end + end + % Check if the relevant fields exist + if exist('previousBlock','var') && all(isfield(previousBlock, ... + {'useContrastsValues','hitBufferValues','trialsToZeroContrastValues'})) &&... + length(previousBlock.newTrialValues) > 5 + % Break the loop and use these parameters + useOldParams = true; + break + end + end +end + +if useOldParams + % If the last experiment file has the relevant fields, set up performance + + % Which contrasts are currently in use + try + len = length(previousBlock.contrastSetValues)/length(previousBlock.contrastSetTimes); + trialDataInit.contrastSet = previousBlock.contrastSetValues(end-len+1:end); + catch + len = length(contrastSet'); + trialDataInit.contrastSet = contrastSet'; + end + trialDataInit.useContrasts = previousBlock.useContrastsValues(end-len+1:end); + + % The buffer to judge recent performance for adding contrasts + trialDataInit.hitBuffer = ... + previousBlock.hitBufferValues(:,end-len+1:end,:); + + % The countdown to adding 0% contrast + trialDataInit.trialsToZeroContrast = previousBlock.trialsToZeroContrastValues(end); + + % If zero contrasts have been introduced and lapse rate is < 0.2 for + % 50% contrasts, remove them. +% if trialDataInit.trialsToZeroContrast == 0 && ... +% sum(trialDataInit.hitBuffer(:,2,1))/size(trialDataInit.hitBuffer,1) > 0.8 && ... +% sum(trialDataInit.hitBuffer(:,2,2))/size(trialDataInit.hitBuffer,1) > 0.8 +% trialDataInit.useContrasts(trialDataInit.contrastSet == 0.5) = false; +% end + + % If the subject did over 200 trials last session, reduce the reward by + % 0.1, unless it is 2ml + if length(previousBlock.newTrialValues) > 200 && lastRewardSize > 1.5 + trialDataInit.rewardSize = lastRewardSize-0.1; + else + trialDataInit.rewardSize = lastRewardSize; + end + if learned + % Remove repeat on incorrect + trialDataInit.repeatOnMiss = zeros(1,length(trialDataInit.contrastSet)); + end + +else + % If this animal has no previous experiments, initialize performance + % Store the contrasts which are used + trialDataInit.contrastSet = contrastSet'; + trialDataInit.useContrasts = startingContrasts; + trialDataInit.hitBuffer = nan(trialsToBuffer, length(contrastSet), 2); % two tables, one for each side + trialDataInit.trialsToZeroContrast = trialsToZeroContrast; + % Initialize water reward size & wheel gain + trialDataInit.rewardSize = rewardSize; +end + +% Set the first contrast +contrasts = trialDataInit.contrastSet(trialDataInit.useContrasts); +w = ((contrasts~=0) + 1) / length(unique([contrasts, -contrasts])); +trialDataInit.trialContrast = randsample(contrasts, 1, true, w); +trialDataInit.trialSide = iff(rand <= 0.5, -1, 1); +end + +function trialData = updateTrialData(trialData,responseData) +% Update the performance and pick the next contrast +stimDisplacement = responseData(1); +response = responseData(3); +% bias normalized by trial number: abs(bias) = 0:1 +bias = responseData(4)/10; +% windowedRT = responseData(2); +% trialNum = responseData(3); + +% if trialNum > 50 && windowedRT < 60 +% trialData.wheelGain = 3; +% end +% +%%%% Get index of current trial contrast +currentContrastIdx = trialData.trialContrast == trialData.contrastSet; + +%%%% Define response type based on trial condition +trialData.hit = response~=3 && stimDisplacement*trialData.trialSide < 0; + +% Index for whether contrast was on the left or the right as performance is +% calculated for both sides. If the contrast was on the left, the index is +% 1, otherwise 2 +trialSideIdx = iff(trialData.trialSide<0, 1, 2); + + +%%%% Update buffers and counters if not a repeat trial +if ~trialData.repeatTrial + %%%% Contrast-adding performance buffer + % Update hit buffer for running performance + trialData.hitBuffer(:,currentContrastIdx,trialSideIdx) = ... + [trialData.hit;trialData.hitBuffer(1:end-1,currentContrastIdx,trialSideIdx)]; +end + +%%%% Add new contrasts as necessary given performance +% This is based on the last trialsToBuffer trials for rolling performance +% (these parameters are hard-coded because too specific) +% (these are side-dependent) +current_min_contrast = min(trialData.contrastSet(trialData.useContrasts & trialData.contrastSet ~= 0)); +trialsToBuffer = size(trialData.hitBuffer,1); +switch current_min_contrast + + case 0.5 + % Lower from 0.5 contrast after > 70% correct + min_hit_percentage = 0.70; + + contrast_buffer_idx = ismember(trialData.contrastSet,[0.5,1]); + contrast_total_trials = sum(~isnan(trialData.hitBuffer(:,contrast_buffer_idx,:))); + % If there have been enough buffer trials, check performance + if sum(contrast_total_trials) >= size(trialData.hitBuffer,1) + % Sample as evenly as possible across pooled contrasts. Here + % we pool the columns representing the 50% and 100% contrasts + % for each side (dim 3) individually, then shift the dimentions + % so that pooled_hits(1,:) = all 50% and 100% trials on the + % left, and pooled_hits(2,:) = all 50% and 100% trials on the + % right. + pooled_hits = shiftdim(... + reshape(trialData.hitBuffer(:,contrast_buffer_idx,:),[],1,2), 2); + use_hits(1) = sum(pooled_hits(1,(find(~isnan(pooled_hits(1,:)),trialsToBuffer/2)))); + use_hits(2) = sum(pooled_hits(2,(find(~isnan(pooled_hits(2,:)),trialsToBuffer/2)))); + min_hits = find(1 - binocdf(1:trialsToBuffer/2,trialsToBuffer/2,min_hit_percentage) < 0.05,1); + if all(use_hits >= min_hits) + trialData.useContrasts(find(~trialData.useContrasts,1)) = true; + end + end + + case 0.25 + % Lower from 0.25 contrast after > 50% correct + min_hit_percentage = 0.70; + + contrast_buffer_idx = ismember(trialData.contrastSet,current_min_contrast); + contrast_total_trials = sum(~isnan(trialData.hitBuffer(:,contrast_buffer_idx,:))); + % If there have been enough buffer trials, check performance + if sum(contrast_total_trials) >= size(trialData.hitBuffer,1) + % Sample as evenly as possible across pooled contrasts + pooled_hits = shiftdim(... + reshape(trialData.hitBuffer(:,contrast_buffer_idx,:),[],1,2), 2); + use_hits(1) = sum(pooled_hits(1,(find(~isnan(pooled_hits(1,:)),trialsToBuffer/2)))); + use_hits(2) = sum(pooled_hits(2,(find(~isnan(pooled_hits(2,:)),trialsToBuffer/2)))); + min_hits = find(1 - binocdf(1:trialsToBuffer/2,trialsToBuffer/2,min_hit_percentage) < 0.05,1); + if all(use_hits >= min_hits) + trialData.useContrasts(find(~trialData.useContrasts,1)) = true; + end + end + + case 0.125 + % Lower from 0.125 contrast after > 65% correct + min_hit_percentage = 0.65; + + contrast_buffer_idx = ismember(trialData.contrastSet,current_min_contrast); + contrast_total_trials = sum(~isnan(trialData.hitBuffer(:,contrast_buffer_idx,:))); + % If there have been enough buffer trials, check performance + if sum(contrast_total_trials) >= size(trialData.hitBuffer,1) + % Sample as evenly as possible across pooled contrasts + pooled_hits = shiftdim(... + reshape(trialData.hitBuffer(:,contrast_buffer_idx,:),[],1,2), 2); + use_hits(1) = sum(pooled_hits(1,(find(~isnan(pooled_hits(1,:)),trialsToBuffer/2)))); + use_hits(2) = sum(pooled_hits(2,(find(~isnan(pooled_hits(2,:)),trialsToBuffer/2)))); + min_hits = find(1 - binocdf(1:trialsToBuffer/2,trialsToBuffer/2,min_hit_percentage) < 0.05,1); + if all(use_hits >= min_hits) + trialData.useContrasts(find(~trialData.useContrasts,1)) = true; + end + end + +end + +% 200 trials after 12.5 % contrast introduced, put 6% +% 400 trials after 12.5 % contrast introduced, put 0% +% 600 trials after 12.5 % contrast introduced, remove 50% +if min(trialData.contrastSet(trialData.useContrasts)) <= 0.125 && ... + trialData.trialsToZeroContrast > 0 + % Subtract one from the countdown + trialData.trialsToZeroContrast = trialData.trialsToZeroContrast-1; + + if trialData.trialsToZeroContrast == 0 && ... + ~trialData.useContrasts(trialData.contrastSet == 0.06) + trialData.useContrasts(trialData.contrastSet == 0.06) = true; % Add 6% + trialData.trialsToZeroContrast = 200; % Reset counter + + elseif trialData.trialsToZeroContrast == 0 && ... + ~trialData.useContrasts(trialData.contrastSet == 0) + trialData.useContrasts(trialData.contrastSet == 0) = true; % Add 0% + trialData.trialsToZeroContrast = 200; % Reset counter + + elseif trialData.trialsToZeroContrast == 0 && ... + trialData.useContrasts(trialData.contrastSet == 0) + trialData.useContrasts(trialData.contrastSet == 0.5) = false; % Remove 50% + end +end + +%%%% Set flag to repeat - skip trial choice if so +if ~trialData.hit && any(trialData.repeatOnMiss==true) && ... + ismember(trialData.trialContrast,trialData.contrastSet(trialData.repeatOnMiss)) + % If the response is a no-go, repeat the same trial side + if response ~= 3 + % Otherwise take biased sample from normal distribution + sd = 0.5; % standard deviation + r = 0.5 + sd.*randn; % pull number from normal dist with mean 0.5 + trialData.trialSide = iff((r - bias) > 0.5, 1, -1); + % trialData.trialSide = iff(binornd(1,bias), + end + trialData.repeatTrial = true; + return +else + trialData.repeatTrial = false; +end + +%%%% Pick next contrast + +% Next contrast is random from current contrast set +contrasts = trialData.contrastSet(trialData.useContrasts); +w = ((contrasts~=0) + 1) / length(unique([contrasts, -contrasts])); +trialData.trialContrast = randsample(contrasts, 1, true, w); +%%%% Pick next side +trialData.trialSide = iff(rand <= 0.5, -1, 1); +end +function learned = isLearned(ref) +learned = false; +subject = dat.parseExpRef(ref); +expRef = dat.listExps(subject); +j = 1; +pooledCont = []; +pooledIncl = []; +pooledChoice = []; +for i = length(expRef):-1:1 + p = dat.expFilePath(expRef{i}, 'block', 'master'); + if exist(p,'file')==2 + % Block doesn't exist + p = fileparts(p); + else + fprintf('No block file for session %s: skipping\n', expRef{i}) + continue + end + try + feedback = readNPY(fullfile(p,'_ibl_trials.feedbackType.npy')); + contrastLeft = readNPY(fullfile(p,'_ibl_trials.contrastLeft.npy')); + contrastRight = readNPY(fullfile(p,'_ibl_trials.contrastRight.npy')); + incl = readNPY(fullfile(p,'_ibl_trials.included.npy')); + choice = readNPY(fullfile(p,'_ibl_trials.choice.npy')); + catch + warning('isLearned:ALFLoad:MissingFiles', ... + 'Unable to load files for session %s', expRef{i}) + continue + end + % If the zero contrast stimuli have not been introduced, the subject + % can't have learned. NB: Unfortunately if the hand of fate not once + % chose a zero contrast trial then the mouse would fail here, even if it + % was available to sample. This is fairly unlikely to happen and this + % method is much quicker than loading the block file to retreive the + % actual contrast set. + contrast = diff([contrastLeft,contrastRight],[],2); + if ~any(contrast==0) + fprintf('Low contrasts not yet introduced\n') + return + end + perfOnEasy = sum(feedback==1 & abs(contrast > 0.25)) / sum(abs(contrast > 0.25)); + if length(feedback) > 200 && perfOnEasy > 0.8 + pooledCont = [pooledCont; contrast]; + pooledIncl = [pooledIncl; incl]; + pooledChoice = [pooledChoice; choice]; + if j < 3 + j = j+1; + else + % All three sessions meet criteria + contrastSet = unique(pooledCont); + nn = arrayfun(@(c)sum(pooledCont==c & pooledIncl), contrastSet); + pp = arrayfun(@(c)sum(pooledCont==c & pooledIncl & pooledChoice==-1), contrastSet)./nn; + pars = psy.mle_fit_psycho([contrastSet';nn';pp'], 'erf_psycho_2gammas',... + [mean(contrastSet), 3, 0.05, 0.05],... + [min(contrastSet), 10, 0, 0],... + [max(contrastSet), 30, 0.4, 0.4]); + if abs(pars(1)) < 16 && pars(2) < 19 && pars(3) < 0.2 && pars(4) < 0.2 + learned = true; + else + fprintf('Fit parameter values below threshold\n') + return + end + end + else + fprintf('Low trial count or performance at high contrast\n') + return + end +end +end \ No newline at end of file diff --git a/tests/expDefinitions/choiceWorld_parameters.mat b/tests/expDefinitions/choiceWorld_parameters.mat new file mode 100644 index 0000000000000000000000000000000000000000..35d67dbd3e5e9d754c33dd1387b8e9ef492ba80f GIT binary patch literal 709 zcmeZu4DoSvQZUssQ1EpO(M`+DN!3vZ$Vn_o%P-2cQV4Jk_w+L}(NSB|pf+cGgQRLogBb$`CFp~&&t zpBLplLU=-wk`moTbvP7VRRbrt%KJ8JEtD?Me?Lk6=(oiE!Ojth@44z-o^Mjzc;MI( z9%&)&dZvA+pB9z(a&4`+WPLjR_v3W?{gby#B&$pJti5^7Y|XJ}*UtQkJGefgf5$T> zjxCXAvNycV`Ln8(S=F=HncYFh+&M=0RG_uAPb9BkL6wsHLc^avmrkgsEs#5w$gxYw zj(x3|cf_9=uOBj=OOR||=(4GJx6IR^DPG;H(h7w&Gam>%4UkPwQo7*ac|-Ifi>91f zHuvuR$GLnxdvyFYz9q8=H!NY~I&uBYf=)FXhD7!m%eJ}A)(>g@5a{6V*e=UqQZRY0 z!vdKl>bg0TZ+`wVA>!tmh*MFTCwCi*81@kRH>P0{v->Q~g}lFM8zqWLxTA*63^3qn4R-|8=cZ-IiIazPx_>v$x;Z{GGK` zK04j}{=ZxH*14jO@9cJ&bIa-TKl%N|jPK_@wk$Oe_ttj8Xs@}_TP-Xo(Wh1Ux66FKUh*X7)yBk= z!sm78J>PMDmhyM~pKInm&dRC1yI1b{*AE9@{uBRyF!lZ8^xAX2+%d_l*R>0-Z>syf z@Bf(_*KPm)k`+C2QakOB-X#mWProg!f`aG2d7WM}!%ddsviWtp>N Date: Wed, 20 Feb 2019 16:00:33 +0000 Subject: [PATCH 293/507] modify alyx_matlab --- alyx-matlab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx-matlab b/alyx-matlab index c5c5c1ba..a013557f 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit c5c5c1ba22dce86549ed56fd257c3852f5949391 +Subproject commit a013557feab80af3b90903a6b4cd06b8b5c85293 From fde8177a6a44164d8365b95b201c08955b5c4079 Mon Sep 17 00:00:00 2001 From: kevin-j-miller Date: Wed, 20 Feb 2019 16:01:37 +0000 Subject: [PATCH 294/507] move alyxpanel property to private setaccess --- +eui/MControl.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+eui/MControl.m b/+eui/MControl.m index 9d5c201c..0c432533 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -17,10 +17,10 @@ properties LoggingDisplay % control for showing log output - AlyxPanel % holds the AlyxPanel object (see buildUI(), eui.AlyxPanel()) 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 From 6f07fc8ec517b60ec593733f3e57daea43221795 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 21 Feb 2019 18:32:30 +0200 Subject: [PATCH 295/507] Bugfix for registering hw info --- +srv/expServer.m | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/+srv/expServer.m b/+srv/expServer.m index 7fb3ca2f..7af7d14c 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -274,7 +274,8 @@ function handleMessage(id, data, host) rig.stimWindow.flip(); % clear the screen after % save a copy of the hardware in JSON - fid = fopen(dat.expFilePath(expRef, 'hw-info', 'master', 'json'), 'w'); + hwInfo = dat.expFilePath(expRef, 'hw-info', 'master', 'json'); + fid = fopen(hwInfo, 'w'); fprintf(fid, '%s', obj2json(rig)); fclose(fid); if ~strcmp(dat.parseExpRef(expRef), 'default') From 6bceeeaf5d3be0052534a2bab21264631839b2c5 Mon Sep 17 00:00:00 2001 From: Jai Bhagat Date: Thu, 21 Feb 2019 18:55:16 +0000 Subject: [PATCH 296/507] bugfix for registering hw.info --- +srv/expServer.m | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/+srv/expServer.m b/+srv/expServer.m index 7fb3ca2f..7af7d14c 100644 --- a/+srv/expServer.m +++ b/+srv/expServer.m @@ -274,7 +274,8 @@ function handleMessage(id, data, host) rig.stimWindow.flip(); % clear the screen after % save a copy of the hardware in JSON - fid = fopen(dat.expFilePath(expRef, 'hw-info', 'master', 'json'), 'w'); + hwInfo = dat.expFilePath(expRef, 'hw-info', 'master', 'json'); + fid = fopen(hwInfo, 'w'); fprintf(fid, '%s', obj2json(rig)); fclose(fid); if ~strcmp(dat.parseExpRef(expRef), 'default') From 9b5cb2a1840fb08e78e4a13a2052d8d495b2276c Mon Sep 17 00:00:00 2001 From: jaib1 Date: Fri, 22 Feb 2019 16:42:02 +0000 Subject: [PATCH 297/507] added test for 'vis.sinusoidLayer' and updated 'alyx' (dev) and 'signals' (dev) submodules --- alyx-matlab | 2 +- signals | 2 +- tests/sinusoidLayer_test.m | 49 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 tests/sinusoidLayer_test.m diff --git a/alyx-matlab b/alyx-matlab index a013557f..b44b998d 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit a013557feab80af3b90903a6b4cd06b8b5c85293 +Subproject commit b44b998da6323289adeaf404eddd637251174435 diff --git a/signals b/signals index c81b99e0..4b45e84b 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit c81b99e0922dbab9ab5d9a9770ff476c96fb6126 +Subproject commit 4b45e84b15b052bbba897126b9b6a27e4c7cd0e6 diff --git a/tests/sinusoidLayer_test.m b/tests/sinusoidLayer_test.m new file mode 100644 index 00000000..41fe9001 --- /dev/null +++ b/tests/sinusoidLayer_test.m @@ -0,0 +1,49 @@ +%% Test 1: vis.grating default values +azimuth = 0; spatialFreq = 1/15; phase = 0; orientation = 0; +[layer, image] = vis.sinusoidLayer(azimuth, spatialFreq, phase, orientation); +TestAns = [layer.texOffset(1), layer.texAngle, layer.size(1)]; +ExpectedAns = [0 0 15]; +assert(isequal(TestAns,ExpectedAns), 'Test 1 failed.'); + +%% Test 2: Negative Azimuth +azimuth = -90; spatialFreq = 1/15; phase = 0; orientation = 0; +[layer, image] = vis.sinusoidLayer(azimuth, spatialFreq, phase, orientation); +TestAns = [layer.texOffset(1), layer.texAngle, layer.size(1)]; +ExpectedAns = [-90 0 15]; +assert(isequal(TestAns,ExpectedAns), 'Test 2 failed.'); + +%% Test 3: High Spatial Frequency +azimuth = 0; spatialFreq = 2; phase = 0; orientation = 0; +[layer, image] = vis.sinusoidLayer(azimuth, spatialFreq, phase, orientation); +TestAns = round([layer.texOffset(1), layer.texAngle, layer.size(1)],4); +ExpectedAns = [0 0 0.5000]; +assert(isequal(TestAns,ExpectedAns), 'Test 3 failed.'); + + +%% Test 4: Negative Phase +azimuth = 0; spatialFreq = 1/15; phase = -90; orientation = 0; +[layer, image] = vis.sinusoidLayer(azimuth, spatialFreq, phase, orientation); +TestAns = round([layer.texOffset(1), layer.texAngle, layer.size(1)],4); +ExpectedAns = [10.1408 0 15.0000]; +assert(isequal(TestAns,ExpectedAns), 'Test 4 failed.'); + +%% Test 5: Negative Orientation +azimuth = 0; spatialFreq = 1/15; phase = 0; orientation = -90; +[layer, image] = vis.sinusoidLayer(azimuth, spatialFreq, phase, orientation); +TestAns = [layer.texOffset(1), layer.texAngle, layer.size(1)]; +ExpectedAns = [0 -90 15]; +assert(isequal(TestAns,ExpectedAns), 'Test 5 failed.'); + +%% Test 6: Non-zero values for all input args +azimuth = 45; spatialFreq = 7/15; phase = 30; orientation = 60; +[layer, image] = vis.sinusoidLayer(azimuth, spatialFreq, phase, orientation); +TestAns = round([layer.texOffset(1), layer.texAngle, layer.size(1)],4); +ExpectedAns = [24.1600 60.0000 2.1429]; +assert(isequal(TestAns,ExpectedAns), 'Test 6 failed.'); + +%% Test 7: Impossible Spatial Frequency +azimuth = 45; spatialFreq = -1/15; phase = 30; orientation = 60; +[layer, image] = vis.sinusoidLayer(azimuth, spatialFreq, phase, orientation); +TestAns = round([layer.texOffset(1), layer.texAngle, layer.size(1)],4); +ExpectedAns = [10.8803 60.0000 -15.0000]; +assert(isequal(TestAns,ExpectedAns), 'Test 7 failed.'); \ No newline at end of file From 5f1a10e60c01ec7f22656f7582013d4b3b651ed7 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 25 Feb 2019 16:18:57 +0200 Subject: [PATCH 298/507] Update readme Added coverage badge --- readme.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/readme.md b/readme.md index 6f6e5470..c2fed245 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,8 @@ ---------- # Rigbox +[![Coverage Status](https://coveralls.io/repos/github/cortex-lab/Rigbox/badge.svg?branch=master)](https://coveralls.io/github/cortex-lab/Rigbox?branch=master) + Rigbox is a (mostly) object-oriented MATLAB software package for designing and controlling neurophysiological behavioural experiments (principally, the [steering wheel setup](https://www.ucl.ac.uk/cortexlab/tools/wheel) which [we](https://www.ucl.ac.uk/cortexlab) developed to probe mouse behaviour). Rigbox requires two machines, one for stimulus presentation ('the stimulus server') and another for controlling and monitoring the experiment ('mc'). ## Getting Started From aa123dbe2618e48bfac39815f7c8e38f3a48f8a1 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 26 Feb 2019 15:52:17 +0200 Subject: [PATCH 299/507] Started AlyxPanel tests --- +eui/AlyxPanel.m | 4 ++-- alyx-matlab | 2 +- tests/+dat/paths.m | 5 ++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index f8b6da14..1e367cf3 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -211,7 +211,7 @@ function delete(obj) 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 @@ -222,7 +222,7 @@ function login(obj) % Are we logging in or out? if ~obj.AlyxInstance.IsLoggedIn % logging in % attempt login - obj.AlyxInstance = obj.AlyxInstance.login(); % returns an instance if success, empty if you cancel + 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 diff --git a/alyx-matlab b/alyx-matlab index dd2ab36d..8b4f4f96 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit dd2ab36de59843edc94c15956967731d81173b74 +Subproject commit 8b4f4f96f7cfe4094cb4b2b8690aeb454f22c7b7 diff --git a/tests/+dat/paths.m b/tests/+dat/paths.m index 95e39049..7d22e839 100644 --- a/tests/+dat/paths.m +++ b/tests/+dat/paths.m @@ -18,9 +18,8 @@ p.rigbox = fileparts(which('addRigboxPaths')); % Repository for local copy of everything generated on this rig p.localRepository = 'C:\LocalExpData'; -p.localAlyxQueue = fullfile(p.rigbox, 'tests', 'data', 'alyx'); -p.databaseURL = 'https://alyx-dev.cortexlab.net'; -% p.databaseURL = 'https://dev.alyx.internationalbrainlab.org/'; +p.localAlyxQueue = fullfile(p.rigbox, 'alyx-matlab', 'tests', 'data'); +p.databaseURL = 'https://test.alyx.internationalbrainlab.org'; p.gitExe = 'C:\Program Files\Git\cmd\git.exe'; % Under the new system of having data grouped by mouse From cf9a2c2a4d940a443e46fa5ebdba62840d7f3923 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Wed, 27 Feb 2019 13:06:13 +0200 Subject: [PATCH 300/507] Change to validation params --- tests/expDefinitions/choiceWorld_parameters.mat | Bin 709 -> 701 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/expDefinitions/choiceWorld_parameters.mat b/tests/expDefinitions/choiceWorld_parameters.mat index 35d67dbd3e5e9d754c33dd1387b8e9ef492ba80f..0a70ff65f120214e81f5776ea788184a1a589793 100644 GIT binary patch delta 614 zcmV-s0-62A1-%83G#FQ9WFSUmVjwa%ATcvKFflqbHXt%EF*%V@BavVQk#rD$H39$t zc$~eJzi$&U6vv&*kG2dI14x}%*ils$gpfi>)MVjD+n`G52HeZL1`GW6=3V7g2KA*pNpL@^GFY+cK{*)5P6NVVh<=CcpZqWA(PPF8e664Kucv^!IKtf41{SJHPFoU3u|s|LnBu#@Rk6 zj@z%91?FwtlWTZ#fd|)rGStgP!QJ==Zo}%&E}ag(ES`6L@@*0SxYu)g=M{Iy#vPu+ zHCM#lGUN5D%swppCeH0s_fPw%_hC?KR`A)!RZs4gC)f1k+JC_Xzi|OsTl4Ra;+)=i z^+#FP|9zf2p4@v6F8kkqZ5G_z;aV%Wp~GFY>)y@qp)$8|{tWv)B1*HXNQDE`)ATqz zz{#%jI%+jS97R(mTsv`{QRnzmvBZV<#Jot=ZN#tj6Zn%=%uz zuUL<`<^l+`S5Gh44{-KuSOeJ7`Xx908lto@p`A6sWP z-~QS^J8c!?+`B}a+CuYx{vW?{0q>pTJ$A>D`|QZo9k~rhuHnc%V1K`lDz};GcT@Y+ za^+uNKkfdQy-E4r1$=!4f5ZCwcZq$;>o!d7TgxR6w;c1=bmUy>v0=#*&n=$kxX+p+ z_b>UZo#Se3ecint=k@Tx>HZi0!Da8`XMN613tVFb*DG+>vVQwTzW>bF*FF9E9TmjM zD`xyI(l|cy`#3sMQ=jbXXMC7O9SGAm*FJlGFjg4Aq0*D#ZBfnFs`zV__(?yA4JpCc IAFB?~ekw&PWB>pF From 6d78c2c820216e06674792ccb7e4377c5e2cd842 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Thu, 28 Feb 2019 16:49:52 +0200 Subject: [PATCH 301/507] Fix for xlim when single water restricted weight --- +eui/AlyxPanel.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 1e367cf3..53d573fc 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -574,7 +574,7 @@ function viewSubjectHistory(obj, ax) box(ax, 'off'); % Change the plot x axis limits maxDate = max(dates([records.is_water_restricted]|~isnan([records.weighing_at]))); - if numel(dates) > 1 && ~isempty(maxDate) + if numel(dates) > 1 && ~isempty(maxDate) && min(dates) ~= maxDate xlim(ax, [min(dates) maxDate]) else maxDate = now; From 4f0cf69179993cddadf5160d19763f083cb67423 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 5 Mar 2019 14:08:38 +0200 Subject: [PATCH 302/507] Bug fix for differing def fun paths --- tests/inferParamsTest.m | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/inferParamsTest.m b/tests/inferParamsTest.m index a6e0a1bc..1b34cb19 100644 --- a/tests/inferParamsTest.m +++ b/tests/inferParamsTest.m @@ -12,6 +12,12 @@ pars = exp.inferParameters([expDefPath filesep 'advancedChoiceWorld.m']); load(fullfile(expDefPath, 'advancedChoiceWorld_parameters.mat')); +assert(strcmp(pars.defFunction, [expDefPath filesep 'advancedChoiceWorld.m']), ... + 'Incorrect expDef path') + +% Remove defFunction field before comparison +pars = rmfield(pars, 'defFunction'); +parameters = rmfield(parameters, 'defFunction'); assert(isequal(pars, parameters), 'Unexpected parameter struct returned') %% Test 2: choiceWorld @@ -19,6 +25,9 @@ pars = exp.inferParameters([expDefPath filesep 'choiceWorld.m']); load(fullfile(expDefPath, 'choiceWorld_parameters.mat')); +% Remove defFunction field before comparison +pars = rmfield(pars, 'defFunction'); +parameters = rmfield(parameters, 'defFunction'); assert(isequal(pars, parameters), 'Unexpected parameter struct returned') %% Test 3: single global parameter From 35a387060f523134ffddcdf8ff9b5d628df7f76d Mon Sep 17 00:00:00 2001 From: jaib1 Date: Fri, 15 Feb 2019 12:31:15 +0000 Subject: [PATCH 303/507] Rebased 'TestPanelClasses' onto 'dev' at diverge point c9c27a2 after pull request changes Test Panel as Classes modeled off SignalsExp & MControl variable name changes to exp.SignalsExp added 'cprintf.m' updated signals submodule updated signals submodule updates submodule signals updates signals submodule (TestPanelClasses branch) updated submodule signals (TestPanelClasses branch) MControl and mc commits to match dev branch alyx-related commits to match dev branch updated submodule signals (TestPanelClasses branch) updated submodule signals (TestPanelClasses branch) updated signals submodule (to 'TestPanelClasses' branch) and alyx-matlab(to 'dev' branch) and improved 'namedArg' updated signals submodule ('TestPanelClasses') after rebasing that branch in that submodule typo fix in 'hw.devices' made changes for Miles updated signals (TestPanelClasses) submodule Rebased 'TestPanelClasses' onto 'dev' at diverge point after pull request changes Rebased 'TestPanelClasses' onto 'dev' at diverge point Test Panel as Classes modeled off SignalsExp & MControl variable name changes to exp.SignalsExp added 'cprintf.m' updated signals submodule updated signals submodule updates submodule signals updates signals submodule (TestPanelClasses branch) updated submodule signals (TestPanelClasses branch) MControl and mc commits to match dev branch alyx-related commits to match dev branch updated submodule signals (TestPanelClasses branch) updated submodule signals (TestPanelClasses branch) updated signals submodule (to 'TestPanelClasses' branch) and alyx-matlab(to 'dev' branch) and improved 'namedArg' updated signals submodule ('TestPanelClasses') after rebasing that branch in that submodule typo fix in 'hw.devices' made changes for Miles updated signals (TestPanelClasses) submodule updated signals (TestPannelClasses) submodule after incorporating pull request changes undid variable name changes for Miles --- +eui/AlyxPanel.m | 20 ++++++++------ +eui/MControl.m | 2 +- +exp/SignalsExp.m | 2 ++ +exp/inferParameters.m | 55 ++++++++++++------------------------- +hw/devices.m | 6 ++-- alyx-matlab | 2 +- cb-tools/burgbox/namedArg.m | 2 +- cortexlab/+git/changes.m | 6 ---- signals | 2 +- 9 files changed, 39 insertions(+), 58 deletions(-) delete mode 100644 cortexlab/+git/changes.m diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index f8b6da14..ef221f98 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -217,6 +217,8 @@ function login(obj) % 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? @@ -282,6 +284,8 @@ 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 @@ -314,7 +318,7 @@ function giveFutureWater(obj) '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 = inputdlg(prompt,'Future Amounts', [1 50]); + amtStr = newid(prompt,'Future Amounts', [1 50]); if isempty(amtStr)||~obj.AlyxInstance.IsLoggedIn return % user pressed 'Close' or 'x' end @@ -446,10 +450,10 @@ 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}); @@ -601,13 +605,13 @@ function viewSubjectHistory(obj, ax) axWater = axes('Parent',plotBox); plot(axWater, dates, obj.round([records.given_water_total], 'up'), '.-'); hold(axWater, 'on'); - plot(axWater, dates, obj.round([records.given_water_hydrogel], 'down'), '.-'); - plot(axWater, dates, obj.round([records.given_water_liquid], 'down'), '.-'); + 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(axWater, 'water/hydrogel (mL)'); + ylabel(axWater, 'water (mL)'); % Create table of useful weight and water information, % sorted by date @@ -627,14 +631,14 @@ function viewSubjectHistory(obj, ax) arrayfun(@(x)iff(isnan(x), [], @()sprintf('%.1f', 0.8*(x-iw)+iw)), expected', 'uni', false), ... weightPctByDate'); waterDat = (... - num2cell(horzcat([records.given_water_liquid]', [records.given_water_hydrogel]', ... + 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]; diff --git a/+eui/MControl.m b/+eui/MControl.m index 5b25075d..0c432533 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -20,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 @@ -33,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 diff --git a/+exp/SignalsExp.m b/+exp/SignalsExp.m index f616ea70..d2fb2631 100644 --- a/+exp/SignalsExp.m +++ b/+exp/SignalsExp.m @@ -720,6 +720,8 @@ function mainLoop(obj) obj.Data.stimWindowRenderTimes(obj.StimWindowUpdateCount) = renderTime; obj.StimWindowInvalid = false; end + % make sure some minimum time passes before updating signals, to + % improve performance on MC if (obj.Clock.now - t) > 0.1 || obj.IsLooping == false sendSignalUpdates(obj); t = obj.Clock.now; diff --git a/+exp/inferParameters.m b/+exp/inferParameters.m index 945845d1..275c964f 100644 --- a/+exp/inferParameters.m +++ b/+exp/inferParameters.m @@ -5,12 +5,6 @@ % create some signals just to pass to the definition function and track % which parameter names are used -% if ischar(expdef) && file.exists(expdef) -% expdeffun = fileFunction(expdef); -% else -% expdeffun = expdef; -% expdef = which(func2str(expdef)); -% end if ischar(expdef) && file.exists(expdef) expdeffun = fileFunction(expdef); else @@ -18,38 +12,28 @@ expdef = which(func2str(expdef)); end -net = sig.Net; -e = struct; -e.t = net.origin('t'); -e.events = net.subscriptableOrigin('events'); -e.pars = net.subscriptableOrigin('pars'); -e.pars.CacheSubscripts = true; -e.visual = net.subscriptableOrigin('visual'); -e.audio.Devices = @dummyDev; -e.inputs = net.subscriptableOrigin('inputs'); -e.outputs = net.subscriptableOrigin('outputs'); +e = sig.void; +pars = sig.void(true); +audio.Devices = @dummyDev; try + expdeffun(e.t, e.events, pars, e.visual, e.inputs, e.outputs, audio); - expdeffun(e.t, e.events, e.pars, e.visual, e.inputs, e.outputs, e.audio); - - % paramNames will be the strings corresponding to the fields of e.pars + % paramNames will be the strings corresponding to the fields of pars % that the user tried to reference in her expdeffun. - paramNames = e.pars.Subscripts.keys'; - %The paramValues are signals corresponding to those parameters and they - %will all be empty, except when they've been given explicit numerical - %definitions right at the end of the function - and in that case, we'll - %take those values (extracted into matlab datatypes, from the signals, - %using .Node.CurrValue) to be the desired default values. - paramValues = e.pars.Subscripts.values'; - parsStruct = cell2struct(cell(size(paramNames)), paramNames); - for i = 1:size(paramNames,1) - parsStruct.(paramNames{i}) = paramValues{i}.Node.CurrValue; - end + parsStruct = pars.Subscripts; + + % Check for reserved fieldnames + reserved = {'randomiseConditions', 'services', 'expPanelFun', ... + 'numRepeats', 'defFunction', 'waterType', 'isPassive'}; + assert(~any(ismember(fieldnames(parsStruct), reserved)), ... + 'exp:InferParameters:ReservedParameters', ... + 'The following param names are reserved:\n%s', ... + strjoin(intersect(fieldnames(parsStruct), reserved), ', ')) + + szFcn = @(a)iff(ischar(a), @()size(a,1), @()size(a,2)); sz = iff(isempty(fieldnames(parsStruct)), 1,... % if there are no paramters sz = 1 - structfun(@(a)size(a,2), parsStruct)); % otherwise get number of columns - isChar = structfun(@ischar, parsStruct); % we disregard charecter arrays - if any(isChar); sz = sz(~isChar); end + structfun(szFcn, parsStruct)); % otherwise get number of columns % add 'numRepeats' parameter, where total number of trials = 1000 parsStruct.numRepeats = ones(1,max(sz))*floor(1000/max(sz)); parsStruct.defFunction = expdef; @@ -60,12 +44,9 @@ ExpPanel_fn = [path filesep ExpPanel_name ext]; if exist(ExpPanel_fn,'file'); parsStruct.expPanelFun = ExpPanel_name; end catch ex - net.delete(); rethrow(ex) end -net.delete(); - function dev = dummyDev(~) % Returns a dummy audio device structure, regardless of input % Returns a standard structure with values for generating tone @@ -75,4 +56,4 @@ 'DefaultSampleRate', 44100,... 'NrOutputChannels', 2); end -end \ No newline at end of file +end diff --git a/+hw/devices.m b/+hw/devices.m index 2734fe92..1bba5908 100644 --- a/+hw/devices.m +++ b/+hw/devices.m @@ -69,8 +69,8 @@ % Get list of audio devices devs = getOr(rig, 'audioDevices', PsychPortAudio('GetDevices')); % Sanitize the names - names = matlab.lang.makeValidName([{'default'} {devs(2:end).DeviceName}],... - 'ReplacementStyle', 'delete'); + names = matlab.lang.makeValidName({devs.DeviceName}, 'ReplacementStyle', 'delete'); + names = iff(ismember('default', names), names, @()[{'default'} names(2:end)]); for i = 1:length(names); devs(i).DeviceName = names{i}; end rig.audioDevices = devs; end @@ -93,4 +93,4 @@ function configure(deviceName, usedaq) end end -end \ No newline at end of file +end diff --git a/alyx-matlab b/alyx-matlab index dd2ab36d..55e3e2ad 160000 --- a/alyx-matlab +++ b/alyx-matlab @@ -1 +1 @@ -Subproject commit dd2ab36de59843edc94c15956967731d81173b74 +Subproject commit 55e3e2adc57c42a1c84dbc0b8570c428f246f65d diff --git a/cb-tools/burgbox/namedArg.m b/cb-tools/burgbox/namedArg.m index 5e5820fb..72264f8a 100644 --- a/cb-tools/burgbox/namedArg.m +++ b/cb-tools/burgbox/namedArg.m @@ -8,7 +8,7 @@ % 2014-02 CB created -defIdx = find(cellfun(@(a) isequal(a, name), args), 1); +defIdx = find(cellfun(@(a) strcmpi(a, name), args), 1); if ~isempty(defIdx) present = true; value = args{defIdx + 1}; diff --git a/cortexlab/+git/changes.m b/cortexlab/+git/changes.m deleted file mode 100644 index d9e9f013..00000000 --- a/cortexlab/+git/changes.m +++ /dev/null @@ -1,6 +0,0 @@ -disp('Updating queued Alyx posts...') -posts = dirPlus(getOr(dat.paths, 'localAlyxQueue', 'C:/localAlyxQueue')); -posts = posts(endsWith(posts, 'put')); -newPosts = cellfun(@(str)[str(1:end-3) 'patch'], posts, 'uni', 0); -status = cellfun(@movefile, posts, newPosts); -assert(all(status), 'Unable to rename queued Alyx files, please do this manually') \ No newline at end of file diff --git a/signals b/signals index 93520307..ea8f85fe 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 93520307c1cb1a9dc12fa8bda685a01b9ab80401 +Subproject commit ea8f85fe28313ca9562f9a52ff58114b3f7ef6de From 86fcbbbe86861182737cd09501611382a2bd4e75 Mon Sep 17 00:00:00 2001 From: jaib1 Date: Thu, 7 Mar 2019 11:05:17 +0000 Subject: [PATCH 304/507] updated signals (dev) submodule --- signals | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/signals b/signals index 8a56f9e6..01829db3 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 8a56f9e6b3b5cf0d37c34e5b0b948c2e55bb45d8 +Subproject commit 01829db30c43561a0ec7481b14cfa895e31c59bb From b6b47dba83c449c464fc8f1bc1f79efc0957b3ca Mon Sep 17 00:00:00 2001 From: jaib1 Date: Thu, 7 Mar 2019 12:35:30 +0000 Subject: [PATCH 305/507] updated signals (TestPanelClasses) submodule --- signals | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/signals b/signals index ea8f85fe..38007424 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit ea8f85fe28313ca9562f9a52ff58114b3f7ef6de +Subproject commit 380074249abfd91b9f0be65fb3a9f5eb5b5944d6 From 6238fda95e8c724f6975d7ee3adfa3f2ffc1a5ea Mon Sep 17 00:00:00 2001 From: jaib1 Date: Thu, 7 Mar 2019 12:40:00 +0000 Subject: [PATCH 306/507] updated signals (dev, merged into from TestPanelClasses) submodule --- signals | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/signals b/signals index 38007424..2350bac3 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 380074249abfd91b9f0be65fb3a9f5eb5b5944d6 +Subproject commit 2350bac34f61f0cecab49e7fcd9fa6c055157262 From 5be5059e4a336468534be169de98e39212d9940e Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 8 Mar 2019 17:06:30 +0200 Subject: [PATCH 307/507] Added tests and bug fix for timer deletion --- +eui/AlyxPanel.m | 3 +- tests/AlyxPanelTest.m | 133 +++++++++++++++++++++++++++++++++ tests/data/viewSubjectData.mat | Bin 0 -> 8840 bytes 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 tests/AlyxPanelTest.m create mode 100644 tests/data/viewSubjectData.mat diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 53d573fc..7e4306d2 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -76,7 +76,8 @@ '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 diff --git a/tests/AlyxPanelTest.m b/tests/AlyxPanelTest.m new file mode 100644 index 00000000..b220d9cb --- /dev/null +++ b/tests/AlyxPanelTest.m @@ -0,0 +1,133 @@ +classdef AlyxPanelTest < matlab.unittest.TestCase + + properties + % Figure visibility setting before running tests + FigureVisibleDefault + % AlyxPanel instance + Panel + % Figure handle for AlyxPanel + hPanel + % Figure handle for any extra figures opened during tests + Figure + % List of subjects returned by the test database + Subjects = {'ZM_1085'; 'ZM_1087'; 'ZM_1094'; 'ZM_1098'; 'ZM_335'} + % bui.Selector object for setting the subject list in tests + SubjectUI + % Expected Y-axis labels for the viewSubjectHistory plots + Ylabels = {'water (mL)', 'weight as pct (%)', 'weight (g)'} + % The table data for the the viewSubjectHistory table + TableData + % Cell array of graph data for the the viewSubjectHistory plots. One + % cell per plot containing {xData, yData} arrays. + GraphData + end + + methods (TestClassSetup) + function killFigures(testCase) + testCase.FigureVisibleDefault = get(0,'DefaultFigureVisible'); + set(0,'DefaultFigureVisible','off'); + end + + function loadData(testCase) + % Loads validation data + % Graph data is a cell array where each element is the graph number + % (1:3) and within each element is a cell of X- and Y- axis values + % respecively + load('data/viewSubjectData.mat', 'tableData', 'graphData') + testCase.TableData = tableData; + testCase.GraphData = graphData; + end + + function setupPanel(testCase) + % Check paths file + assert(endsWith(which('dat.paths'), fullfile('tests','+dat','paths.m'))); + % Create figure for panel + testCase.hPanel = figure('Name', 'alyx GUI',... + 'MenuBar', 'none',... + 'Toolbar', 'none',... + 'NumberTitle', 'off',... + 'Units', 'normalized',... + 'OuterPosition', [0.1 0.1 0.4 .4]); + parent = uiextras.VBox('Parent', testCase.hPanel, 'Visible', 'on'); + testCase.Panel = eui.AlyxPanel(parent); + % subject selector + sbox = uix.HBox('Parent', parent); + bui.label('Select subject: ', sbox); + % Subject dropdown box + testCase.SubjectUI = bui.Selector(sbox, [{'default'}; testCase.Subjects]); + % set a callback on subject selection so that we can show water + % requirements for new mice as they are selected. This should + % be set by any other GUI that instantiates this object (e.g. + % MControl using this as a panel. + testCase.SubjectUI.addlistener('SelectionChanged', ... + @(src, evt)testCase.Panel.dispWaterReq(src, evt)); + + % Set Alyx Instance and log in + testCase.Panel.login('test_user', 'TapetesBloc18'); + testCase.fatalAssertTrue(testCase.Panel.AlyxInstance.IsLoggedIn,... + 'Failed to log into Alyx'); + end + end + + methods (TestClassTeardown) + function restoreFigures(testCase) + set(0,'DefaultFigureVisible',testCase.FigureVisibleDefault); + close(testCase.hPanel) + end + end + + methods (TestMethodTeardown) + function closeFigure(testCase) + % Close any figures opened during the test + if ~isempty(testCase.Figure); close(testCase.Figure); end + end + end + + methods (Test) + function test_viewSubjectHistory(testCase) + % Post some weights for plotting + + % Set new subject + testCase.SubjectUI.Selected = testCase.SubjectUI.Option{3}; + testCase.Panel.viewSubjectHistory + testCase.Figure = gcf(); + child_handles = testCase.Figure.Children.Children; + % Verify table data + testCase.assertTrue(isa(child_handles(1),'matlab.ui.control.Table')); + tableData = child_handles(1).Data; + testCase.verifyTrue(isequal(size(tableData), [ceil(now-737146) 9]), ... + 'Unexpected table data'); + expected = testCase.TableData; + % Remove empty days + idx = find(strcmp(expected{1,1}, tableData(:,1)),1); + tableData = tableData(idx:end,:); + testCase.verifyTrue(isequal(tableData, expected), 'Unexpected table data'); + + ax_h = child_handles(2).Children; + testCase.assertTrue(isa(ax_h, 'matlab.graphics.axis.Axes')) + testCase.assertTrue(length(ax_h)==3, 'Not all axes created') + + for i = 1:length(ax_h) + label = testCase.Ylabels{i}; + testCase.verifyEqual(ax_h(i).YLabel.String, label); + testCase.verifyEqual(length(ax_h(i).Children), ... + size(testCase.GraphData{i}{1},1)); + xData = vertcat(ax_h(i).Children(:).XData); + yData = vertcat(ax_h(i).Children(:).YData); + testCase.verifyEqual(xData, testCase.GraphData{i}{1}); + testCase.verifyEqual(yData, testCase.GraphData{i}{2}); + end + end + + function test_viewAllSubjects(testCase) + testCase.Panel.viewAllSubjects; + testCase.Figure = gcf(); + child_handle = testCase.Figure.Children.Children; + tableData = child_handle.Data; + expected = {'algernon', '0.00', '0.00'}; + testCase.verifyTrue(isequal(tableData, expected)); + end + + end + +end \ No newline at end of file diff --git a/tests/data/viewSubjectData.mat b/tests/data/viewSubjectData.mat new file mode 100644 index 0000000000000000000000000000000000000000..7bd663b95c8630a659a07ceec01f7ad4a4d5371f GIT binary patch literal 8840 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 Q9Ak2j5=>Ti*GKdJ03HqOoB#j- literal 0 HcmV?d00001 From 447eb42415e4046364da2deac6606ce2c4f34a43 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Fri, 8 Mar 2019 18:06:04 +0200 Subject: [PATCH 308/507] Last weight used in calculation for subject history plots --- +eui/AlyxPanel.m | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/+eui/AlyxPanel.m b/+eui/AlyxPanel.m index 7e4306d2..e8b24c6c 100644 --- a/+eui/AlyxPanel.m +++ b/+eui/AlyxPanel.m @@ -554,8 +554,10 @@ function viewSubjectHistory(obj, ax) 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([records.weighing_at])) = nan; + expected(expected==0|isnan(weights)) = nan; dates = cellfun(@(x)datenum(x), {records.date}); % build the figure to show it @@ -568,13 +570,13 @@ function viewSubjectHistory(obj, ax) ax = axes('Parent', plotBox); end - plot(ax, dates, [records.weighing_at], '.-'); + 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([records.weighing_at]))); + maxDate = max(dates([records.is_water_restricted]|~isnan(weights))); if numel(dates) > 1 && ~isempty(maxDate) && min(dates) ~= maxDate xlim(ax, [min(dates) maxDate]) else @@ -590,7 +592,7 @@ function viewSubjectHistory(obj, ax) if nargin==1 ax = axes('Parent', plotBox); - plot(ax, dates, ([records.weighing_at]-iw)./(expected-iw), '.-'); + 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); @@ -602,25 +604,25 @@ function viewSubjectHistory(obj, ax) axWater = axes('Parent',plotBox); plot(axWater, dates, obj.round([records.given_water_total], 'up'), '.-'); hold(axWater, 'on'); - plot(axWater, dates, obj.round([records.given_water_hydrogel], 'down'), '.-'); - plot(axWater, dates, obj.round([records.given_water_liquid], 'down'), '.-'); + 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(axWater, '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 = num2cell([records.weighing_at]); + weightsByDate = num2cell(weights); weightsByDate = cellfun(@(x)sprintf('%.1f', x), weightsByDate, 'uni', false); - weightsByDate(isnan([records.weighing_at])) = {[]}; - weightPctByDate = num2cell(([records.weighing_at]-iw)./(expected-iw)); + weightsByDate(isnan(weights)) = {[]}; + weightPctByDate = num2cell((weights-iw)./(expected-iw)); weightPctByDate = cellfun(@(x)sprintf('%.1f', x*100), weightPctByDate, 'uni', false); - weightPctByDate(isnan([records.weighing_at])|~[records.is_water_restricted]) = {[]}; + weightPctByDate(isnan(weights)|~[records.is_water_restricted]) = {[]}; dat = horzcat(... arrayfun(@(x)datestr(x), dates', 'uni', false), ... @@ -628,14 +630,14 @@ function viewSubjectHistory(obj, ax) arrayfun(@(x)iff(isnan(x), [], @()sprintf('%.1f', 0.8*(x-iw)+iw)), expected', 'uni', false), ... weightPctByDate'); waterDat = (... - num2cell(horzcat([records.given_water_liquid]', [records.given_water_hydrogel]', ... + 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]; From df607ec3ae2d42d6b0ef1543156406d13a90e10e Mon Sep 17 00:00:00 2001 From: k1o0 Date: Sun, 10 Mar 2019 12:56:02 +0200 Subject: [PATCH 309/507] Revert irrelevent commits --- +eui/ConditionPanel.m | 254 ---------------- +eui/FieldPanel.m | 165 ----------- +eui/MControl.m | 24 +- +eui/ParamEditor.m | 632 +++++++++++++++++++++++++++------------- +eui/ParamEditor_old.m | 516 -------------------------------- +exp/Parameters.m | 10 +- cortexlab/+git/update.m | 2 +- signals | 2 +- 8 files changed, 438 insertions(+), 1167 deletions(-) delete mode 100644 +eui/ConditionPanel.m delete mode 100644 +eui/FieldPanel.m delete mode 100644 +eui/ParamEditor_old.m diff --git a/+eui/ConditionPanel.m b/+eui/ConditionPanel.m deleted file mode 100644 index eacd317f..00000000 --- a/+eui/ConditionPanel.m +++ /dev/null @@ -1,254 +0,0 @@ -classdef ConditionPanel < handle - %UNTITLED Summary of this class goes here - % Detailed explanation goes here - % TODO Document - % TODO Add sort by column - % TODO Add set condition idx - % TODO Use tags for menu items - - properties - ConditionTable - MinWidth = 80 -% MaxWidth = 140 -% Margin = 4 - UIPanel - ButtonPanel - ContextMenus - end - - properties %(Access = protected) - ParamEditor - Listener - NewConditionButton - DeleteConditionButton - MakeGlobalButton - SetValuesButton - SelectedCells %[row, column;...] of each selected cell - end - - methods - function obj = ConditionPanel(f, ParamEditor, varargin) - obj.ParamEditor = ParamEditor; - obj.UIPanel = uix.VBox('Parent', f, 'BackgroundColor', 'white'); - % Create a child menu for the uiContextMenus - c = uicontextmenu; - obj.UIPanel.UIContextMenu = c; - obj.ContextMenus = uimenu(c, 'Label', 'Make Global', 'MenuSelectedFcn', @(~,~)obj.makeGlobal); - 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', @(~,~)disp('feature not yet implemented'), 'Tag', 'sort by'); - % Create condition table - p = uix.Panel('Parent', obj.UIPanel); - obj.ConditionTable = uitable('Parent', p,... - 'FontName', 'Consolas',... - 'RowName', [],... - 'RearrangeableColumns', true,... - '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, ... - 'BackgroundColor', 'white'); - % Create callback so that width of button panel is slave to width of - % conditional UIPanel -% b = obj.ButtonPanel; -% fcn = @(s)set(obj.ButtonPanel, 'Position', ... -% [s.Position(1) b.Position(2) s.Position(3) b.Position(4)]); -% obj.Listener = event.listener(obj.UIPanel, 'SizeChanged', @(s,~)fcn(s)); - % Define some common properties - props.BackgroundColor = 'white'; - props.Style = 'pushbutton'; - props.Units = 'normalized'; - props.Parent = obj.ButtonPanel; - % Create out four buttons - obj.NewConditionButton = uicontrol(props,... - 'String', 'New condition',... - ...'Position',[0 0 1/4 1],... - 'TooltipString', 'Add a new condition',... - 'Callback', @(~, ~) obj.newCondition()); - obj.DeleteConditionButton = uicontrol(props,... - 'String', 'Delete condition',... - ...'Position',[1/4 0 1/4 1],... - 'TooltipString', 'Delete the selected condition',... - 'Enable', 'off',... - 'Callback', @(~, ~) obj.deleteSelectedConditions()); - obj.MakeGlobalButton = uicontrol(props,... - 'String', 'Globalise parameter',... - ...'Position',[2/4 0 1/4 1],... - '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',... - ...'Position',[3/4 0 1/4 1],... - '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) - disp('updating table cell'); - row = eventData.Indices(1); - col = eventData.Indices(2); - paramName = obj.ConditionTable.ColumnName{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) - set(obj.ConditionTable, 'ColumnName', [], ... - 'Data', [], 'ColumnEditable', false); - end - - function delete(obj) - disp('delete called'); - delete(obj.UIPanel); - end - - function onSelect(obj, ~, 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'); - set(obj.ContextMenus(1), 'Enable', 'on'); - set(obj.ContextMenus(3), 'Enable', 'on'); - else - % nothing selected, disable buttons - set(obj.MakeGlobalButton, 'Enable', 'off'); - set(obj.DeleteConditionButton, 'Enable', 'off'); - set(obj.SetValuesButton, 'Enable', 'off'); - set(obj.ContextMenus(1), 'Enable', 'off'); - set(obj.ContextMenus(3), 'Enable', 'off'); - end - end - - function makeGlobal(obj) - if isempty(obj.SelectedCells) - disp('nothing selected') - return - end - [cols, iu] = unique(obj.SelectedCells(:,2)); - names = obj.ConditionTable.ColumnName(cols); - rows = num2cell(obj.SelectedCells(iu,1)); %get rows of unique selected cols - PE = obj.ParamEditor; - cellfun(@PE.globaliseParamAtCell, names, rows); - 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 - % - % See also EXP.PARAMETERS, GLOBALISESELECTEDPARAMETERS - rows = unique(obj.SelectedCells(:,1)); - names = obj.ConditionTable.ColumnName; - numConditions = size(obj.ConditionTable.Data,2); - % 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); %FIXME: Should be in ParamEditor - end - % Refresh the table of conditions FIXME: Should be in ParamEditor - obj.ParamEditor.fillConditionTable(); - 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.ConditionTable.ColumnName(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) 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/MControl.m b/+eui/MControl.m index 69c8628d..5b25075d 100644 --- a/+eui/MControl.m +++ b/+eui/MControl.m @@ -247,10 +247,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) - % Clear existing parameters control - clear(obj.ParamEditor) + %delete existing parameters control + delete(obj.ParamEditor); + set(obj.ParamProfileLabel, 'String', 'loading...', 'ForegroundColor', [1 0 0]); % Red 'Loading...' while new set loads end factory = obj.NewExpFactory; % Find which 'world' we are in @@ -305,18 +305,12 @@ function loadParamProfile(obj, profile) paramStruct = rmfield(paramStruct, 'services'); end obj.Parameters.Struct = paramStruct; - if isempty(paramStruct); return; end - % Now parameters are loaded, pass to ParamEditor for display, etc. - if isempty(obj.ParamEditor) - panel = uipanel('Parent', obj.ParamPanel, 'Position', [0 0 1 1]); -% panel = uiextras.Panel('Parent', obj.ParamPanel); - obj.ParamEditor = eui.ParamEditor(obj.Parameters, panel); % Build parameter list in Global panel by calling eui.ParamEditor - 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 + if ~isempty(paramStruct) % Now parameters are loaded, pass to ParamEditor for display, etc. + 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); + if strcmp(obj.RemoteRigs.Selected.Status, 'idle') + set(obj.BeginExpButton, 'Enable', 'on') % Re-enable start button + end end end diff --git a/+eui/ParamEditor.m b/+eui/ParamEditor.m index 4c375b7e..75aca882 100644 --- a/+eui/ParamEditor.m +++ b/+eui/ParamEditor.m @@ -1,130 +1,155 @@ classdef ParamEditor < handle - %UNTITLED2 Summary of this class goes here - % Detailed explanation goes here + %EUI.PARAMEDITOR UI control for configuring experiment parameters + % TODO. See also EXP.PARAMETERS. + % + % 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 properties + GlobalVSpacing = 20 Parameters end - properties %(Access = private) - UIPanel - GlobalUI - ConditionalUI - Parent - Listener - end - properties (Dependent) Enable end + properties (Access = private) + Root + GlobalGrid + ConditionTable + TableColumnParamNames = {} + NewConditionButton + DeleteConditionButton + MakeGlobalButton + SetValuesButton + SelectedCells %[row, column;...] of each selected cell + GlobalControls + end + events Changed end methods - function obj = ParamEditor(pars, f) - if nargin == 0; pars = []; end - if nargin < 2 - f = figure('Name', 'Parameters', 'NumberTitle', 'off',... - 'Toolbar', 'none', 'Menubar', 'none', 'DeleteFcn', @(~,~)obj.delete); - obj.Listener = event.listener(f, 'SizeChanged', @(~,~)obj.onResize); + function obj = ParamEditor(params, parent) + if nargin < 2 % Can call this function to display parameters is new window + parent = figure('Name', 'Parameters', 'NumberTitle', 'off',... + 'Toolbar', 'none', 'Menubar', 'none'); end - obj.Parent = f; - obj.UIPanel = uix.HBox('Parent', f); - obj.GlobalUI = eui.FieldPanel(obj.UIPanel, obj); - obj.ConditionalUI = eui.ConditionPanel(obj.UIPanel, obj); - obj.buildUI(pars); + obj.Parameters = params; + obj.build(parent); end function delete(obj) - delete(obj.GlobalUI); - delete(obj.ConditionalUI); - end - - function set.Enable(obj, value) - cUI = obj.ConditionalUI; - fig = obj.Parent; - if value == true - arrayfun(@(prop) set(prop, 'Enable', 'on'), findobj(fig,'Enable','off')); - if isempty(cUI.SelectedCells) - set(cUI.MakeGlobalButton, 'Enable', 'off'); - set(cUI.DeleteConditionButton, 'Enable', 'off'); - set(cUI.SetValuesButton, 'Enable', 'off'); - end - obj.Enable = true; - else - arrayfun(@(prop) set(prop, 'Enable', 'off'), findobj(fig,'Enable','on')); - obj.Enable = false; + disp('ParamEditor destructor called'); + if obj.Root.isvalid + obj.Root.delete(); end end - function clear(obj) - clear(obj.GlobalUI); - clear(obj.ConditionalUI); + function value = get.Enable(obj) + value = obj.Root.Enable; end - function buildUI(obj, pars) - obj.Parameters = pars; - obj.clear() - c = obj.GlobalUI; - names = pars.GlobalNames; - for nm = names' - if strcmp(nm, 'randomiseConditions'); continue; end - if islogical(pars.Struct.(nm{:})) % If parameter is logical, make checkbox - ctrl = uicontrol('Parent', c.UIPanel, 'Style', 'checkbox', ... - 'Value', pars.Struct.(nm{:}), 'BackgroundColor', 'white'); - addField(c, nm{:}, ctrl); - else - [~, ctrl] = addField(c, nm{:}); - ctrl.String = obj.paramValue2Control(pars.Struct.(nm{:})); - end - end + function set.Enable(obj, value) + obj.Root.Enable = value; + 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(); - %%% 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(); + 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', 'Set selected values to specified value, range or function',... + 'Enable', 'off',... + 'Callback', @(~, ~) obj.setSelectedValues()); + + obj.Root.Sizes = [sum(obj.GlobalGrid.ColumnSizes) + 32, -1]; 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'; + 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}); 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)); end - function fillConditionTable(obj) - % Build the condition table - titles = obj.Parameters.TrialSpecificNames; - [~, trialParams] = obj.Parameters.assortForExperiment; - if isempty(titles) - obj.ConditionalUI.ButtonPanel.Visible = 'off'; - obj.ConditionalUI.UIPanel.Visible = 'off'; - obj.GlobalUI.UIPanel.Position(3) = 1; - else - obj.ConditionalUI.ButtonPanel.Visible = 'on'; - obj.ConditionalUI.UIPanel.Visible = 'on'; - data = reshape(struct2cell(trialParams), numel(titles), [])'; - data = mapToCell(@(e) obj.paramValue2Control(e), data); - set(obj.ConditionalUI.ConditionTable, 'ColumnName', titles, 'Data', data,... - 'ColumnEditable', true(1, numel(titles))); - end - end +% function swapConditions(obj, idx1, idx2) % Function started, never +% finished - MW 2017-02-15 +% % params = obj.Parameters.trial +% end function addEmptyConditionToParam(obj, name) assert(obj.Parameters.isTrialSpecific(name),... @@ -158,134 +183,217 @@ function addEmptyConditionToParam(obj, name) obj.Parameters.Struct.(name) = cat(2, obj.Parameters.Struct.(name), newValue); end - function newValue = update(obj, name, value, row) - % FIXME change name to updateGlobal - 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; + 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'); else - newValue = obj.controlValue2Param(currValue, value); - obj.Parameters.Struct.(name)(:,row) = newValue; + %nothing selected, disable buttons + set(obj.MakeGlobalButton, 'Enable', 'off'); + set(obj.DeleteConditionButton, 'Enable', 'off'); + set(obj.SetValuesButton, 'Enable', 'off'); end - notify(obj, 'Changed'); end - function globaliseParamAtCell(obj, name, row) - % Make parameter 'name' a global parameter and set it's value to be - % that of the specified row. + 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 % - % See also EXP.PARAMETERS/MAKEGLOBAL, UI.CONDITIONPANEL/MAKEGLOBAL + % 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}; value = obj.Parameters.Struct.(name)(:,row); obj.Parameters.makeGlobal(name, value); - % Refresh the table of conditions - obj.fillConditionTable; - % Add new global parameter to field panel - if islogical(value) % If parameter is logical, make checkbox - ctrl = uicontrol('Parent', obj.GlobalUI.UIPanel, 'Style', 'checkbox', ... - 'Value', value, 'BackgroundColor', 'white'); - addField(obj.GlobalUI, name, ctrl); - else - [~, ctrl] = addField(obj.GlobalUI, name); - ctrl.String = obj.paramValue2Control(value); - end - obj.GlobalUI.onResize(); - obj.notify('Changed'); end - - function onResize(obj) - %%% resize condition table - notify(obj.ConditionalUI.ButtonPanel, 'SizeChanged'); - cUI = obj.ConditionalUI.UIPanel; - gUI = obj.GlobalUI.UIPanel; - - pos = obj.GlobalUI.Controls(end).Position; - colExtent = pos(1) + pos(3) + obj.GlobalUI.Margin; - colWidth = pos(3) + obj.GlobalUI.Margin + obj.GlobalUI.ColSpacing; % FIXME: inaccurate - pos = getpixelposition(gUI); - gUIExtent = pos(3); - pos = getpixelposition(cUI); - cUIExtent = pos(3); - - extent = get(obj.ConditionalUI.ConditionTable, 'Extent'); - panelWidth = cUI.Position(3); - if colExtent > gUIExtent && cUIExtent > obj.ConditionalUI.MinWidth - % If global UI controls are cut off and there is no dead space in - % the table but the minimum table width hasn't been reached, reduce - % the conditional UI width: table has scroll bar and global panel - % does not - % FIXME calculate how much space required for min control width -% obj.GlobalUI.MinCtrlWidth - % Calculate conditional UI width in normalized units - requiredWidth = (cUI.Position(3) / cUIExtent) * (colExtent - gUIExtent); - minConditionalWidth = (cUI.Position(3) / cUIExtent) * obj.ConditionalUI.MinWidth; - if requiredWidth < minConditionalWidth - % If the required width is smaller that the minimum table width, - % use minimum table width - cUI.Position(3) = minConditionalWidth; - else % Otherwise use this width - cUI.Position(3) = requiredWidth; + + 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)= 1 && colExtent < gUIExtent - % If the table space is cut off and there is dead space in the - % global UI panel, reduce the global UI panel - % If the extra space is minimum, return - if floor(gUIExtent - colExtent) <= 2; return; end - deadspace = gUIExtent - colExtent; % Spece between panels in pixels - gUI.Position(3) = (gUI.Position(3) / gUIExtent) * (gUIExtent - deadspace); - cUI.Position(3) = 1-gUI.Position(3); - cUI.Position(1) = gUI.Position(3); + notify(obj, 'Changed'); + end + + function cellEditCallback(obj, src, eventData) + disp('updating table cell'); + row = eventData.Indices(1); + col = eventData.Indices(2); + paramName = obj.TableColumnParamNames{col}; + currValue = obj.Parameters.Struct.(paramName)(:,row); + if iscell(currValue) + % cell holders are allowed to be different types of value + newParam = obj.controlValue2Param(currValue{1}, eventData.NewData, true); + obj.Parameters.Struct.(paramName){:,row} = newParam; else - % Compromise by having both panels take up half the figure -% [cUI.Position([1,3]), gUI.Position(3)] = deal(0.5); + newParam = obj.controlValue2Param(currValue, eventData.NewData); + obj.Parameters.Struct.(paramName)(:,row) = newParam; end - notify(obj.ConditionalUI.ButtonPanel, 'SizeChanged'); + % if successful update the cell with default formatting + data = get(src, 'Data'); + reformed = obj.paramValue2Control(newParam); + 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); + %notify listeners of change + notify(obj, 'Changed'); end - end - - methods (Static) - function data = paramValue2Control(data) - % convert from parameter value to control value, i.e. a value class - % that can be easily displayed and edited by the user. Everything - % except logicals are converted to charecter arrays. - switch class(data) - case 'function_handle' - % convert a function handle to it's string name - data = func2str(data); - case 'logical' - data = data ~= 0; % If logical do nothing, basically. - case 'string' - data = char(data); % Strings not allowed in condition table data - otherwise - if isnumeric(data) - % format numeric types as string number list - strlist = mapToCell(@num2str, data); - data = strJoin(strlist, ', '); - elseif iscellstr(data) - data = strJoin(data, ', '); - end + + function updateGlobal(obj, param, src) + currParamValue = obj.Parameters.Struct.(param); + switch get(src, 'style') + case 'checkbox' + newValue = logical(get(src, 'value')); + obj.Parameters.Struct.(param) = newValue; + case 'edit' + newValue = obj.controlValue2Param(currParamValue, get(src, 'string')); + obj.Parameters.Struct.(param) = newValue; + % if successful update the control with default formatting and + % modified colour + set(src, 'String', obj.paramValue2Control(newValue),... + 'ForegroundColor', [1 0 0]); %red indicating it has changed + %notify listeners of change + notify(obj, 'Changed'); end - % all other data types stay as they are + end + + function [data, paramNames, titles] = tableData(obj) + [~, trialParams] = obj.Parameters.assortForExperiment; + paramNames = fieldnames(trialParams); + titles = obj.Parameters.title(paramNames); + data = reshape(struct2cell(trialParams), numel(paramNames), [])'; + data = mapToCell(@(e) obj.paramValue2Control(e), data); end - function data = controlValue2Param(currParam, data, allowTypeChange) + function data = controlValue2Param(obj, currParam, data, allowTypeChange) % Convert the values displayed in the UI ('control values') to % parameter values. String representations of numrical arrays and % functions are converted back to their 'native' classes. @@ -324,7 +432,111 @@ function onResize(obj) end end end + + function data = paramValue2Control(obj, data) + % convert from parameter value to control value, i.e. a value class + % that can be easily displayed and edited by the user. Everything + % except logicals are converted to charecter arrays. + switch class(data) + case 'function_handle' + % convert a function handle to it's string name + data = func2str(data); + case 'logical' + data = data ~= 0; % If logical do nothing, basically. + case 'string' + data = char(data); % Strings not allowed in condition table data + otherwise + if isnumeric(data) + % format numeric types as string number list + strlist = mapToCell(@num2str, data); + data = strJoin(strlist, ', '); + elseif iscellstr(data) + data = strJoin(data, ', '); + end + end + % all other data types stay as they are + end + + function fillConditionTable(obj) + [data, params, titles] = obj.tableData; + set(obj.ConditionTable, 'ColumnName', titles, 'Data', data,... + 'ColumnEditable', true(1, numel(titles))); + obj.TableColumnParamNames = params; + end + + function makeTrialSpecific(obj, paramName, ctrls) + [uirow, ~] = find(obj.GlobalControls == ctrls{1}); + assert(numel(uirow) == 1, 'Unexpected number of matching global controls'); + cellfun(@(c) delete(c), ctrls); + obj.GlobalControls(uirow,:) = []; + obj.GlobalGrid.RowSizes(uirow) = []; + obj.Parameters.makeTrialSpecific(paramName); + obj.fillConditionTable(); + set(get(obj.GlobalGrid, 'Parent'),... + 'Heights', sum(obj.GlobalGrid.RowSizes)+45); % Reset height of globalPanel + end + function [ctrl, label, buttons] = addParamUI(obj, name) % Adds ui element for each parameter + parent = obj.GlobalGrid; % Made by build function above + ctrl = []; + label = []; + buttons = []; + if iscell(name) % 2017-02-14 MW function now called with arrayFun (instead of cellFun) + name = name{1,1}; + end + value = obj.paramValue2Control(obj.Parameters.Struct.(name)); % convert from parameter value to control value (everything but logical values become strings) + title = obj.Parameters.title(name); + description = obj.Parameters.description(name); + + if islogical(value) % If parameter is logical, make checkbox + for i = 1:length(value) + ctrl(end+1) = uicontrol('Parent', parent,... + 'Style', 'checkbox',... + 'TooltipString', description,... + 'Value', value(i),... % Added 2017-02-15 MW set checkbox to what ever the parameter value is + 'Callback', @(src, e) obj.updateGlobal(name, src)); + end + elseif ischar(value) + ctrl = uicontrol('Parent', parent,... + 'BackgroundColor', [1 1 1],... + 'Style', 'edit',... + 'String', value,... + 'TooltipString', description,... + 'UserData', name,... % save the name of the parameter in userdata + 'HorizontalAlignment', 'left',... + 'Callback', @(src, e) obj.updateGlobal(name, src)); +% elseif iscellstr(value) +% lines = mkStr(value, [], sprintf('\n'), []); +% ctrl = uicontrol('Parent', parent,... +% 'BackgroundColor', [1 1 1],... +% 'Style', 'edit',... +% 'Max', 2,... %make it multiline +% 'String', lines,... +% 'TooltipString', description,... +% 'HorizontalAlignment', 'left',... +% 'UserData', name,... % save the name of the parameter in userdata +% 'Callback', @(src, e) obj.updateGlobal(name, src)); + end + + if ~isempty(ctrl) % If control box is made, add label and conditional button + label = uicontrol('Parent', parent,... + 'Style', 'text', 'String', title, 'HorizontalAlignment', 'left',... + 'TooltipString', description); % Why not use bui.label? MW 2017-02-15 + bbox = uiextras.HBox('Parent', parent); % Make HBox for button + % UIContainer no longer present in GUILayoutToolbox, it used to + % call uipanel with the following args: + % 'Units', 'Normalized'; 'BorderType', 'none') +% buttons = bbox.UIContainer; + buttons = uicontrol('Parent', bbox, 'Style', 'pushbutton',... % Make 'conditional parameter' button + 'String', '[...]',... + 'TooltipString', sprintf(['Make this a condition parameter (i.e. vary by trial).\n'... + 'This will move it to the trial conditions table.']),... + 'FontSize', 7,... + 'Callback', @(~,~) obj.makeTrialSpecific(name, {ctrl, label, bbox})); + bbox.Sizes = 29; % Resize button height to 29px + end + end end + end diff --git a/+eui/ParamEditor_old.m b/+eui/ParamEditor_old.m deleted file mode 100644 index 05855aef..00000000 --- a/+eui/ParamEditor_old.m +++ /dev/null @@ -1,516 +0,0 @@ -classdef ParamEditor < handle - %EUI.PARAMEDITOR UI control for configuring experiment parameters - % TODO. See also EXP.PARAMETERS. - % - % 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 - - properties - GlobalVSpacing = 20 - Parameters - end - - properties (Dependent) - Enable - end - - properties (Access = private) - Root - GlobalGrid - ConditionTable - TableColumnParamNames = {} - NewConditionButton - DeleteConditionButton - MakeGlobalButton - SetValuesButton - SelectedCells %[row, column;...] of each selected cell - GlobalControls - end - - events - Changed - end - - methods - function obj = ParamEditor(params, parent) - if nargin < 2 % Can call this function to display parameters is new window - parent = figure('Name', 'Parameters', 'NumberTitle', 'off',... - 'Toolbar', 'none', 'Menubar', 'none'); - end - obj.Parameters = params; - obj.build(parent); - end - - function delete(obj) - disp('ParamEditor destructor called'); - if obj.Root.isvalid - obj.Root.delete(); - end - end - - function value = get.Enable(obj) - value = obj.Root.Enable; - end - - function set.Enable(obj, value) - obj.Root.Enable = value; - 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', 'Set selected values to specified value, range or function',... - 'Enable', 'off',... - 'Callback', @(~, ~) obj.setSelectedValues()); - - obj.Root.Sizes = [sum(obj.GlobalGrid.ColumnSizes) + 32, -1]; - 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}); - end - - 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 - obj.GlobalGrid.Contents = child_handles; % Set children to new order - - obj.GlobalGrid.ColumnSizes = [180, 200, 40]; % Set column sizes - obj.GlobalGrid.Spacing = 1; - obj.GlobalGrid.RowSizes = repmat(obj.GlobalVSpacing, 1, size(obj.GlobalControls, 1)); - end - -% function swapConditions(obj, idx1, idx2) % Function started, never -% finished - MW 2017-02-15 -% % params = obj.Parameters.trial -% end - - function addEmptyConditionToParam(obj, name) - 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 - currValue = obj.Parameters.Struct.(name); - if isnumeric(currValue) - newValue = zeros(size(currValue, 1), 1, class(currValue)); - elseif islogical(currValue) - newValue = false(size(currValue, 1), 1); - elseif iscell(currValue) - if numel(currValue) > 0 - if iscellstr(currValue) - % if all elements are strings, default to a blank string - newValue = {''}; - elseif isa(currValue{1}, 'function_handle') - % first element is a function handle, so create with a @nop - % handle - newValue = {@nop}; - else - % misc cell case - default to empty element - newValue = {[]}; - end - else - % misc case - default to empty element - newValue = {[]}; - end - else - error('Adding empty condition for ''%s'' type not implemented', class(currValue)); - end - 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'); - else - %nothing selected, disable buttons - set(obj.MakeGlobalButton, 'Enable', 'off'); - set(obj.DeleteConditionButton, 'Enable', 'off'); - set(obj.SetValuesButton, 'Enable', 'off'); - end - 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 - % - % 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) - 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 - - 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}; - 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) 1) ||... % Number of rows > 1 for chars - (~ischar(obj.pStruct.(n)) && size(obj.pStruct.(n), 2) > 1)); % Number of columns > 1 for all others + strcmp(n, 'numRepeats') ||... % numRepeats always trail specific + (ischar(obj.pStruct.(n)) && size(obj.pStruct.(n), 1) > 1) ||... % Number of rows > 1 for chars + (~ischar(obj.pStruct.(n)) && size(obj.pStruct.(n), 2) > 1); % Number of columns > 1 for all others for i = 1:n name = obj.pNames{i}; obj.IsTrialSpecific.(name) = isTrialSpecificDefault(name); @@ -182,8 +182,8 @@ function makeGlobal(obj, name, newValue) 'UniformOutput', false); % concatenate trial parameter trialParamValues = cat(1, trialParamValues{:}); - if isempty(trialParamValues) % Removed MW 30.01.19 - trialParamValues = {}; + if isempty(trialParamValues) + trialParamValues = {1}; end trialParams = cell2struct(trialParamValues, trialParamNames, 1)'; globalParams = cell2struct(globalParamValues, globalParamNames, 1); diff --git a/cortexlab/+git/update.m b/cortexlab/+git/update.m index cd939a83..200ba269 100644 --- a/cortexlab/+git/update.m +++ b/cortexlab/+git/update.m @@ -28,7 +28,7 @@ function update(scheduled) % than an hour ago. if (scheduled && (weekday(now) ~= scheduled) && now - lastFetch < 7) || ... (scheduled && (weekday(now) == scheduled) && now - lastFetch < 1) || ... - (~scheduled && now - lastFetch < 1/24) + (~scheduled && now - lastFetch < 1/24) return end disp('Updating code...') diff --git a/signals b/signals index 23f93fb3..0c9a2bea 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 23f93fb365c441d803e7ff43b5d8f17801a409e9 +Subproject commit 0c9a2bea861352758c77a03978a874cd286835c9 From f93f73b17a48dc6bd10e91fc5cf1517e799d211d Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 11 Mar 2019 13:57:34 +0200 Subject: [PATCH 310/507] Updates to signals --- signals | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/signals b/signals index 2350bac3..bb6086a0 160000 --- a/signals +++ b/signals @@ -1 +1 @@ -Subproject commit 2350bac34f61f0cecab49e7fcd9fa6c055157262 +Subproject commit bb6086a019e0382a937cbe4ccf0925c1fcd8d6e5 From 9babd51a4c8529a381f8880c056c2ca0f50b310a Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 11 Mar 2019 16:30:59 +0200 Subject: [PATCH 311/507] Added config file for todo bot --- .github/config.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/config.yml diff --git a/.github/config.yml b/.github/config.yml new file mode 100644 index 00000000..c41e8225 --- /dev/null +++ b/.github/config.yml @@ -0,0 +1,2 @@ +todo: + keyword: ['@todo','TODO','@fixme','FIXME'] From 09deaf1b4ac045413d2383ebee96484a17dd0e85 Mon Sep 17 00:00:00 2001 From: k1o0 Date: Mon, 11 Mar 2019 16:35:05 +0200 Subject: [PATCH 312/507] Hack for resize issue and fix for makeConditional() --- +eui/ConditionPanel.m | 6 ++++-- +eui/FieldPanel.m | 2 +- +eui/ParamEditor.m | 22 +++++++++++++++------- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/+eui/ConditionPanel.m b/+eui/ConditionPanel.m index eacd317f..d060abcd 100644 --- a/+eui/ConditionPanel.m +++ b/+eui/ConditionPanel.m @@ -33,12 +33,14 @@ % Create a child menu for the uiContextMenus c = uicontextmenu; obj.UIPanel.UIContextMenu = c; - obj.ContextMenus = uimenu(c, 'Label', 'Make Global', 'MenuSelectedFcn', @(~,~)obj.makeGlobal); + 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', @(~,~)disp('feature not yet implemented'), 'Tag', 'sort by'); + 'MenuSelectedFcn', @(~,~)disp('feature not yet implemented'), ... + 'Tag', 'sort by', 'Enable', 'off'); % Create condition table p = uix.Panel('Parent', obj.UIPanel); obj.ConditionTable = uitable('Parent', p,... diff --git a/+eui/FieldPanel.m b/+eui/FieldPanel.m index 10980cb6..c0c38627 100644 --- a/+eui/FieldPanel.m +++ b/+eui/FieldPanel.m @@ -83,7 +83,7 @@ function clear(obj, idx) % FIXME Rename to clearFields function makeConditional(obj, name) if nargin == 1 - selected = obj.UIPanel.Parent.CurrentObject; %FIXME Doesn't work is parent is not figure + selected = obj.ParamEditor.Root.CurrentObject; if isa(selected, 'matlab.ui.control.UIControl') && ... strcmp(selected.Style, 'text') name = selected.String; diff --git a/+eui/ParamEditor.m b/+eui/ParamEditor.m index 4c375b7e..8a760e08 100644 --- a/+eui/ParamEditor.m +++ b/+eui/ParamEditor.m @@ -11,6 +11,7 @@ GlobalUI ConditionalUI Parent + Root Listener end @@ -23,18 +24,25 @@ end methods - function obj = ParamEditor(pars, f) + function obj = ParamEditor(pars, parent) if nargin == 0; pars = []; end if nargin < 2 - f = figure('Name', 'Parameters', 'NumberTitle', 'off',... + parent = figure('Name', 'Parameters', 'NumberTitle', 'off',... 'Toolbar', 'none', 'Menubar', 'none', 'DeleteFcn', @(~,~)obj.delete); obj.Listener = event.listener(f, 'SizeChanged', @(~,~)obj.onResize); end - obj.Parent = f; - obj.UIPanel = uix.HBox('Parent', f); + obj.Root = parent; + while ~isa(obj.Root, 'matlab.ui.Figure'); obj.Root = obj.Root.Parent; end + + obj.Parent = parent; + obj.UIPanel = uix.HBox('Parent', parent); obj.GlobalUI = eui.FieldPanel(obj.UIPanel, obj); obj.ConditionalUI = eui.ConditionPanel(obj.UIPanel, 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) @@ -44,9 +52,9 @@ function delete(obj) function set.Enable(obj, value) cUI = obj.ConditionalUI; - fig = obj.Parent; + parent = obj.UIPanel; if value == true - arrayfun(@(prop) set(prop, 'Enable', 'on'), findobj(fig,'Enable','off')); + arrayfun(@(prop) set(prop, 'Enable', 'on'), findobj(parent,'Enable','off')); if isempty(cUI.SelectedCells) set(cUI.MakeGlobalButton, 'Enable', 'off'); set(cUI.DeleteConditionButton, 'Enable', 'off'); @@ -54,7 +62,7 @@ function delete(obj) end obj.Enable = true; else - arrayfun(@(prop) set(prop, 'Enable', 'off'), findobj(fig,'Enable','on')); + arrayfun(@(prop) set(prop, 'Enable', 'off'), findobj(parent,'Enable','on')); obj.Enable = false; end end From 29266f0f00c5b6db89da5d98d19317147090c35a Mon Sep 17 00:00:00 2001 From: k1o0 Date: Tue, 12 Mar 2019 14:57:22 +0200 Subject: [PATCH 313/507] Added some tests and minor modifications --- +eui/ConditionPanel.m | 48 +++++++------ +eui/FieldPanel.m | 39 +++++++---- +eui/MControl.m | 6 +- +eui/ParamEditor.m | 149 +++++++++++++++++++++------------------- +exp/Parameters.m | 6 +- tests/ParamEditorTest.m | 137 ++++++++++++++++++++++++++++++++++++ tests/ParametersTest.m | 91 ++++++++++++++++++++++++ 7 files changed, 368 insertions(+), 108 deletions(-) create mode 100644 tests/ParamEditorTest.m create mode 100644 tests/ParametersTest.m diff --git a/+eui/ConditionPanel.m b/+eui/ConditionPanel.m index d060abcd..a674f901 100644 --- a/+eui/ConditionPanel.m +++ b/+eui/ConditionPanel.m @@ -1,5 +1,5 @@ classdef ConditionPanel < handle - %UNTITLED Summary of this class goes here + %UNTITLED Deals with formatting trial conditions UI table % Detailed explanation goes here % TODO Document % TODO Add sort by column @@ -16,7 +16,7 @@ ContextMenus end - properties %(Access = protected) + properties (Access = protected) ParamEditor Listener NewConditionButton @@ -29,7 +29,8 @@ methods function obj = ConditionPanel(f, ParamEditor, varargin) obj.ParamEditor = ParamEditor; - obj.UIPanel = uix.VBox('Parent', f, 'BackgroundColor', 'white'); + obj.UIPanel = uix.VBox('Parent', f); +% obj.UIPanel.BackgroundColor = 'white'; % Create a child menu for the uiContextMenus c = uicontextmenu; obj.UIPanel.UIContextMenu = c; @@ -42,7 +43,7 @@ 'MenuSelectedFcn', @(~,~)disp('feature not yet implemented'), ... 'Tag', 'sort by', 'Enable', 'off'); % Create condition table - p = uix.Panel('Parent', obj.UIPanel); + p = uix.Panel('Parent', obj.UIPanel, 'BorderType', 'none'); obj.ConditionTable = uitable('Parent', p,... 'FontName', 'Consolas',... 'RowName', [],... @@ -53,48 +54,37 @@ 'CellEditCallback', @obj.onEdit,... 'CellSelectionCallback', @obj.onSelect); % Create button panel to hold condition control buttons - obj.ButtonPanel = uix.HBox('Parent', obj.UIPanel, ... - 'BackgroundColor', 'white'); - % Create callback so that width of button panel is slave to width of - % conditional UIPanel -% b = obj.ButtonPanel; -% fcn = @(s)set(obj.ButtonPanel, 'Position', ... -% [s.Position(1) b.Position(2) s.Position(3) b.Position(4)]); -% obj.Listener = event.listener(obj.UIPanel, 'SizeChanged', @(s,~)fcn(s)); + obj.ButtonPanel = uix.HBox('Parent', obj.UIPanel); % Define some common properties - props.BackgroundColor = 'white'; +% props.BackgroundColor = 'white'; props.Style = 'pushbutton'; props.Units = 'normalized'; props.Parent = obj.ButtonPanel; % Create out four buttons obj.NewConditionButton = uicontrol(props,... 'String', 'New condition',... - ...'Position',[0 0 1/4 1],... 'TooltipString', 'Add a new condition',... 'Callback', @(~, ~) obj.newCondition()); obj.DeleteConditionButton = uicontrol(props,... 'String', 'Delete condition',... - ...'Position',[1/4 0 1/4 1],... 'TooltipString', 'Delete the selected condition',... 'Enable', 'off',... 'Callback', @(~, ~) obj.deleteSelectedConditions()); obj.MakeGlobalButton = uicontrol(props,... 'String', 'Globalise parameter',... - ...'Position',[2/4 0 1/4 1],... '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',... - ...'Position',[3/4 0 1/4 1],... '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) disp('updating table cell'); row = eventData.Indices(1); @@ -229,7 +219,7 @@ function setSelectedValues(obj) % Set multiple fields in conditional table elseif length(newVals)