diff --git a/@NeuralEmbedding/NeuralEmbedding.m b/@NeuralEmbedding/NeuralEmbedding.m index fe90895..d5b5c61 100644 --- a/@NeuralEmbedding/NeuralEmbedding.m +++ b/@NeuralEmbedding/NeuralEmbedding.m @@ -259,7 +259,8 @@ function addEvents(obj,evts) % % To convert events with absolute timestamps to the required relative % format, use the static utility: - % evts = NeuralEmbedding.absoluteToRelativeEvents(evts, trialStartTimes) + % evts = NeuralEmbedding.absoluteToRelativeEvents(evts, ... + % 'trialStartReference', trialStartTimes) % % See also NeuralEmbedding.absoluteToRelativeEvents arguments @@ -1258,18 +1259,28 @@ function animate3(obj,maxT) end %% Usefull generic methods methods(Static) - function evts = absoluteToRelativeEvents(evts, trialStartTimes) + function evts = absoluteToRelativeEvents(evts, namevalue) % ABSOLUTETORELATIVEEVENTS Convert event timestamps from absolute to relative time. - % EVTS = ABSOLUTETORELATIVEEVENTS(EVTS, TRIALSTARTTIMES) subtracts + % EVTS = ABSOLUTETORELATIVEEVENTS(EVTS, 'trialStartReference', REF) subtracts % each event's trial-start time from its Ts field, so that the % returned struct has Ts values relative to the beginning of the % trial (0 = trial alignment / trial start). % % Inputs: % evts - struct array with fields Ts (absolute recording time), - % name, trial, and optionally data. - % trialStartTimes - (1 x nTrials) or (nTrials x 1) vector of trial-start - % timestamps in the same absolute time base as evts.Ts. + % name, trial, and optionally data. Events without a + % valid Ts are discarded. + % trialStartReference - either: + % * numeric vector (1 x nTrials) or (nTrials x 1) with + % trial-start timestamps in the same absolute time base + % as evts.Ts, or + % * event name (char/string) present in evts. In this + % modality, for each trial the first event with that + % name is used as trial-start reference. + % inferTrialFromBounds - logical flag (default false). If true, and both + % 'trialstart' and 'trialend' events are present in evts, + % missing/invalid trial assignments are inferred by + % checking where each event Ts falls within those bounds. % % Output: % evts - same struct array with Ts converted to relative time. @@ -1281,32 +1292,157 @@ function animate3(obj,maxT) % evt.name = "reward"; % evt.trial = 2; % belongs to trial 2 (started at t=20) % evt.data = []; - % evt = NeuralEmbedding.absoluteToRelativeEvents(evt, trialStarts); + % evt = NeuralEmbedding.absoluteToRelativeEvents(evt, ... + % 'trialStartReference', trialStarts); % % evt.Ts is now 2 (= 22 - 20) % % See also NeuralEmbedding.addEvents arguments - evts struct - trialStartTimes (1,:) double + evts struct + namevalue.inferTrialFromBounds {mustBeNumArrayOrString} + namevalue.trialStartReference (1,1) logical = false end evts = evts(:); % normalise to column vector ff = fieldnames(evts); - if ~ismember('Ts', ff) || ~ismember('trial', ff) - error('NeuralEmbedding:absoluteToRelativeEvents:invalidFields', ... - 'Event structure must contain at least the fields: Ts, trial.'); + + trialStartReference = namevalue.trialStartReference; + inferTrialFromBounds = namevalue.inferTrialFromBounds; + + if ~ismember('Ts', ff) + evts = evts([]); + return; + end + + hasTs = arrayfun(@(e) isnumeric(e.Ts) && isscalar(e.Ts) && ... + ~isempty(e.Ts) && isfinite(e.Ts), evts); + evts = evts(hasTs); + if isempty(evts) + return; + end + + ff = fieldnames(evts); + if ~ismember('trial', ff) + [evts.trial] = deal([]); + ff = fieldnames(evts); + end + + if inferTrialFromBounds + if ~ismember('name', ff) + error('NeuralEmbedding:absoluteToRelativeEvents:invalidFields', ... + 'Event structure must contain field name when inferTrialFromBounds is true.'); + end + evtNames = string({evts.name}); + isTrialStart = strcmpi(evtNames,'trialstart'); + isTrialEnd = strcmpi(evtNames,'trialend'); + if any(isTrialStart) && any(isTrialEnd) + tStart = [evts(isTrialStart).Ts]; + tEnd = [evts(isTrialEnd).Ts]; + + % Handle simple boundary mismatches: + % 1) unmatched end at the beginning + while ~isempty(tStart) && ~isempty(tEnd) && tEnd(1) < tStart(1) + tEnd(1) = []; + end + + % 2) unmatched start at the end + while ~isempty(tStart) && ~isempty(tEnd) && tStart(end) > tEnd(end) + tStart(end) = []; + end + + % Pair remaining starts/ends + nBounds = min(numel(tStart), numel(tEnd)); + tStart = tStart(1:nBounds); + tEnd = tEnd(1:nBounds); + + % Keep only properly ordered pairs + validBounds = tEnd >= tStart; + tStart = tStart(validBounds); + tEnd = tEnd(validBounds); + + if ~isempty(tStart) + for i = 1:numel(evts) + trial = evts(i).trial; + hasValidTrial = isnumeric(trial) && isscalar(trial) && ... + ~isempty(trial) && isfinite(trial) && ... + trial >= 1 && mod(trial,1) == 0; + if ~hasValidTrial + match = find(evts(i).Ts >= tStart & evts(i).Ts <= tEnd, 1, 'first'); + if ~isempty(match) + evts(i).trial = match; + end + end + end + end + end + end + + if isempty(trialStartReference) + error('NeuralEmbedding:absoluteToRelativeEvents:missingTrialStartReference', ... + ['Missing trial-start reference. Provide ', ... + '''trialStartReference'' as numeric trial-start times ', ... + 'or as an event name present in evts.']); + end + + if isnumeric(trialStartReference) + trialStartTimes = trialStartReference(:)'; + else + if ~ismember('name', ff) + error('NeuralEmbedding:absoluteToRelativeEvents:invalidFields', ... + 'Event structure must contain fields Ts, name, trial.'); + end + + refName = string(trialStartReference); + if strlength(refName) == 0 + error('NeuralEmbedding:absoluteToRelativeEvents:invalidTrialStartReference', ... + 'trialStartReference event name must be non-empty.'); + end + + validTrialMask = arrayfun(@(e) isnumeric(e.trial) && isscalar(e.trial) && ... + ~isempty(e.trial) && isfinite(e.trial) && ... + e.trial >= 1 && mod(e.trial,1) == 0, evts); + if ~all(validTrialMask) + error('NeuralEmbedding:absoluteToRelativeEvents:invalidTrial', ... + ['All events must have a valid trial index to use ', ... + 'an event name as trialStartReference.']); + end + + trialIdx = [evts.trial]; + nTrials = max(trialIdx); + evtNames = string({evts.name}); + isRefEvent = strcmp(evtNames, refName); + trialStartTimes = nan(1,nTrials); + for trial = 1:nTrials + idx = find(isRefEvent & trialIdx == trial, 1, 'first'); + if isempty(idx) + error('NeuralEmbedding:absoluteToRelativeEvents:missingTrialStartEvent', ... + 'No event named %s found for trial %d.', char(refName), trial); + end + trialStartTimes(trial) = evts(idx).Ts; + end end nTrials = numel(trialStartTimes); for i = 1:numel(evts) trial = evts(i).trial; - if trial < 1 || trial > nTrials + if ~isnumeric(trial) || ~isscalar(trial) || isempty(trial) || ... + ~isfinite(trial) || trial < 1 || mod(trial,1) ~= 0 || trial > nTrials error('NeuralEmbedding:absoluteToRelativeEvents:invalidTrial', ... - 'Trial index %d is out of range [1, %d].', trial, nTrials); + 'Event trial index is invalid or out of range [1, %d].', nTrials); end evts(i).Ts = evts(i).Ts - trialStartTimes(trial); end + + + end + + function mustBeNumArrayOrString(x) + if not(isnumeric(x) || ischar(x) || (isstring(x) && isscalar(x))) + eidType = 'mustBeNumArrayOrString:notNumArrayOrString'; + msgType = 'Input must be a numeric array or a scalar string (or char vector).'; + error(eidType,msgType); + end end % Gaussian kernel smoothing of data across time @@ -1598,5 +1734,3 @@ function animate3(obj,maxT) end end end - -