diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6535241 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Compiled source # +################### +*.com +*.class +*.dll +*.exe +*.o +*.so + +# Packages # +############ +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar +*.zip + +# Logs and databases # +###################### +*.log +*.sql +*.sqlite + +# OS generated files # +###################### +.DS_Store +Thumbs.db + +# IDEs and editors # +#################### +.vscode/ +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Python # +########## +*.pyc +*.pyo +__pycache__/ + +# pixi # +########### +.pixi/ +pixi.lock + +# Environment variables # +######################### +.env \ No newline at end of file diff --git a/README.md b/README.md index 823acfa..9517ae3 100644 --- a/README.md +++ b/README.md @@ -1 +1,59 @@ -# cagelab \ No newline at end of file +--- +title: CageLab +author: Ian Max Andolina +--- + +# Table of Contents + +1. [Introduction](#introduction) +1. [Installation](#installation) +1. [Usage](#usage) +1. [Parts List](#parts-list) +1. [Contributing](#contributing) +1. [License](#license) + +# Introduction + +![CageLab Render with Eyetracking and Joystick modules attached](images/CageLab.png){width=50%} + +CageLab is a low cost in-cage touchscreen training device that prioritises the following features: + +1. **Customisable**: The device should be customisable to suit different facility needs. We employ a modular design using [ T-slot framing](https://en.wikipedia.org/wiki/T-slot_structural_framing) for the shell to allow for easy modification without needing a full workshop. +1. **Scalable**: Integration with the Alyx metadata pipeline ([International Brain Lab, 2023, Nature Methods](https://doi.org/10.1038/s41592-022-01742-6)) to allow for easy management of the data generated by multiple CageLab instances. Each device uploads behavioural data to a central server, which can be accessed and searched by the user through a web interface. +1. **Automatic Training**: Use behavioural shaping stages with an automated staircase to guide subjects to improved behavioural performance with less experimenter intervention. +1. **Low Cost**: CageLab should be affordable for most research labs. We aim to keep the total cost of the device below $400. +1. **Single-enclosure & Battery Operated**: Many animal facilities do not have accesible power outlets near animal housing, or do not want cables or extra trolleys. The device should be self-contained and battery operated. We added a small UPS to enable hot-swapping of batteries. +1. **Open Source**: The device software should be open source and easy to contribute to. +1. **Common Software Path**: The device should be easy to use and maintain; we use [PsychToolbox (PTB)](https://psychtoolbox.org) for easy integration with existing code common in most labs where PTB is prevalent. PTB offers best-in-class experiment timing and a wide range of specialised hardware support. +1. **Modular**: We have the following modules to use with the device: + - **Fluid Pump**: Peristaltic pump, HID interface and costs about $10. + - **Food Dispenser**: A pellet dispenser using an arduino driver. + - **Camera**: A camera to record the animal's behaviour and stream it over the network. + - **Speaker**: A speaker to play sounds. +1. **Remote Control**: The device must be controllable remotely. We use the fast moonlight remote desktop protocol and server + client protocols where useful. + + +# Installation and Setup + +## Multi-Cage Setup + +CageLab is designed to be used in a multi-cage setup. Each CageLab instance is connected to a central Alyz server that manages the data generated by each device. The server is accessible through a web interface that allows the user to search and download the data generated by each device. The experiment control software is run on a remote desktop that is connected to the device through the ZeroMQ client server protocol. The remote desktop allows the user to control the device from a distance and monitor the animal's behaviour through a camera that is connected to the device. + +![CageLab Multi-Cage Setup](images/CageLab-Network.png) + +## Hardware + +## Software + +You need Octave / MATLAB, PTB and the [opticka toolbox](https://github.com/iandol/opticka) installed as dependencies. For remote desktop, you need to install moonlight on the device and the client. + +# Parts List + + +# Contributing + +Please feel free to open issues or pull requests. We are happy to help you get started with CageLab. We are also looking for collaborators to help us improve the device. + +# License + + diff --git a/data-pipeline/README.md b/data-pipeline/README.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e69de29 diff --git a/hardware/CageLab Frame/README.md b/hardware/CageLab Frame/README.md new file mode 100644 index 0000000..546ffc7 --- /dev/null +++ b/hardware/CageLab Frame/README.md @@ -0,0 +1,4 @@ +# Hardware + +We use T-slot aluminium framing for the shell to allow for easy modification without needing a full workshop. + diff --git a/hardware/Fluid Pump/README.md b/hardware/Fluid Pump/README.md new file mode 100644 index 0000000..3f23738 --- /dev/null +++ b/hardware/Fluid Pump/README.md @@ -0,0 +1,4 @@ +# Hardware + +Docs are WIP. + diff --git a/hardware/Food Dispenser/README.md b/hardware/Food Dispenser/README.md new file mode 100644 index 0000000..3f23738 --- /dev/null +++ b/hardware/Food Dispenser/README.md @@ -0,0 +1,4 @@ +# Hardware + +Docs are WIP. + diff --git a/hardware/HID Joystick/README.md b/hardware/HID Joystick/README.md new file mode 100644 index 0000000..6d4f99f --- /dev/null +++ b/hardware/HID Joystick/README.md @@ -0,0 +1,3 @@ +# HID Joystick + +Docs are WIP. diff --git a/hardware/README.md b/hardware/README.md new file mode 100644 index 0000000..054a067 --- /dev/null +++ b/hardware/README.md @@ -0,0 +1,4 @@ +# Hardware + +The hardware modules are designed to be modular and easy to assemble. We use T-slot aluminium framing for the shell to allow for easy modification without needing a full workshop. Both the fluid pump and joystick have a HID interface, allowing them to be used with any computer. The food dispenser is designed to be easy to clean and refill. + diff --git a/images/CageLab-Network.png b/images/CageLab-Network.png new file mode 100644 index 0000000..535943b Binary files /dev/null and b/images/CageLab-Network.png differ diff --git a/images/CageLab.png b/images/CageLab.png new file mode 100644 index 0000000..e1f50f0 Binary files /dev/null and b/images/CageLab.png differ diff --git a/images/Logo.png b/images/Logo.png new file mode 100644 index 0000000..d4c0e45 Binary files /dev/null and b/images/Logo.png differ diff --git a/images/tslot.png b/images/tslot.png new file mode 100644 index 0000000..497129a Binary files /dev/null and b/images/tslot.png differ diff --git a/software/CageLab.mlapp b/software/CageLab.mlapp new file mode 100644 index 0000000..eb37375 Binary files /dev/null and b/software/CageLab.mlapp differ diff --git a/software/Logo.png b/software/Logo.png new file mode 100644 index 0000000..d4c0e45 Binary files /dev/null and b/software/Logo.png differ diff --git a/software/README.md b/software/README.md new file mode 100644 index 0000000..9c899b4 --- /dev/null +++ b/software/README.md @@ -0,0 +1,13 @@ +# Software Frameworks + +We use the opticka toolbox for the main experiment control, a high-level wrapper for PsychToolbox. A GUI called `CageLab.mlapp` is provided. This GUI is run on your lab machine and will send the experiment settings to the CageLab instance. Each experiment protocol can be run. + +## Data pipeline + +We use the [International Brain Lab](https://doi.org/10.1038/s41592-022-01742-6) metadata pipeline to manage the data generated by CageLab instances, which opticka supports without other dependencies. + + +## Remote control + +We use Zerotier VPN, SSH and the [moonlight remote desktop protocol](https://moonlight-stream.org) or NoMachine for remote control of the device. + diff --git a/software/images/cebsit.png b/software/images/cebsit.png new file mode 100644 index 0000000..f63e1a8 Binary files /dev/null and b/software/images/cebsit.png differ diff --git a/software/images/link.circle.off.svg b/software/images/link.circle.off.svg new file mode 100644 index 0000000..4fea5c4 --- /dev/null +++ b/software/images/link.circle.off.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/software/images/link.circle.svg b/software/images/link.circle.svg new file mode 100644 index 0000000..d80ff83 --- /dev/null +++ b/software/images/link.circle.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/software/images/send.svg b/software/images/send.svg new file mode 100644 index 0000000..c59b459 --- /dev/null +++ b/software/images/send.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + diff --git a/software/startTouchTraining.m b/software/startTouchTraining.m new file mode 100644 index 0000000..e4eb88f --- /dev/null +++ b/software/startTouchTraining.m @@ -0,0 +1,368 @@ +function startTouchTraining(tr) + if ~exist('tr','var') + tr.phase = 1; + tr.density = 70; + tr.distance = 30; + tr.timeOut = 4; + tr.bg = [0.5 0.5 0.5]; + tr.maxSize = 30; + tr.minSize = 1; + tr.folder = '~/optickaFiles/'; + tr.fg = [1 1 0.75]; + tr.debug = true; + tr.dummy = true; + tr.audio = true; + tr.soundvol = 0.7; + tr.stimulus = 1; + tr.task = 2; + tr.name = 'simulcra'; + tr.rewardmode = 1; + tr.volume = 250; + tr.random = 1; + end + pixelsPerCm = tr.density; + distance = tr.distance; + timeOut = tr.timeOut; + rewardPort = '/dev/ttyACM0'; + windowed = []; + sf = []; + + % =========================== debug mode? + if tr.debug + if max(Screen('Screens'))==0; sf = kPsychGUIWindow; windowed = [0 0 1600 800]; end + end + + %if IsOctave; try pkg load instrument-control; end; end + + try + % ============================screen + s = screenManager('blend',true,'pixelsPerCm',pixelsPerCm, 'distance', distance,... + 'backgroundColour',tr.bg,'windowed',windowed,'specialFlags',sf); + + % s============================stimuli + rtarget = imageStimulus('size', 10, 'colour', [0 1 0], 'fileName', [s.paths.root filesep 'stimuli' filesep 'star.png']); + if tr.stimulus == 2 + target = imageStimulus('size', tr.maxSize, 'fileName', tr.folder, 'crop', 'square'); + else + target = discStimulus('size', tr.maxSize, 'colour', tr.fg); + end + if tr.debug; target.verbose = true; end + + % ============================audio + + a = audioManager; + if tr.debug; a.verbose = true; end + if tr.soundvol == 0 || tr.audio == false; a.silentMode = true; end + setup(a); + beep(a,2000,0.1,tr.soundvol/2); + WaitSecs(0.1); + beep(a,300,0.5,tr.soundvol/2); + + % ============================touch + t = touchManager('isDummy',tr.dummy); + t.window.doNegation = true; + t.window.negationBuffer = 1.5; + t.drainEvents = true; + t.verbose=true; + if tr.debug; t.verbose = true; end + + % ============================reward + rM = arduinoManager; + rM.silentMode = true; + rM.reward.type = 'TTL'; + rM.reward.pin = 2; + rM.reward.time = tr.volume; % 250ms + if tr.debug; rM.verbose = true; end + try open(rM); end + + % ============================steps table + sz = linspace(tr.maxSize, tr.minSize, 5); + + if tr.task == 1 % simple task + if tr.phase > 9; tr.phase = 9; end + pn = 1; + %size + p(pn).size = sz(pn); p(pn).hold = 0.05; p(pn).rel = 3; p(pn).pos = [0 0]; pn = pn + 1; + p(pn).size = sz(pn); p(pn).hold = 0.05; p(pn).rel = 3; p(pn).pos = [0 0]; pn = pn + 1; + p(pn).size = sz(pn); p(pn).hold = 0.05; p(pn).rel = 3; p(pn).pos = [0 0]; pn = pn + 1; + p(pn).size = sz(pn); p(pn).hold = 0.05; p(pn).rel = 3; p(pn).pos = [0 0]; pn = pn + 1; + p(pn).size = sz(pn); p(pn).hold = 0.05; p(pn).rel = 3; p(pn).pos = [0 0]; pn = pn + 1; + % position + p(pn).size = sz(end); p(pn).hold = 0.05; p(pn).rel = 3; p(pn).pos = 3; pn = pn + 1; + p(pn).size = sz(end); p(pn).hold = 0.05; p(pn).rel = 3; p(pn).pos = 5; pn = pn + 1; + p(pn).size = sz(end); p(pn).hold = 0.05; p(pn).rel = 3; p(pn).pos = 7; pn = pn + 1; + p(pn).size = sz(end); p(pn).hold = 0.05; p(pn).rel = 3; p(pn).pos = 11; pn = pn + 1; + else + pn = 1; + p(pn).size = sz(pn); p(pn).hold = 0.05; p(pn).rel = 3; p(pn).pos = [0 0]; pn = pn + 1; + p(pn).size = sz(pn); p(pn).hold = 0.05; p(pn).rel = 3; p(pn).pos = [0 0]; pn = pn + 1; + p(pn).size = sz(pn); p(pn).hold = 0.05; p(pn).rel = 3; p(pn).pos = [0 0]; pn = pn + 1; + p(pn).size = sz(pn); p(pn).hold = 0.05; p(pn).rel = 3; p(pn).pos = [0 0]; pn = pn + 1; + p(pn).size = sz(pn); p(pn).hold = 0.05; p(pn).rel = 3; p(pn).pos = [0 0]; pn = pn + 1; + % 6 + p(pn).size = sz(end); p(pn).hold = 0.1; p(pn).rel = 3; p(pn).pos = [0 0]; pn = pn + 1; + p(pn).size = sz(end); p(pn).hold = 0.2; p(pn).rel = 3; p(pn).pos = [0 0]; pn = pn + 1; + p(pn).size = sz(end); p(pn).hold = 0.4; p(pn).rel = 3; p(pn).pos = [0 0]; pn = pn + 1; + p(pn).size = sz(end); p(pn).hold = 0.8; p(pn).rel = 3; p(pn).pos = [0 0]; pn = pn + 1; + p(pn).size = sz(end); p(pn).hold = 1; p(pn).rel = 3; p(pn).pos = [0 0]; pn = pn + 1; + p(pn).size = sz(end); p(pn).hold = [1 2]; p(pn).rel = 3; p(pn).pos = [0 0]; pn = pn + 1; + % 12 + p(pn).size = sz(end); p(pn).hold = [1 2]; p(pn).rel = 2; p(pn).pos = [0 0]; pn = pn + 1; + p(pn).size = sz(end); p(pn).hold = [1 2]; p(pn).rel = 1.75; p(pn).pos = [0 0]; pn = pn + 1; + p(pn).size = sz(end); p(pn).hold = [1 2]; p(pn).rel = 1.5; p(pn).pos = [0 0]; pn = pn + 1; + p(pn).size = sz(end); p(pn).hold = [1 2]; p(pn).rel = 1.25; p(pn).pos = [0 0]; pn = pn + 1; + p(pn).size = sz(end); p(pn).hold = [1 2]; p(pn).rel = 1; p(pn).pos = [0 0]; pn = pn + 1; + % 17 + p(pn).size = sz(end); p(pn).hold = [1 2]; p(pn).rel = 1; p(pn).pos = 3; pn = pn + 1; + p(pn).size = sz(end); p(pn).hold = [1 2]; p(pn).rel = 1; p(pn).pos = 5; pn = pn + 1; + p(pn).size = sz(end); p(pn).hold = [1 2]; p(pn).rel = 1; p(pn).pos = 7; pn = pn + 1; + p(pn).size = sz(end); p(pn).hold = [1 2]; p(pn).rel = 1; p(pn).pos = 11; pn = pn + 1; + end + + + % ============================setup + sv = open(s); + drawText(s,'Initialising...');flip(s); + aspect = sv.width / sv.height; + setup(rtarget, s); + setup(target, s); + setup(t, s); + createQueue(t); + start(t); + + % ==============================save file name + svn = initialiseSaveFile(s); + mkdir([s.paths.savedData filesep tr.name]); + saveName = [ s.paths.savedData filesep tr.name filesep 'TouchT-' tr.name '-' svn '.mat']; + dt = touchData; + dt.name = saveName; + dt.subject = tr.name; + dt.data.random = 0; + dt.data.rewards = 0; + dt.data.tr = tr; + + % ============================settings + quitKey = KbName('escape'); + RestrictKeysForKbCheck([quitKey]); + Screen('Preference','Verbosity',4); + %try Priority(1); end + if ~tr.debug || ~tr.dummy; HideCursor; end + txt = 'Waiting for touch...'; + keepRunning = true; + trialN = 0; + phaseN = 0; + timeOut = 2; + phase = tr.phase; + stimulus = 1; + rTime = GetSecs; + rRect = rtarget.mvRect; + + while keepRunning + if phase > length(p); phase = length(p); end + if length(p(phase).pos) == 2 + x = p(phase).pos(1); + y = p(phase).pos(2); + else + x = randi(p(phase).pos(1)); + if rand > 0.5; x = -x; end + y = randi(p(phase).pos(1)); + y = y / aspect; + if rand > 0.5; y = -y; end + end + if length(p(phase).hold) == 2 + t.window.hold = randi(p(phase).hold .* 1e3) / 1e3; + else + t.window.hold = p(phase).hold(1); + end + if isa(target,'imageStimulus') + t.window.radius = [p(phase).size/2 p(phase).size/2]; + else + t.window.radius = p(phase).size / 2; + end + t.window.init = 3; + t.window.release = p(phase).rel; + t.window.X = x; + t.window.Y = y; + + target.xPositionOut = x; + target.yPositionOut = y; + target.sizeOut = p(phase).size; + if isa(target,'imageStimulus') + target.selectionOut = randi(target.nImages); + stimulus = target.selectionOut; + end + update(target); + + res = 0; + touchStart = false; keepRunning = true; + touchResponse = ''; + anyTouch = false; + txt = ''; + trialN = trialN + 1; + hldtime = false; + + fprintf('\n===> START TRIAL: %i - PHASE %i STIM %i\n', trialN, phase, stimulus); + fprintf('===> Size: %.1f Init: %.2f Hold: %.2f Release: %.2f\n', t.window.radius,t.window.init,t.window.hold,t.window.release); + + if trialN == 1; dt.data.startTime = GetSecs; end + + reset(t); + flush(t); + + WaitSecs(0.01); + vbl = flip(s); vblInit = vbl; + while ~touchStart && vbl < vblInit + 4 + if ~hldtime; draw(target); end + if tr.debug && ~isempty(t.x) && ~isempty(t.y) + drawText(s, txt); + [xy] = s.toPixels([t.x t.y]); + Screen('glPoint', s.win, [1 0 0], xy(1), xy(2), 10); + end + vbl = flip(s); + [touchResponse, hld, hldtime, rel, reli, se, fail, tch] = testHoldRelease(t,'yes','no'); + if tch + anyTouch = true; + end + txt = sprintf('Step=%i Touch=%i x=%.2f y=%.2f h:%i ht:%i r:%i rs:%i s:%i %.1f Init: %.2f Hold: %.2f Release: %.2f',... + phase,touchResponse,t.x,t.y,hld, hldtime, rel, reli, se,... + t.window.radius,t.window.init,t.window.hold,t.window.release); + if ~isempty(touchResponse); touchStart = true; break; end + [~,~,c] = KbCheck(); + if c(quitKey); keepRunning = false; break; end + end + + vblEnd = flip(s); + WaitSecs(0.05); + + if anyTouch == false + tt = vblEnd - rTime; + if tr.random > 0 && tt > tr.random && rand > 0.25 + drawBackground(s); + WaitSecs(rand/2); + for i = 0:round(s.screenVals.fps/3) + draw(rtarget); + flip(s); + inc = sin(i*0.2)/2 + 1; + if inc <=0; inc =0.01; end + rtarget.angleOut = rtarget.angleOut+0.5; + rtarget.mvRect = ScaleRect(rRect, inc, inc); + rtarget.mvRect = CenterRect(rtarget.mvRect,s.screenVals.winRect); + end + flip(s); + giveReward(rM); + dt.data.rewards = dt.data.rewards + 1; + dt.data.random = dt.data.random + 1; + fprintf('===> RANDOM REWARD :-)\n'); + beep(a,2000,0.1,0.1); + WaitSecs(0.75+rand); + rTime = GetSecs; + else + fprintf('===> TIMEOUT :-)\n'); + drawText(s,'TIMEOUT!'); + flip(s); + WaitSecs(0.75+rand); + end + elseif strcmp(touchResponse,'yes') + giveReward(rM); + dt.data.rewards = dt.data.rewards + 1; + fprintf('===> CORRECT :-)\n'); + beep(a,2000,0.1,0.1); + update(dt, true, phase, trialN, vblEnd-vblInit, stimulus); + phaseN = phaseN + 1; + drawText(s,['CORRECT! phase: ' num2str(phase)]); + flip(s); + WaitSecs(0.5+rand); + rTime = GetSecs; + elseif strcmp(touchResponse,'no') + update(dt, false, phase, trialN, vblEnd-vblInit, stimulus); + phaseN = phaseN + 1; + fprintf('===> FAIL :-(\n'); + drawBackground(s,[1 0 0]); + drawText(s,['FAIL! phase: ' num2str(phase)]); + flip(s); + beep(a,250,0.3,0.8); + WaitSecs(timeOut); + else + fprintf('===> UNKNOWN :-|\n'); + drawText(s,'UNKNOWN!'); + flip(s); + WaitSecs(0.5+rand); + end + + if trialN >= 10 + if length(dt.data.result)>10 + res = sum(dt.data.result(end-9:end)); + end + fprintf('===> Performance: %i Phase: %i\n',res,phase); + if phaseN >= 10 && length(dt.data.result)>10 + if res >= 8 + phase = phase + 1; + elseif res <= 2 + phase = phase - 1; + end + phaseN = 0; + if phase < 1; phase = 1; end + if phase > 20; phase = 20; end + if tr.task == 1 && phase > 9; phase = 9; end + fprintf('===> Phase update: %i\n',phase); + end + end + + if keepRunning == false; break; end + drawBackground(s); + flip(s); + end % while keepRunning + + drawText(s, 'FINISHED!'); + flip(s); + + try ListenChar(0); Priority(0); ShowCursor; end + try reset(target); end + try reset(rtarget); end + try close(s); end + try close(t); end + try close(rM); end + + % save trial data + disp(''); + disp('========================================='); + fprintf('===> Data for %s\n',saveName) + disp('========================================='); + tVol = (9.38e-4 * tr.volume) * dt.data.rewards; + fVol = (9.38e-4 * tr.volume) * dt.data.random; + cor = sum(dt.data.result==true); + incor = sum(dt.data.result==false); + fprintf(' Total Trials: %i\n',trialN); + fprintf(' Correct Trials: %i\n',cor); + fprintf(' Incorrect Trials: %i\n',cor); + fprintf(' Free Rewards: %i\n',dt.data.random); + fprintf(' Correct Volume: %.2f ml\n',tVol); + fprintf(' Free Volume: %i ml\n\n\n',fVol); + try dt.plot(dt); end + + % save trial data + disp('========================================='); + fprintf('===> Saving data to %s\n',saveName) + disp('========================================='); + save('-v7', saveName, 'dt'); + disp('Done!!!'); + disp('');disp('');disp(''); + WaitSecs(0.5); + sca; + + catch ME + getReport(ME); + if exist('pid','var') && ~isempty(pid) + try system(['pkill ' pid]); end %#ok<*TRYNC> + end + try reset(target); end + try close(s); end + try close(t); end + try close(rM); end + try close(a); end + try Priority(0); end + try ListenChar(0); end + sca; + end + +end