diff --git a/README.md b/README.md index 0551348..8522948 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ # OpenEP OpenEP supports the import and analysis of electroanatomic mapping data in Matlab + +For headless CARTO and EnSiteX conversion, validation, status reporting and +TRE integration, see [docs/tre_importer_integration.md](docs/tre_importer_integration.md). diff --git a/addForceData.m b/addForceData.m new file mode 100644 index 0000000..70ec75f --- /dev/null +++ b/addForceData.m @@ -0,0 +1,17 @@ +function userdata = addForceData(userdata) + +time = zeros(size(userdata.rf.originaldata.ablparams.time)); +force = zeros(size(userdata.rf.originaldata.ablparams.time)); +axialangle = zeros(size(userdata.rf.originaldata.ablparams.time)); +lateralangle = zeros(size(userdata.rf.originaldata.ablparams.time)); +position = zeros(size(userdata.rf.originaldata.ablparams.time)); + +forcedata.time = time; +forcedata.force = force; +forcedata.axialangle = axialangle; +forcedata.lateralangle = lateralangle; +forcedata.position = position; + +userdata.rf.originaldata.force = forcedata; + +end \ No newline at end of file diff --git a/addLocationPoints.m b/addLocationPoints.m new file mode 100644 index 0000000..f0d6d07 --- /dev/null +++ b/addLocationPoints.m @@ -0,0 +1,55 @@ +function userdata = addLocationPoints(userdata, X, tags, names) +% ADDLOCATIONPOINTS Manually add location points to data +% +% Usage: +% userdata = addLocationPoints(userdata, X, tags, names) +% Where: +% userdata - the OpenEP data structure +% X - locations +% tags - tags +% names - names +% +% addLocationPoints does not accept parameter value pairs +% +% ADDLOCATIONPOINTS Manually add location points to data +% +% Author: Steven Williams (2024) +% Modifications - +% +% Info on Code Testing: +% --------------------------------------------------------------- +% % copy and paste co-ordinates from a CSV file +% tags = cell(size(X,1), 1); +% tags(:) = {'ablation'}; +% names = cell(size(X,1), 1); +% names(:) = {'abl'}; +% userdata = addLocationPoints(userdata, X, tags, names) +% --------------------------------------------------------------- +% +% --------------------------------------------------------------- +% code +% --------------------------------------------------------------- + +numPointsToAdd = size(X, 1); + +for i = 1:numPointsToAdd +userdata.electric.tags{end+1} = tags{i}; +userdata.electric.names{end+1} = [names{i} num2str(i)]; +userdata.electric.egmX(end+1,:) = X(i,:); +userdata.electric.egm(end+1,:) = NaN; +userdata.electric.egmRef(end+1,:) = NaN(1,1000); +userdata.electric.annotations.woi(end+1,:) = [NaN NaN]; +userdata.electric.annotations.referenceAnnot(end+1,:) = NaN; +userdata.electric.annotations.mapAnnot(end+1,:) = NaN; +userdata.electric.voltages.bipolar(end+1,:) = NaN; + +tr = getMesh(userdata, 'type', 'triangulation'); +surfX = findclosestvertex(tr, X(i,:), true); +userdata.electric.egmSurfX(end+1,:) = userdata.surface.triRep.X(surfX,:); +userdata.electric.LATs(end+1,:) = NaN; +userdata.electric.electrodeNames_uni(end+1,:) = NaN; +userdata.electric.include(end+1) = false; +userdata.electric.discarded(end+1) = true; + + +end \ No newline at end of file diff --git a/batchConvert.m b/batchConvert.m index d96b28a..3c0ed49 100644 --- a/batchConvert.m +++ b/batchConvert.m @@ -44,11 +44,30 @@ function batchConvert(inputDir, outputDir) % Store comment about what we have done userdata.notes{end+1} = [date ': data set converted using batchConvert.m']; + % If force data does not exist, add it please + if isfield(userdata, 'rf') + if isempty(userdata.rf) + userdata = addForceData(userdata); + end + if ~isfield(userdata.rf.originaldata, 'force') + userdata = addForceData(userdata); + disp(' ... fake force data added') + userdata.notes{end+1} = [date ': fake forcedata added during batchConvert.m']; + end + else + userdata.rf = []; + end + + % If force data does not exist, add it please + if isempty(userdata.surface.uni_imp_frc) + userdata.surface.uni_imp_frc = NaN(size(userdata.surface.triRep.X)); + end + % We save as -v7 because it's faster to load in OpenEP-py than -v7.3, % and the saved file is significantly smaller compared to -v6 files. outputFile = [outputDir filesep() allFiles{i}]; disp(['saving file: ' outputFile]) - save(outputFile, 'userdata', '-v7'); + save(outputFile, 'userdata', '-v7.3'); end diff --git a/calculatePeak2PeakVoltage.m b/calculatePeak2PeakVoltage.m new file mode 100644 index 0000000..c602f72 --- /dev/null +++ b/calculatePeak2PeakVoltage.m @@ -0,0 +1,37 @@ +function V = calculatePeak2PeakVoltage( egms, refAnnot, woi ) +% CALCULATEPEAK2PEAKVOLTAGE Calcualtes peak to peak voltages +% +% Usage: +% V = calculatePeak2PeakVoltage( egms, refAnnot, woi ) +% Where: +% egms - the electrograms +% refAnnot - the reference annotation, in samples +% woi - the window of interest, relative to the refAnnot, in samples +% V - the output voltages +% +% CALCULATEPEAK2PEAKVOLTAGE does not accept parameter-value pairs +% +% CALCULATEPEAK2PEAKVOLTAGE Detailed description goes here +% +% Author: Steven Williams (2025) +% Modifications - +% +% Info on Code Testing: +% --------------------------------------------------------------- +% test code +% --------------------------------------------------------------- +% +% --------------------------------------------------------------- +% code +% --------------------------------------------------------------- + +nEgm = size(egms,1); +V = NaN(nEgm,1); +for iEgm = 1:nEgm + + sampleRange = refAnnot(iEgm)+woi(iEgm,1):refAnnot(iEgm)+woi(iEgm,2); + thisEgm = egms(iEgm, sampleRange); + V(iEgm) = max(thisEgm) - min(thisEgm); + +end +end diff --git a/changelog.md b/changelog.md index 75bc6ac..5e9d914 100644 --- a/changelog.md +++ b/changelog.md @@ -29,12 +29,13 @@ This section documents changes which have been merged into develop and will form - importcarto_mem.m - no longer has to check the electrode name for each point as this is taken from read_ecgfile_v4 +- ability to read latest Carto data (new format without redundancy) - ### Fixed - Fixed issue with multiple colour bars - Fixed issue #70 for setting tolerance when identifying in/out points in the mesh - Geodesic distance calculator now configured for both Ubuntu and MacOSX platforms - Tidied importcarto_mem default directories - Sped up xml reading -- Sped up read_meshfile \ No newline at end of file +- Sped up read_meshfile +- Fixed bug in getEarliestActivationSite/getLatestActivationSite when handling datasets written by EP Workbench \ No newline at end of file diff --git a/comparestructure.m b/comparestructure.m index 79be84e..1429b00 100644 --- a/comparestructure.m +++ b/comparestructure.m @@ -1,108 +1,111 @@ -function [result, commentary] = comparestructure(a,b) -% COMPARESTRUCTURE. -% Usage: -% [result, commentary] = comparestructure(a,b) -% Inputs: -% a,b - input structures to compare -% Outputs: -% result - logical -% commentary - a cellarray of text messages explaining the differences -% -% Author: Nick Linton (2023) -% Modifications - - -% Info on Code Testing: - % --------------------- - % test code - % --------------------- - -% --------------------------------------------------------------- -% code -% --------------------------------------------------------------- - - - local_addmsg(0, 'reset'); - - if isequal(a,b) - result = true; - commentary = ''; - return - else - result = false; - local_compare(a,b,0) - commentary = local_addmsg(0, 'retrieve'); - end -end - -function local_compare(a,b,level) - fieldnamesA = sort(fieldnames(a)); - fieldnamesB = sort(fieldnames(b)); - fieldNamesAll = unique([fieldnamesA;fieldnamesB]); - - badNamesA = false(size(fieldNamesAll)); - badNamesB = false(size(fieldNamesAll)); - if ~isequal(fieldnamesA, fieldnamesB) - for i = 1:numel(fieldNamesAll) - if ~any(matches(fieldnamesB, fieldNamesAll{i})) - badNamesA(i) = true; - local_addmsg(['.' fieldnamesA{i} ' is not in the second structure']); - end - if ~any(matches(fieldnamesA, fieldNamesAll{i})) - badNamesB(i) = true; - local_addmsg(['.' fieldnamesA{i} ' is not in the second structure']); - end - end - end - - level = level + 1; -% now go through the fieldNames that are shared. - sharedFieldNames = fieldNamesAll(~badNamesA & ~badNamesB); - for i = 1:numel(sharedFieldNames) - f = sharedFieldNames{i}; - if ~strcmp(class(a.(f)),class(b.(f))) - local_addmsg(level, ['.' f ' does not have the same class']); - else - switch class(a.(f)) - case 'struct' - local_addmsg(level, ['.' f ]); - local_compare(a.(f),b.(f),level) - otherwise - aData = a.(f); - bData = b.(f); - isSizeOK = isequal(size(a.(f)),size(b.(f))); - isNumelOK = isequal(numel(a.(f)),numel(b.(f))); - isContentOK = false; - if isNumelOK - isContentOK = isequaln(a.(f)(:),b.(f)(:)); - end - if isSizeOK && isContentOK - % do nothing - all good - local_addmsg(level, ['.' f ' - correct']); - elseif ~isContentOK - local_addmsg(level, ['.' f ' - &&&ERROR&&& has different CONTENT']); - else - local_addmsg(level, ['.' f ' - &&&ERROR&&& has different DIMENSIONS but similar content']); - end - end - end - end -end - -function msgAll = local_addmsg(level, msg) - persistent msgStore - - verbose = true; - switch msg - case 'reset' - msgStore = {}; - case 'retrieve' - msgAll = msgStore; - otherwise - indent = repmat(' ',1,level); - if verbose - disp([indent,msg]) - end - msgStore = [msgStore; [indent,msg]]; - end - msgAll = msgStore; -end +function [result, commentary] = comparestructure(a,b) +% COMPARESTRUCTURE. +% Usage: +% b = myfunction(a) +% Inputs: +% a - input +% Outputs: +% b - output +% +% MYFUNCTION detailed description. +% +% Author: Nick Linton (2021) +% Modifications - + +% Info on Code Testing: + % --------------------- + % test code + % --------------------- + +% --------------------------------------------------------------- +% code +% --------------------------------------------------------------- + + + local_addmsg(0, 'reset'); + + if isequal(a,b) + result = true; + commentary = ''; + return + else + result = false; + local_compare(a,b,0) + commentary = local_addmsg(0, 'retrieve'); + end +end + +function local_compare(a,b,level) + fieldnamesA = sort(fieldnames(a)); + fieldnamesB = sort(fieldnames(b)); + fieldNamesAll = unique([fieldnamesA;fieldnamesB]); + + badNamesA = false(size(fieldNamesAll)); + badNamesB = false(size(fieldNamesAll)); + if ~isequal(fieldnamesA, fieldnamesB) + for i = 1:numel(fieldNamesAll) + if ~any(matches(fieldnamesB, fieldNamesAll{i})) + badNamesA(i) = true; + local_addmsg(level, ['.' fieldNamesAll{i} ' is not in the second structure']); + end + if ~any(matches(fieldnamesA, fieldNamesAll{i})) + badNamesB(i) = true; + local_addmsg(level, ['.' fieldNamesAll{i} ' is not in the first structure']); + end + end + else + local_addmsg(level, 'the order of the fields are different'); + end + + %level = level + 1; +% now go through the fieldNames that are shared. + sharedFieldNames = fieldNamesAll(~badNamesA & ~badNamesB); + for i = 1:numel(sharedFieldNames) + f = sharedFieldNames{i}; + if ~strcmp(class(a.(f)),class(b.(f))) + local_addmsg(level, ['.' f ' does not have the same class']); + else + switch class(a.(f)) + case 'struct' + local_addmsg(level, ['.' f ]); + local_compare(a.(f),b.(f),level+1) + otherwise + aData = a.(f); + bData = b.(f); + isSizeOK = isequal(size(a.(f)),size(b.(f))); + isNumelOK = isequal(numel(a.(f)),numel(b.(f))); + isContentOK = false; + if isNumelOK + isContentOK = isequaln(a.(f)(:),b.(f)(:)); + end + if isSizeOK && isContentOK + % do nothing - all good + local_addmsg(level, ['.' f ' - correct']); + elseif ~isContentOK + local_addmsg(level, ['.' f ' - &&&ERROR&&& has different CONTENT']); + else + local_addmsg(level, ['.' f ' - &&&ERROR&&& has different DIMENSIONS but similar content']); + end + end + end + end +end + +function msgAll = local_addmsg(level, msg) + persistent msgStore + + verbose = true; + switch msg + case 'reset' + msgStore = {}; + case 'retrieve' + msgAll = msgStore; + otherwise + indent = repmat(' ',1,level); + if verbose + disp([indent,msg]) + end + msgStore = [msgStore; [indent,msg]]; + end + msgAll = msgStore; +end diff --git a/convert_mapping_case.m b/convert_mapping_case.m new file mode 100644 index 0000000..d64df3a --- /dev/null +++ b/convert_mapping_case.m @@ -0,0 +1,458 @@ +function result = convert_mapping_case(inputPath, outputFile, varargin) +%CONVERT_MAPPING_CASE Headless CARTO or EnSiteX conversion for integration. +% +% result = convert_mapping_case(inputPath, outputFile, ... +% 'system', 'carto', ... +% 'maptoread', '2-LA', ... +% 'refchannel', 'CS1-CS2', ... +% 'ecgchannel', 'V1'); +% +% The MAT output is written only after input and OpenEP output validation. +% A JSON status file and text log are written on both success and failure. + +p = inputParser; +addRequired(p, 'inputPath', @(x) ischar(x) || isstring(x)); +addRequired(p, 'outputFile', @(x) ischar(x) || isstring(x)); +addParameter(p, 'system', 'auto', @(x) ischar(x) || isstring(x)); +addParameter(p, 'maptoread', '', @(x) ischar(x) || isstring(x)); +addParameter(p, 'modes', {}, @(x) ischar(x) || isstring(x) || iscellstr(x)); +addParameter(p, 'refchannel', '', @(x) ischar(x) || isstring(x)); +addParameter(p, 'ecgchannel', '', ... + @(x) ischar(x) || isstring(x) || iscellstr(x)); +addParameter(p, 'validationlevel', 'standard', ... + @(x) ischar(x) || isstring(x)); +addParameter(p, 'statusfilename', '', @(x) ischar(x) || isstring(x)); +addParameter(p, 'logfilename', '', @(x) ischar(x) || isstring(x)); +addParameter(p, 'progressfilename', '', @(x) ischar(x) || isstring(x)); +addParameter(p, 'throwonfailure', false, ... + @(x) islogical(x) && isscalar(x)); +parse(p, inputPath, outputFile, varargin{:}); +opts = p.Results; + +validationFolder = fullfile(fileparts(mfilename('fullpath')), 'validation'); +if exist('validate_mapping_input', 'file') ~= 2 + addpath(validationFolder); +end + +inputPath = char(inputPath); +outputFile = char(outputFile); +[outputFolder, outputName, outputExtension] = fileparts(outputFile); +if isempty(outputFolder) + outputFolder = pwd; + outputFile = fullfile(outputFolder, [outputName, outputExtension]); +end +if ~strcmpi(outputExtension, '.mat') + error('convert_mapping_case:OutputExtension', ... + 'outputFile must have a .mat extension.'); +end +ensureFolder(outputFolder); + +statusFile = char(opts.statusfilename); +if isempty(statusFile) + statusFile = fullfile(outputFolder, [outputName, '.status.json']); +end +logFile = char(opts.logfilename); +if isempty(logFile) + logFile = fullfile(outputFolder, [outputName, '.log.txt']); +end +progressFile = char(opts.progressfilename); +if isempty(progressFile) + progressFile = fullfile(outputFolder, [outputName, '.progress.json']); +end +ensureParentFolder(statusFile); +ensureParentFolder(logFile); +ensureParentFolder(progressFile); + +result = emptyResult(inputPath, outputFile, statusFile, logFile, progressFile); +totalStart = tic; +consoleLog = ''; +failureException = []; +cleanupObj = onCleanup(@() []); +writeProgress(progressFile, inputPath, outputFile, '', totalStart, ... + 'starting', 0, 'Starting conversion.'); + +try + sourceSystem = resolveSystem(inputPath, opts.system); + result.sourceSystem = sourceSystem; + writeProgress(progressFile, inputPath, outputFile, sourceSystem, ... + totalStart, 'preparing_input', 5, 'Preparing input files.'); + + preparationStart = tic; + if strcmp(sourceSystem, 'carto') + validateCartoSelections(opts); + [preparedInput, cleanupObj, archiveInfo] = prepareCartoInput(inputPath); + result.archive = archiveInfo; + else + preparedInput = inputPath; + end + result.timings.preparationSeconds = toc(preparationStart); + + writeProgress(progressFile, inputPath, outputFile, sourceSystem, ... + totalStart, 'validating_input', 15, 'Validating input files.'); + validationStart = tic; + result.inputValidation = validateInput( ... + preparedInput, sourceSystem, opts); + result.timings.inputValidationSeconds = toc(validationStart); + if result.inputValidation.numFail > 0 + error('convert_mapping_case:InputValidationFailed', ... + 'Input validation failed: %s', result.inputValidation.summary); + end + + lastwarn(''); + progressCallback = @(stage, fraction, message) writeProgress( ... + progressFile, inputPath, outputFile, sourceSystem, totalStart, ... + ['importing_', char(stage)], 20 + 65 * fraction, char(message)); %#ok + writeProgress(progressFile, inputPath, outputFile, sourceSystem, ... + totalStart, 'importing', 20, 'Importing mapping data.'); + importStart = tic; + [consoleLog, payload, importException] = evalc( ... + 'invokeImporter(preparedInput, sourceSystem, opts, progressCallback)'); + result.timings.importSeconds = toc(importStart); + [warningMessage, warningId] = lastwarn(); + result.runtimeWarning = struct( ... + 'identifier', warningId, 'message', warningMessage); + if ~isempty(importException) + throw(importException); + end + + writeProgress(progressFile, inputPath, outputFile, sourceSystem, ... + totalStart, 'validating_output', 88, 'Validating OpenEP output.'); + validationStart = tic; + result.outputValidation = validate_mapping_input( ... + payload.value, payload.validationMode); + result.timings.outputValidationSeconds = toc(validationStart); + if result.outputValidation.numFail > 0 + error('convert_mapping_case:OutputValidationFailed', ... + 'OpenEP output validation failed: %s', ... + result.outputValidation.summary); + end + + writeProgress(progressFile, inputPath, outputFile, sourceSystem, ... + totalStart, 'saving_output', 95, 'Publishing MAT output.'); + saveStart = tic; + savePayloadAtomically(payload, outputFile); + result.timings.saveSeconds = toc(saveStart); + result.outputPublished = true; + result.success = true; + if result.inputValidation.numWarning > 0 || ... + result.outputValidation.numWarning > 0 || ... + ~isempty(result.runtimeWarning.message) + result.status = 'warning'; + else + result.status = 'success'; + end +catch ME + failureException = ME; + result.success = false; + result.status = 'failure'; + result.error = exceptionAsStruct(ME); + writeProgress(progressFile, inputPath, outputFile, result.sourceSystem, ... + totalStart, 'failed', 100, ME.message); +end + +try + delete(cleanupObj); +catch cleanupException + result.cleanupWarning = struct( ... + 'identifier', cleanupException.identifier, ... + 'message', cleanupException.message); + if result.success + result.status = 'warning'; + end +end +result.finishedAt = timestampNow(); +result.timings.totalSeconds = toc(totalStart); +writeTextAtomically(logFile, formatLog(result, consoleLog)); +writeJsonAtomically(statusFile, result); +deleteIfPresent(progressFile); + +if ~result.success && opts.throwonfailure + throw(failureException); +end +end + +function result = emptyResult( ... + inputPath, outputFile, statusFile, logFile, progressFile) +result = struct(); +result.schemaName = 'OpenEP mapping conversion result'; +result.schemaVersion = '1.0'; +result.success = false; +result.status = 'failure'; +result.sourceSystem = ''; +result.inputPath = inputPath; +result.outputFile = outputFile; +result.outputPublished = false; +result.statusFile = statusFile; +result.logFile = logFile; +result.progressFile = progressFile; +result.startedAt = timestampNow(); +result.finishedAt = ''; +result.archive = struct(); +result.inputValidation = struct(); +result.outputValidation = struct(); +result.runtimeWarning = struct('identifier', '', 'message', ''); +result.cleanupWarning = struct('identifier', '', 'message', ''); +result.error = struct('identifier', '', 'message', '', 'stack', []); +result.timings = struct( ... + 'preparationSeconds', 0, ... + 'inputValidationSeconds', 0, ... + 'importSeconds', 0, ... + 'outputValidationSeconds', 0, ... + 'saveSeconds', 0, ... + 'totalSeconds', 0); +end + +function sourceSystem = resolveSystem(inputPath, requestedSystem) +sourceSystem = lower(strtrim(char(requestedSystem))); +if strcmp(sourceSystem, 'ensite') + sourceSystem = 'ensitex'; +end +if ~strcmp(sourceSystem, 'auto') + if ~any(strcmp(sourceSystem, {'carto', 'ensitex'})) + error('convert_mapping_case:UnknownSystem', ... + 'system must be auto, carto or ensitex.'); + end + return +end + +if isfile(inputPath) + [~, ~, ext] = fileparts(inputPath); + if any(strcmpi(ext, {'.zip', '.xml'})) + sourceSystem = 'carto'; + return + end +elseif isfolder(inputPath) + if ~isempty(dir(fullfile(inputPath, '**', 'Contact_Mapping_Model.xml'))) + sourceSystem = 'ensitex'; + return + end + if ~isempty(dir(fullfile(inputPath, '*.mesh'))) + sourceSystem = 'carto'; + return + end +end + +error('convert_mapping_case:SystemDetectionFailed', ... + 'Could not identify input as CARTO or EnSiteX: %s', inputPath); +end + +function validateCartoSelections(opts) +if isempty(opts.maptoread) || isempty(opts.refchannel) || isempty(opts.ecgchannel) + error('convert_mapping_case:CartoSelectionsRequired', ... + ['Headless CARTO conversion requires maptoread, refchannel ', ... + 'and ecgchannel.']); +end +end + +function [studyXml, cleanupObj, archiveInfo] = prepareCartoInput(inputPath) +[~, ~, ext] = fileparts(inputPath); +if isfile(inputPath) && strcmpi(ext, '.xml') + studyXml = inputPath; + cleanupObj = onCleanup(@() []); + archiveInfo = struct(); + return +end + +[caseFolder, cleanupObj, archiveInfo] = prepare_carto_case(inputPath); +studyXml = findCartoStudyXml(caseFolder); +end + +function studyXml = findCartoStudyXml(caseFolder) +xmlFiles = dir(fullfile(caseFolder, '*.xml')); +names = {xmlFiles.name}; +isStudy = ~startsWith(names, '.') & ... + ~contains(names, 'Point_Export') & ... + ~contains(names, 'Points_Export'); +xmlFiles = xmlFiles(isStudy); +if numel(xmlFiles) ~= 1 + error('convert_mapping_case:CartoStudyXml', ... + 'Expected one CARTO study XML in %s, found %d.', ... + caseFolder, numel(xmlFiles)); +end +studyXml = fullfile(xmlFiles(1).folder, xmlFiles(1).name); +end + +function report = validateInput(preparedInput, sourceSystem, opts) +if strcmp(sourceSystem, 'carto') + report = validate_mapping_input(fileparts(preparedInput), ... + 'carto_openep', ... + 'mapToRead', opts.maptoread, ... + 'refChannel', opts.refchannel, ... + 'validationLevel', opts.validationlevel); +else + report = validate_mapping_input(preparedInput, ... + 'ensitex_openep', ... + 'mapToRead', opts.maptoread, ... + 'validationLevel', opts.validationlevel); +end +end + +function [payload, caughtException] = invokeImporter( ... + preparedInput, sourceSystem, opts, progressCallback) +payload = struct('variableName', '', 'validationMode', '', 'value', struct()); +caughtException = []; +try + if strcmp(sourceSystem, 'carto') + userdata = importcarto_mem(preparedInput, ... + 'maptoread', opts.maptoread, ... + 'refchannel', opts.refchannel, ... + 'ecgchannel', opts.ecgchannel, ... + 'progresscallback', progressCallback, ... + 'verbose', false); + payload.variableName = 'userdata'; + payload.validationMode = 'openep_userdata'; + payload.value = userdata; + else + openepCase = importensitex_case(preparedInput, ... + 'maptoread', opts.maptoread, ... + 'modes', opts.modes, ... + 'progresscallback', progressCallback, ... + 'showprogress', false); + payload.variableName = 'openepCase'; + payload.validationMode = 'openep_case'; + payload.value = openepCase; + end +catch ME + caughtException = ME; +end +end + +function savePayloadAtomically(payload, outputFile) +outputFolder = fileparts(outputFile); +temporaryFile = [tempname(outputFolder), '.mat']; +cleanupObj = onCleanup(@() deleteIfPresent(temporaryFile)); + +if strcmp(payload.variableName, 'userdata') + userdata = payload.value; + save(temporaryFile, 'userdata', '-v7.3'); +elseif strcmp(payload.variableName, 'openepCase') + openepCase = payload.value; + save(temporaryFile, 'openepCase', '-v7.3'); +else + error('convert_mapping_case:UnknownPayload', ... + 'Importer returned an unsupported payload.'); +end + +[moved, message] = movefile(temporaryFile, outputFile, 'f'); +if ~moved + error('convert_mapping_case:OutputMoveFailed', ... + 'Could not finalize MAT output: %s', message); +end +delete(cleanupObj); +end + +function value = exceptionAsStruct(exception) +stack = struct('file', {}, 'name', {}, 'line', {}); +for i = 1:numel(exception.stack) + stack(i) = struct( ... + 'file', exception.stack(i).file, ... + 'name', exception.stack(i).name, ... + 'line', exception.stack(i).line); +end +value = struct( ... + 'identifier', exception.identifier, ... + 'message', exception.message, ... + 'stack', stack); +end + +function text = formatLog(result, consoleLog) +lines = { + sprintf('OpenEP conversion status: %s', upper(result.status)) + sprintf('System: %s', result.sourceSystem) + sprintf('Input: %s', result.inputPath) + sprintf('Output: %s', result.outputFile) + sprintf('Started: %s', result.startedAt) + sprintf('Finished: %s', result.finishedAt) + sprintf('Duration: %.3f seconds', result.timings.totalSeconds) + }; +if ~isempty(fieldnames(result.inputValidation)) + lines{end+1} = ['Input validation: ', result.inputValidation.summary]; +end +if ~isempty(fieldnames(result.outputValidation)) + lines{end+1} = ['Output validation: ', result.outputValidation.summary]; +end +if ~result.success + lines{end+1} = sprintf('Error [%s]: %s', ... + result.error.identifier, result.error.message); +end +lines{end+1} = ''; +lines{end+1} = 'Importer output:'; +lines{end+1} = consoleLog; +text = strjoin(lines, newline); +end + +function writeJsonAtomically(filePath, value) +text = jsonencode(value, 'PrettyPrint', true); +writeTextAtomically(filePath, text); +end + +function writeProgress(filePath, inputPath, outputFile, sourceSystem, ... + totalStart, stage, percent, message) +progress = struct(); +progress.schemaName = 'OpenEP mapping conversion progress'; +progress.schemaVersion = '1.0'; +if strcmp(stage, 'failed') + progress.state = 'failed'; +else + progress.state = 'running'; +end +progress.stage = char(stage); +progress.percent = max(0, min(100, round(double(percent), 1))); +progress.message = char(message); +progress.sourceSystem = char(sourceSystem); +progress.inputPath = inputPath; +progress.outputFile = outputFile; +progress.updatedAt = timestampNow(); +progress.elapsedSeconds = toc(totalStart); +writeJsonAtomically(filePath, progress); +end + +function writeTextAtomically(filePath, text) +folder = fileparts(filePath); +temporaryFile = tempname(folder); +cleanupObj = onCleanup(@() deleteIfPresent(temporaryFile)); +fid = fopen(temporaryFile, 'w'); +if fid == -1 + error('convert_mapping_case:StatusWriteFailed', ... + 'Could not create temporary output: %s', temporaryFile); +end +fileCleanup = onCleanup(@() fclose(fid)); +fprintf(fid, '%s', text); +delete(fileCleanup); + +[moved, message] = movefile(temporaryFile, filePath, 'f'); +if ~moved + error('convert_mapping_case:StatusMoveFailed', ... + 'Could not finalize %s: %s', filePath, message); +end +delete(cleanupObj); +end + +function ensureParentFolder(filePath) +folder = fileparts(filePath); +if ~isempty(folder) + ensureFolder(folder); +end +end + +function ensureFolder(folder) +if ~isfolder(folder) + [created, message] = mkdir(folder); + if ~created + error('convert_mapping_case:CreateFolderFailed', ... + 'Could not create output folder %s: %s', folder, message); + end +end +end + +function deleteIfPresent(filePath) +if isfile(filePath) + delete(filePath); +end +end + +function value = timestampNow() +value = char(datetime('now', ... + 'TimeZone', 'UTC', ... + 'Format', 'yyyy-MM-dd''T''HH:mm:ss.SSSXXX')); +end diff --git a/decrementUnipoleName.m b/decrementUnipoleName.m new file mode 100644 index 0000000..ef517ad --- /dev/null +++ b/decrementUnipoleName.m @@ -0,0 +1,7 @@ +function uni2name = decrementUnipoleName(uni1name) +%increment the unipole name by 1 to get the second unipole (this assumes +%that the second unipole is always the 'first + 1' +pattern = '(?\S*\D)(?\d*)'; +result = regexpi(uni1name,pattern,'names'); +uni2name = [result.name, num2str(str2double(result.number)-1)]; +end \ No newline at end of file diff --git a/docs/tre_importer_integration.md b/docs/tre_importer_integration.md new file mode 100644 index 0000000..1396960 --- /dev/null +++ b/docs/tre_importer_integration.md @@ -0,0 +1,260 @@ +# TRE Mapping Data Conversion + +## Purpose + +`convert_mapping_case` is the supported headless entry point for converting +CARTO and EnSiteX exports into OpenEP MAT files. It is intended for automated +execution where no MATLAB dialogs or progress windows are available. + +The function: + +1. prepares and validates the source export; +2. runs the appropriate importer without GUI interaction; +3. validates the imported OpenEP structure; +4. atomically publishes the MAT file; +5. writes JSON status and a text execution log. + +An unsuccessful conversion does not replace an existing MAT output with a +partial file. + +For a TRE service, the recommended pattern is to run with the default +`throwonfailure=false`, then read `case.status.json` to determine the final +outcome. Use `throwonfailure=true` only when the surrounding batch system needs +MATLAB to exit nonzero on conversion failure. + +## CARTO + +CARTO input may be an extracted export folder, its study XML, or a ZIP archive. +ZIP archives are inspected before extraction. On Linux, extraction uses +`/dev/shm` when sufficient storage and memory are available, then falls back to +the system temporary directory. Temporary files are removed after conversion. + +Headless CARTO conversion requires explicit map, reference and ECG channels: + +```matlab +addpath('/path/to/openep-core'); + +result = convert_mapping_case( ... + '/input/carto-export.zip', ... + '/output/case.mat', ... + 'system', 'carto', ... + 'maptoread', '2-LA', ... + 'refchannel', 'CS1-CS2', ... + 'ecgchannel', 'V1'); +``` + +The MAT file contains a variable named `userdata`. + +## EnSiteX + +An EnSiteX study may contain separate exports for bipolar, unipolar and +omnipolar recordings. The converter discovers recording modes from file +contents and stores the selected modes as independent datasets: + +```matlab +addpath('/path/to/openep-core'); + +result = convert_mapping_case( ... + '/input/ensitex-study', ... + '/output/case.mat', ... + 'system', 'ensitex', ... + 'maptoread', 'VoXel SR 1 ENDO', ... + 'modes', {'bi', 'uni', 'omni'}); +``` + +The MAT file contains `openepCase`. Each entry in +`openepCase.datasets` contains one `userdata` structure and its recording mode, +source folder and detection evidence. Use: + +```matlab +userdata = select_openep_dataset(openepCase, 'omni'); +``` + +## Output Contract + +By default, conversion produces three files: + +```text +case.mat +case.status.json +case.log.txt +``` + +`case.mat` is written only after input validation, import and output validation +have completed. If conversion fails, the previous MAT file at the same path is +not overwritten by a partial result. + +While conversion is running, it also maintains: + +```text +case.progress.json +``` + +This file is atomically replaced as work advances. It contains the current +stage, percentage, message, update timestamp and elapsed time. CARTO reports +point, annotation, electrogram and force-data progress. EnSiteX reports +discovery and completion of each requested recording mode. The progress file +is removed only after the final status JSON has been written successfully. If +MATLAB is terminated unexpectedly, a stale progress file remains as evidence +of the interrupted job. + +Progress can be watched from a shell: + +```bash +watch -n 2 cat /output/case.progress.json +``` + +The status JSON document is the machine-readable final result. Important fields +are: + +| Field | Meaning | +|---|---| +| `schemaName` | Status document type | +| `schemaVersion` | Status document schema version | +| `success` | `true` only when import, output validation and MAT publication completed | +| `status` | `success`, `warning`, or `failure` | +| `sourceSystem` | Detected or requested source system: `carto` or `ensitex` | +| `inputPath` | Source path used for conversion | +| `outputFile` | Final MAT path | +| `outputPublished` | Whether this conversion published a new MAT file | +| `statusFile` | Path to this status JSON file | +| `logFile` | Path to the text execution log | +| `progressFile` | Path used for transient progress updates | +| `startedAt`, `finishedAt` | UTC timestamps | +| `inputValidation` | Input checks and summary | +| `outputValidation` | OpenEP structure checks and summary | +| `runtimeWarning` | Last MATLAB importer warning, when present | +| `error` | Exception identifier, message and stack on failure | +| `timings` | Preparation, validation, import, save and total seconds | + +All detailed validation checks have a stable `id`, a `level`, a stage, a +message and the related file. A successful conversion can have status +`warning`; the MAT output is still valid, but the warnings should be shown to +the user or retained for review. + +The text log contains importer console output and warnings that are not part of +the structured validator. Paths and importer messages may contain study +identifiers, so status and log files must remain inside the TRE. + +Custom status, progress and log locations can be supplied with +`statusfilename`, `progressfilename` and `logfilename`. + +Example successful status fragment: + +```json +{ + "success": true, + "status": "success", + "sourceSystem": "carto", + "outputPublished": true, + "outputFile": "/output/case.mat", + "inputValidation": { + "summary": "PASS: 0 fail, 0 warning, 12 pass, 1 info" + }, + "outputValidation": { + "summary": "PASS: 0 fail, 0 warning, 18 pass, 0 info" + } +} +``` + +Example failure status fragment: + +```json +{ + "success": false, + "status": "failure", + "outputPublished": false, + "error": { + "identifier": "convert_mapping_case:InputValidationFailed", + "message": "Input validation failed: FAIL: 1 fail, 0 warning, 8 pass, 1 info" + } +} +``` + +Example progress fragment while a job is running: + +```json +{ + "state": "running", + "stage": "importing_electrograms", + "percent": 46.2, + "message": "Reading CARTO electrograms: 203 of 711.", + "elapsedSeconds": 128.4 +} +``` + +## Batch Failure Behaviour + +For a service that reads the JSON result, use the default +`throwonfailure=false`. The MATLAB call returns a result and writes diagnostics +even when conversion fails. + +For a shell job that should exit nonzero: + +```matlab +convert_mapping_case(inputPath, outputFile, ... + 'system', sourceSystem, ... + 'maptoread', mapName, ... + 'throwonfailure', true); +``` + +The status and log files are written before the exception is rethrown. + +## Validation Levels + +`validationlevel` accepts: + +- `quick`: required files, core headers and lightweight structure checks. This + mode avoids reading large waveform files in full where possible. +- `standard`: `quick` checks plus representative numeric and consistency checks + for coordinates, LAT, voltage-like values and selected waveform files. +- `full`: all currently implemented import-relevant checks. This mode can be + slower and is intended for local investigation or regression testing rather + than every routine conversion. + +Every converted OpenEP output receives structural and numeric validation +regardless of the input validation level. + +Validation is format and importer validation. It checks that the exported files +can be interpreted consistently and that the produced OpenEP structures are +well formed. It is not a substitute for scientific or clinical review of the +mapping data. + +## Known Limitations + +- EnSiteX ZIP archives are not extracted by this workflow. EnSiteX input should + be an extracted study folder. +- CARTO ZIP archives can be large. Extraction requires enough temporary storage + for the uncompressed archive plus a safety margin. +- Linux systems can use `/dev/shm` for fast CARTO extraction when enough memory + is available. Other platforms fall back to the system temporary directory. +- Full-case tests are opt-in and require local test exports that are not stored + in the repository. +- The text log captures MATLAB importer output verbatim, including MATLAB + warning backtraces. The JSON status file should be treated as the stable + machine-readable contract. + +## Verification + +Run the normal unit and regression suite: + +```matlab +run('run_validation_tests.m') +``` + +Run the complete CARTO integration test: + +```bash +RUN_FULL_CARTO_IMPORT_TESTS=1 matlab -batch \ + "run('run_validation_tests.m')" +``` + +Run the complete EnSiteX multi-dataset integration test: + +```bash +RUN_FULL_IMPORTER_SMOKE_TESTS=1 matlab -batch \ + "run('run_validation_tests.m')" +``` + +Full-case tests are opt-in because they require local clinical exports, +substantial storage and several minutes to complete. diff --git a/drawMap.m b/drawMap.m index 51364e1..44d875b 100644 --- a/drawMap.m +++ b/drawMap.m @@ -137,6 +137,7 @@ % Call colorShell if ~all(isnan(DATA)) + % this may need to be colorShell(hSurf, userdata.surface.triRep.X, DATA, DISTANCETHRESH ... colorShell(hSurf, userdata.electric.egmSurfX, DATA, DISTANCETHRESH ... , 'showcolorbar', 'show' ... , 'coloraxis', [t_min t_max] ... diff --git a/examples/generate_tre_demo_outputs.m b/examples/generate_tre_demo_outputs.m new file mode 100644 index 0000000..50b16b0 --- /dev/null +++ b/examples/generate_tre_demo_outputs.m @@ -0,0 +1,49 @@ +%% Generate CARTO and EnSiteX demonstration outputs + +openepRoot = fileparts(fileparts(mfilename('fullpath'))); +projectRoot = fileparts(openepRoot); +outputRoot = fullfile(projectRoot, 'demo_outputs'); + +addpath(openepRoot); + +if ~isfolder(outputRoot) + mkdir(outputRoot); +end + +cartoInput = fullfile( ... + projectRoot, ... + 'full_cases', ... + 'Carto', ... + 'ReCETT-AF', ... + 'Study1-Williams-Edinburgh', ... + 'Export_PAF-02_28_2023-14-19-48.zip'); + +cartoResult = convert_mapping_case( ... + cartoInput, ... + fullfile(outputRoot, 'carto_study1_2LA.mat'), ... + 'system', 'carto', ... + 'maptoread', '2-LA', ... + 'refchannel', 'CS1-CS2', ... + 'ecgchannel', 'V1'); + +ensiteInput = fullfile( ... + projectRoot, ... + 'full_cases', ... + 'EnsiteX', ... + 'Brussels', ... + 'Study3-Gharaviri-Brussels'); + +ensiteResult = convert_mapping_case( ... + ensiteInput, ... + fullfile(outputRoot, 'ensitex_study3_all.mat'), ... + 'system', 'ensitex', ... + 'maptoread', 'VoXel SR 1 ENDO', ... + 'modes', {'bi', 'uni', 'omni'}); + +disp(cartoResult); +disp(ensiteResult); + +assert(cartoResult.success, cartoResult.error.message); +assert(ensiteResult.success, ensiteResult.error.message); + +fprintf('\nDemo outputs written to:\n%s\n', outputRoot); diff --git a/examples/import_ensitex_study3.m b/examples/import_ensitex_study3.m new file mode 100644 index 0000000..d077b50 --- /dev/null +++ b/examples/import_ensitex_study3.m @@ -0,0 +1,45 @@ +function [openepCase, outputFile] = import_ensitex_study3(caseRoot, outputRoot, egmTypes) +%IMPORT_ENSITEX_STUDY3 Import Study3 into one multi-dataset MAT file. +% +% openepCase = import_ensitex_study3(caseRoot, outputRoot) +% openepCase = import_ensitex_study3(caseRoot, outputRoot, {'bi', 'uni'}) + +repoRoot = fileparts(fileparts(mfilename('fullpath'))); +addpath(repoRoot); +addpath(fullfile(repoRoot, 'validation')); + +if nargin < 1 || isempty(caseRoot) + caseRoot = getenv('OPENEP_FULL_ENSITEX_CASE'); + if isempty(caseRoot) + caseRoot = fullfile(fileparts(repoRoot), 'full_cases', 'EnsiteX', ... + 'Brussels', 'Study3-Gharaviri-Brussels'); + end +end +if nargin < 2 || isempty(outputRoot) + outputRoot = fullfile(pwd, 'openep_imports'); +end +if nargin < 3 || isempty(egmTypes) + egmTypes = {'bi', 'omni', 'uni'}; +end + +caseRoot = char(caseRoot); +outputRoot = char(outputRoot); +egmTypes = cellstr(egmTypes); + +assert(isfolder(caseRoot), 'EnSiteX Study3 folder not found: %s', caseRoot); +if ~isfolder(outputRoot) + mkdir(outputRoot); +end + +outputFile = fullfile(outputRoot, 'Study3_Gharaviri_Brussels_all.mat'); +[openepCase, outputFile] = importensitex_case( ... + caseRoot, ... + 'maptoread', 'VoXel SR 1 ENDO', ... + 'modes', egmTypes, ... + 'showprogress', false, ... + 'savefilename', outputFile); + +report = validate_mapping_input(openepCase, 'openep_case'); +fprintf('Study3 case import: %s\nSaved to: %s\n', report.summary, outputFile); +assert(report.numFail == 0, 'Imported Study3 case failed validation.'); +end diff --git a/getArea.m b/getArea.m index 333fd46..e4387c3 100644 --- a/getArea.m +++ b/getArea.m @@ -9,10 +9,14 @@ % % GETAREA accepts the following parameter-value pairs % 'method' {'nofill'}|'fill' +% 'mapped' {'all'}|'onlymapped' % % GETAREA Returns the surface area of an anatomical model. The anatomical % model can first be closed (filling any holes) by specifying the 'method', -% 'fill' ('nofill' by default). +% 'fill' ('nofill' by default). Using the 'mapped' parameter specify +% whether to return the total area or only the area which has been mapped. +% The area which has been mapped is identified by having non-NaN values in +% userdata.surface.act_bip(:,2). % % Author: Steven Williams (2020) (Copyright) % SPDX-License-Identifier: Apache-2.0 @@ -30,20 +34,44 @@ nStandardArgs = 1; % UPDATE VALUE method = 'nofill'; +mapped = 'all'; if nargin > nStandardArgs for i = 1:2:nargin-1 switch varargin{i} case 'method' method = varargin{i+1}; + case 'mapped' + mapped = varargin{i+1}; end end end switch method case "nofill" - area = sum(real(triarea(getMesh(userdata)))/100); + tr = getMesh(userdata); + area = sum(real(triarea(tr))/100); case "fill" tr = getClosedSurface(userdata); area = sum(real(triarea(tr))/100); end + +switch mapped + case 'onlymapped' + % subtract the area of the subset of triangles which has not been mapped + sIFace = trVertToFaceData(tr, userdata.surface.act_bip(:,2)); + iTri = zeros(size(sIFace)); + iTri(isnan(sIFace)) = 1; + triangleInclude = tr.Triangulation; + triangleInclude(~logical(iTri),:) = []; + if ~isempty(triangleInclude) + tr2 = TriRep(double(triangleInclude), tr.X); + areas2 = sum(real(triarea(tr2))/100); + else + areas2 = 0; + end + area = area - areas2; + case 'all' + % make no modifications end + +end \ No newline at end of file diff --git a/getEarliestActivationSite.m b/getEarliestActivationSite.m index 49910b6..c39a6ed 100644 --- a/getEarliestActivationSite.m +++ b/getEarliestActivationSite.m @@ -99,7 +99,7 @@ %new version iP = getMappingPointsWithinWoI(userdata); -mapAnnot = userdata.electric.annotations.mapAnnot; +mapAnnot = double(userdata.electric.annotations.mapAnnot); mapAnnot(~iP) = NaN; referenceAnnot = userdata.electric.annotations.referenceAnnot; tr = getMesh(userdata); diff --git a/getLatestActivationSite.m b/getLatestActivationSite.m index e9a3356..97401fa 100644 --- a/getLatestActivationSite.m +++ b/getLatestActivationSite.m @@ -99,7 +99,7 @@ %new version iP = getMappingPointsWithinWoI(userdata); -mapAnnot = userdata.electric.annotations.mapAnnot; +mapAnnot = double(userdata.electric.annotations.mapAnnot); mapAnnot(~iP) = NaN; referenceAnnot = userdata.electric.annotations.referenceAnnot; tr = getMesh(userdata); diff --git a/import_ensite_x.m b/import_ensite_x.m new file mode 100644 index 0000000..3887d5b --- /dev/null +++ b/import_ensite_x.m @@ -0,0 +1,522 @@ +function [userdata, matFileFullPath] = import_ensite_x(varargin) +% IMPORT_ENSITEX is used to import an EnsiteX case. +% +% Usage: +% userdata = import_ensitex() +% userdata = import_ensitex(directory) +% userdata = import_ensitex( ... , Name,Value ... ) +% +% Where: +% directory - an absolute folder path (if empty, user will be asked) +% userdata - an OpenEP data structure +% +% Author: Steven Williams (2024) +% Modifications - +% +% Info on Code Testing: +% --------------------------------------------------------------- +% test code +% --------------------------------------------------------------- +% +% --------------------------------------------------------------- +% code +% --------------------------------------------------------------- + +% Parse input data +persistent saveDir homeDir +if isempty(saveDir) || ~ischar(saveDir) + saveDir = userpath(); +end +if ~isfolder(saveDir) + saveDir = userpath(); +end + +if isempty(homeDir) + homeDir = userpath(); +end +if ~isfolder(homeDir) + homeDir = saveDir; +end + +userdata = []; + +if nargin >= 1 + userinput = varargin{1}; +else + dialog_title = 'Select the EnsiteX Contact_Mapping_Model file, or a .mat file.'; + filterSpec = {'*.xml;*.mat', 'Appropriate files (*.xml;*.mat)' ; '*.xml','XML (*.xml)' ; '*.mat','Matlab (*.mat)' ; '*.*','All files (*.*)'}; + if ~ispc() + uiwait(msgbox(dialog_title,'modal')) + end + [filename,pathname] = uigetfile(filterSpec, dialog_title, homeDir); + userinput = fullfile(pathname, filename); + if filename == 0 + return + else + homeDir = pathname; + end +end + +if strcmpi(userinput((end-3):end),'.zip') + %then we need to unzip the folder + error('IMPORT_ENSITEX: ZIP files are no longer supported - unzip externally to Matlab.'); + return %#ok +elseif strcmpi(userinput((end-3):end),'.mat') + s = load(userinput); + userdata = s.userdata; + return +else + studyDir = fileparts(userinput); + homeDir = studyDir; +end + +% parse command line input +nStandardArgs = 1; +bipoleType = 'along'; +channelRef_cli = ''; +channelECG_cli = ''; +saveFileName_cli = ''; +if nargin > nStandardArgs + for i = nStandardArgs+1:2:nargin + switch lower(varargin{i}) + case 'bipoletype' + bipoleType_cli = varargin{i+1}; + case 'refchannel' + channelRef_cli = varargin{i+1}; + if ischar(channelRef_cli) + channelRef_cli = {channelRef_cli}; + end + case 'ecgchannel' + channelECG_cli = varargin{i+1}; + if ischar(channelECG_cli) + channelECG_cli = {channelECG_cli}; + end + case 'savefilename' + saveFileName_cli = varargin{i+1}; + otherwise + error('OPENEP/IMPORT_ENSITEX: Unrecognized input.') + end + end +end + +% Create an empty OpenEP data structure +userdata = openep_createuserdata; + +% General data +userdata.systemName = 'ensitex'; +userdata.notes{1} = [date() ': Created']; +userdata.ensiteXFolder = studyDir; + +% Load the model groups +info = loadprecision_modelgroups([studyDir filesep() 'Contact_Mapping_Model.xml']); + +% assign the geometry data +if isfield(info.dxgeo, 'triangles') && isfield(info.dxgeo, 'vertices') + TRI = info.dxgeo.triangles; + X = info.dxgeo.vertices(:,1); + Y = info.dxgeo.vertices(:,2); + Z = info.dxgeo.vertices(:,3); + surfaceTriRep = TriRep(TRI, X, Y, Z); + userdata = setMesh(userdata, surfaceTriRep); + FF = freeBoundary(surfaceTriRep); + isVertexAtRim = false(size(userdata.surface.triRep.X,1),1); + if ~isempty(FF) + isVertexAtRim(FF(:,1)) = true; + end + userdata.surface.isVertexAtRim = isVertexAtRim; +end + +% deal with labels, for the test case this is 0, 1 or 2 +allLabels = info.dxgeo.surface_of_origin; +labels = unique(allLabels); +cMap = colormap(parula(numel(labels))); +faceColors = trFaceToVertData(getMesh(userdata, 'type', 'trirep'), allLabels); +hSurf = drawMap(userdata, 'type', 'none'); +colorShell(hSurf, [X Y Z], faceColors, Inf ... + , 'showcolorbar', 'show' ... + , 'coloraxis', [0 numel(labels)] ... + , 'interpolation', 'off' ... + , 'usrColorMap', cMap ... + , 'datatype', 'labels' ... + ); + +% next load the map file and the wave files into memory using loadensitex_dxldata.m +cMapDir = [studyDir filesep() 'Contact_Mapping']; +mappingFiles = nameFiles(cMapDir, 'showhiddenfiles', false, 'extension', '.csv'); +for i = 1:numel(mappingFiles) + [info, varnames, data] = loadensitex_dxldata([studyDir filesep() 'Contact_Mapping' filesep() mappingFiles{i}]); + dataFile{i}.info = info; %#ok<*AGROW> + dataFile{i}.varnames = varnames; + dataFile{i}.data = data; +end + +% save('/Users/steven/Desktop/dataFiles.mat', 'dataFile'); +% s = load('/Users/steven/Desktop/dataFiles.mat'); +% dataFile = s.dataFile; + +% find the map name +iContactMapFile = local_findFile('Map_CV_omni.csv', dataFile); +map.name = dataFile{iContactMapFile}.info.mapName; +map.type = dataFile{iContactMapFile}.info.mapType; +map.study = dataFile{iContactMapFile}.info.study; + +% then map the data into the OpenEP data format + +% Deal wtih the infoMapping dictionary +infoMapping = { ... + 'electric.sampleFrequency' 'Wave_rov.csv' 'sampleFreq' ... + ; ... + }; +for i = 1:size(infoMapping) + fieldNames = strsplit(infoMapping{i,1}, '.'); + thisFileName = infoMapping{i,2}; + fileInd = local_findFile(thisFileName, dataFile); + thisFieldName = infoMapping{i,3}; + switch numel(fieldNames) + case 1 + userdata.(fieldNames{1}) = dataFile{fileInd}.info.(thisFieldName); + case 2 + userdata.(fieldNames{1}).(fieldNames{2}) = dataFile{fileInd}.info.(thisFieldName); + case 3 + userdata.(fieldNames{1}).(fieldNames{2}).(fieldNames{3}) = dataFile{fileInd}.info.(thisFieldName); + case 4 + userdata.(fieldNames{1}).(fieldNames{2}).(fieldNames{3}).(fieldNames{4}) = dataFile{fileInd}.info.(thisFieldName); + otherwise + error('OPENEP/IMPORT_ENSITEX: Code not yet implemented for more than 4 sub fields') + end +end + +% I think we have to decide whether to import an 'along' map or an 'across' +% map and these might need combining retrospectively for particular use +% cases, but could be considered as separate OpenEP data structures at +% import time. The default should possibly be to import to the 'along' as +% this is 'along the spline' and consistent with usual practice in EP, +% whereas 'across' may be influenced by HD grid geometry. + +% Deal wtih the dataMapping dictionary +switch lower(bipoleType) + case 'along' + wave_bi = 'Wave_bi_along.csv'; + wave_uni = 'Wave_uni_along.csv'; + secondElectrode = 'Uni_AlongX,Uni_AlongY,Uni_AlongZ'; + case 'across' + wave_bi = 'Wave_bi_across.csv'; + wave_uni = 'Wave_uni_acrpss.csv'; + secondElectrode = 'Uni_AcrossX,Uni_AcrossY,Uni_AcrossZ'; + +end + +% Ask, 'which signal is a good reference' +referenceFile = 'Wave_refs.csv'; +iRefFile = local_findFile(referenceFile, dataFile); +refNames = unique(dataFile{iRefFile}.data(:,1)); +refNames = regexp(refNames, '\s*(\w*\s*[a-zA-Z_0-9-]*)', 'tokens'); +for i = 1:numel(refNames) + newRefNames{i} = refNames{i}{1}{1}; +end +refNames = unique(newRefNames); +% ecgNames = refNames(strstartcmpi('ECG', refNames)); +% otherSignalNames = refNames(~strstartcmpi('ECG', refNames)); + +if isempty(channelRef_cli) + [kRef,ok] = listdlg( 'ListString', refNames ... + , 'SelectionMode', 'single' ... + , 'PromptString', 'Which signal is Ref?' ... + , 'ListSize',[300 300] ... + ); + if ~ok; return; end + channelRef_cli = refNames{kRef}; +else + kRef = find(strcmpi(channelRef_cli, refNames)); + if isempty(kRef) || numel(kRef)>1 + error(['OPENEP/IMPORT_ENSITEX: Unable to uniquely identify the specified reference channel: ' channelRef_cli]); + end +end +if isempty(channelECG_cli) + [kEcg,ok] = listdlg( 'ListString', refNames ... + , 'SelectionMode', 'multiple' ... + , 'PromptString', 'Which other signals should be downloaded with each point (typically one or more ECG signals)?' ... + , 'ListSize', [300 300] ... + ); + if ~ok; return; end + channelECG_cli = refNames(kEcg); +else + kEcg = zeros(1,numel(channelECG_cli)); + for i = 1:numel(channelECG_cli) + kEcg(i) = find(strcmpi(channelECG_cli{i}, refNames)); + if isempty(kEcg(i)) + error(['OPENEP/IMPORT_ENSITEX: Unable to uniquely identify the specified additional channel: ' channelECG_cli]); + end + end +end + +dataMapping = { ... + 'electric.tags' 'Map_CV_omni.csv' 'annot' ... + ; 'electric.names' 'Map_CV_omni.csv' '(Point #)' ... + ; 'electric.electrodeNames_bip' wave_bi 'Trace' ... + ; 'electric.egmX' 'Map_CV_omni.csv' 'roving x,roving y,roving z' ... + ; 'electric.egm' wave_bi 'signals' ... + ; 'electric.electrodeNames_uni' wave_uni 'Trace' ... + ; 'electric.egmUniX' 'Map_CV_omni.csv' ['Uni_CornerX,Uni_CornerY,Uni_CornerZ,' secondElectrode] ... + ; 'electric.egmUni' ['Wave_uni_corner.csv,' wave_uni] 'signals' ... + ; 'electric.egmRef' 'Wave_refs.csv' 'signals' ... % Ask, 'which signal is a good reference' + ; 'electric.ecg' 'Wave_refs.csv' 'signals' ... % Ask, 'which signal is a good ECG' + ; 'electric.annotations.woi' 'Map_CV_omni.csv' 'left curtain (ms),right curtain (ms)' ... % this is in ms relative to samples! + ; 'electric.annotations.referenceAnnot' 'Map_CV_omni.csv' 'Ref Tick' ... % this is in samples! + ; 'electric.annotations.mapAnnot' wave_bi 'rovTime (wave samples)' ... % this is _presumably_ in samples! + ; 'electric.voltages.bipolar' 'Map_CV_omni.csv' 'pp_Valong' ... + ; 'electric.voltages.unipolar' 'Map_CV_omni.csv' 'unipoleMaxPP' ... + ; 'electric.egmSurfX' 'Map_CV_omni.csv' 'surface x,surface y,surface z' ... + ; 'electric.barDirection' 'Map_CV_omni.csv' 'normal x,normal y,normal z' ... + ; 'electric.include' 'Map_CV_omni.csv' 'utilized' ... + }; + +% ; 'electric.impedances.time' '' '' ... % We do not seem to have impedance data +% ; 'electric.impedances.value' '' '' ... % We do not seem to have impedance data + +for i = 1:size(dataMapping,1) + + thisFileName = dataMapping{i,2}; + fileInd = local_findFile(thisFileName, dataFile); + + thisFieldName = dataMapping{i,3}; + + fieldInd = local_findField(thisFieldName, dataFile(fileInd)); + % parse the data + + switch dataMapping{i,1} + case 'electric.tags' + userdata.electric.tags = dataFile{fileInd}.data(:,fieldInd); + + case 'electric.names' + userdata.electric.names = dataFile{fileInd}.data(:,fieldInd); + + case 'electric.electrodeNames_bip' + userdata.electric.electrodeNames_bip = dataFile{fileInd}.data(:,fieldInd); + + case 'electric.egmX' + X = str2double(dataFile{fileInd}.data(:,fieldInd(1))); + Y = str2double(dataFile{fileInd}.data(:,fieldInd(2))); + Z = str2double(dataFile{fileInd}.data(:,fieldInd(3))); + userdata.electric.egmX = [X Y Z]; + + case 'electric.egm' + userdata.electric.egm = cell2mat(dataFile{fileInd}.data(:,fieldInd)); + + fGroupInd = local_findField('Freeze Grp #', dataFile(fileInd)); + freezeGroup = dataFile{fileInd}.data(:,fGroupInd); + + case 'electric.electrodeNames_uni' + userdata.electric.electrodeNames_uni = dataFile{fileInd}.data(:,fieldInd); + + case 'electric.egmUniX' + X = str2double(dataFile{fileInd}.data(:,fieldInd(1))); + Y = str2double(dataFile{fileInd}.data(:,fieldInd(2))); + Z = str2double(dataFile{fileInd}.data(:,fieldInd(3))); + userdata.electric.egmUniX(:,:,1) = [X Y Z]; + + X2 = str2double(dataFile{fileInd}.data(:,fieldInd(4))); + Y2 = str2double(dataFile{fileInd}.data(:,fieldInd(5))); + Z2 = str2double(dataFile{fileInd}.data(:,fieldInd(6))); + userdata.electric.egmUniX(:,:,2) = [X2 Y2 Z2]; + + case 'electric.egmUni' + userdata.electric.egmUni(:,:,1) = cell2mat(dataFile{fileInd(1)}.data(:,fieldInd(1))); + userdata.electric.egmUni(:,:,2) = cell2mat(dataFile{fileInd(2)}.data(:,fieldInd(2))); + + case 'electric.egmRef' + % find the trace names in the reference wave file + traceInd = local_findField('Trace', dataFile(fileInd)); + refTraceNames = dataFile{fileInd}.data(:,traceInd); + + % remove any leadng white space and redundant nested cells + refTraceNames = regexp(refTraceNames, '\s*(\w*\s*[a-zA-Z_0-9-]*)', 'tokens'); + for iPoint = 1:numel(refTraceNames) + temp{iPoint} = refTraceNames{iPoint}{1}{1}; %#ok + end + refTraceNames = temp'; + + % identify all the reference traces with the required name + iValidTrace = strcmpi(channelRef_cli, refTraceNames); + + % find the freeze group # for every reference trace + freezeGroupInd = local_findField('Freeze Grp #', dataFile(fileInd)); + refFreezeGroup = dataFile{fileInd}.data(:,freezeGroupInd); + + % convert freeze group strings to double + pointfG = sscanf(sprintf(' %s',freezeGroup{:}),'%f',[1,Inf]); % size is number of points + reffG = sscanf(sprintf(' %s',refFreezeGroup{:}),'%f',[1,Inf]); % size is number of reference waves + pointfG = pointfG'; + reffG = reffG'; + + % work out the indices into the reference wave file for every + % mapping point (based on the mapping point's freeze group and + % the chosen reference signal channel) + for iP = 1:numel(freezeGroup) % iP for index point - we iterate through every mapping point + requiredFreezeGroupNumber = pointfG(i); + tffG = (reffG==requiredFreezeGroupNumber) & iValidTrace; + freezeGroupTable(iP) = find(tffG); %#ok + end + freezeGroupTable = freezeGroupTable'; + + % store the reference signal + temp = dataFile{fileInd}.data(freezeGroupTable,fieldInd); + userdata.electric.egmRef = cell2mat(temp); + + % and store the reference egm name + userdata.electric.egmRefNames = channelRef_cli; + + case 'electric.ecg' + % find the trace names in the reference wave file + traceInd = local_findField('Trace', dataFile(fileInd)); + ecgTraceNames = dataFile{fileInd}.data(:,traceInd); + + % remove any leadng white space and redundant nested cells + ecgTraceNames = regexp(ecgTraceNames, '\s*(\w*\s*[a-zA-Z_0-9-]*)', 'tokens'); + temp = []; + for iPoint = 1:numel(ecgTraceNames) + temp{iPoint} = ecgTraceNames{iPoint}{1}{1}; %#ok + end + ecgTraceNames = temp'; + + % identify all the reference traces with the required name + iValidTrace = strcmpi(channelECG_cli{1}, ecgTraceNames); + iValidTrace = iValidTrace(:); + if numel(channelECG_cli) > 1 + for iEcg = 2:numel(channelECG_cli) + iValidTrace(:,iEcg) = strcmpi(channelECG_cli{iEcg}, ecgTraceNames); + end + end + + % find the freeze group # for every reference trace + freezeGroupInd = local_findField('Freeze Grp #', dataFile(fileInd)); + refFreezeGroup = dataFile{fileInd}.data(:,freezeGroupInd); + + % convert freeze group strings to double + pointfG = sscanf(sprintf(' %s',freezeGroup{:}),'%f',[1,Inf]); % size is number of points + reffG = sscanf(sprintf(' %s',refFreezeGroup{:}),'%f',[1,Inf]); % size is number of reference waves + pointfG = pointfG'; + reffG = reffG'; + + % work out the indices into the reference wave file for every + % mapping point (based on the mapping point's freeze group and + % the chosen reference signal channel) + freezeGroupTable = []; + for iChannel = 1:numel(channelECG_cli) + for iP = 1:numel(freezeGroup) % iP for index point - we iterate through every mapping point + requiredFreezeGroupNumber = pointfG(i); + tffG = (reffG==requiredFreezeGroupNumber) & iValidTrace(:,iChannel); + freezeGroupTable{iChannel}(iP) = find(tffG); %#ok + end + freezeGroupTable{iChannel} = freezeGroupTable{iChannel}(:); + end + + % store the additional/ECG signals + temp = dataFile{fileInd}.data(freezeGroupTable{1},fieldInd); + userdata.electric.egmRef(:,:) = cell2mat(temp); + if numel(channelECG_cli) > 1 + for iChannel = 2:numel(channelECG_cli) + temp = dataFile{fileInd}.data(freezeGroupTable{iChannel},fieldInd); + userdata.electric.ecg(:,:,iChannel) = cell2mat(temp); + end + end + + % and store the additional channel/ECG name(s) + userdata.electric.ecgNames = channelECG_cli; + + case 'electric.annotations.woi' + startWindow = str2double(dataFile{fileInd}.data(:,fieldInd(1))); + endWindow = str2double(dataFile{fileInd}.data(:,fieldInd(2))); + userdata.electric.annotations.woi = round([startWindow endWindow] ./ 1000 .* userdata.electric.sampleFrequency); + + case 'electric.annotations.referenceAnnot' + userdata.electric.annotations.referenceAnnot = str2double(dataFile{fileInd}.data(:,fieldInd)); + + case 'electric.annotations.mapAnnot' + userdata.electric.annotations.mapAnnot = round(str2double(dataFile{fileInd}.data(:,fieldInd)) ./ userdata.electric.sampleFrequency .* userdata.electric.sampleFrequency); + + case 'electric.voltages.bipolar' + userdata.electric.voltages.bipolar = str2double(dataFile{fileInd}.data(:,fieldInd)); + + case 'electric.voltages.unipolar' + userdata.electric.voltages.unipolar = str2double(dataFile{fileInd}.data(:,fieldInd)); + + case 'electric.impedances.time' + % TODO + + case 'electric.impedances.value' + % TODO + + case 'electric.egmSurfX' + X = str2double(dataFile{fileInd}.data(:,fieldInd(1))); + Y = str2double(dataFile{fileInd}.data(:,fieldInd(2))); + Z = str2double(dataFile{fileInd}.data(:,fieldInd(3))); + userdata.electric.egmSurfX = [X Y Z]; + + case 'electric.barDirection' + X = str2double(dataFile{fileInd}.data(:,fieldInd(1))); + Y = str2double(dataFile{fileInd}.data(:,fieldInd(2))); + Z = str2double(dataFile{fileInd}.data(:,fieldInd(3))); + userdata.electric.barDirection = [X Y Z]; + + case 'electric.include' + userdata.electric.include = str2double(dataFile{fileInd}.data(:,fieldInd)); + end +end + +% convert to single +userdata = doubleToSingle(userdata); + +% Encourage user to save the data +if ~isempty(saveFileName_cli) + save(saveFileName_cli, 'userdata'); + matFileFullPath = saveFileName_cli; +else + defaultName = [map.study '_' map.name]; + defaultName(isspace(defaultName)) = '_'; + originalDir = cd(); + matFileFullPath = fullfile(saveDir, defaultName); %default + cd(saveDir); + [filename,saveDir] = uiputfile('*.mat', 'Save the userdata to disc for future rapid access?',defaultName); + cd(originalDir); + if filename ~= 0 + save([saveDir filename], 'userdata','-v7'); %needed as sometimes >2GB + matFileFullPath = fullfile(saveDir, filename); + end +end + +% local helper functions + function iF = local_findFile(f, d) + % find the index into the cell array, d, of the filename f + for iD = 1:numel(d) + [~,n,e] = fileparts(d{iD}.info.filename); + allFileNames{iD} = [n e]; %#ok + end + requiredFiles = strsplit(f,','); + for iFile = 1:numel(requiredFiles) + iF(iFile) = find(strstartcmpi(requiredFiles{iFile},allFileNames)); %#ok + end + end + + function iC = local_findField(f, d) + % find the column iC in d.data such that d.varname{iC} == f + requiredFields = strsplit(f,','); + for iDataFile = 1:numel(d) + for iField = 1:numel(requiredFields) + iC(iDataFile,iField) = find(strcmpi(d{iDataFile}.varnames,requiredFields{iField})); %#ok + end + end + end + +end + +% % Ablation data - TODO +% % userdata.rf.originaldata.force.time = +% % userdata.rf.originaldata.force.force = +% % userdata.rf.originaldata.force.axialangle = +% % userdata.rf.originaldata.force.lateralangle = +% % userdata.rf.originaldata.force.position = +% % userdata.rf.originaldata.ablparams.time = +% % userdata.rf.originaldata.ablparams.power = +% % userdata.rf.originaldata.ablparams.impedance = +% % userdata.rf.originaldata.ablparams.distaltemp = \ No newline at end of file diff --git a/import_ensitex.m b/import_ensitex.m index dd691fe..c8d17d8 100644 --- a/import_ensitex.m +++ b/import_ensitex.m @@ -141,18 +141,18 @@ ); % next load the map file and the wave files into memory using loadensitex_dxldata.m -% cMapDir = [studyDir filesep() 'Contact_Mapping']; -% mappingFiles = nameFiles(cMapDir, 'showhiddenfiles', false, 'extension', '.csv'); -% for i = 1:numel(mappingFiles) -% [info, varnames, data] = loadensitex_dxldata([studyDir filesep() 'Contact_Mapping' filesep() mappingFiles{i}]); -% dataFile{i}.info = info; %#ok<*AGROW> -% dataFile{i}.varnames = varnames; -% dataFile{i}.data = data; -% end +cMapDir = [studyDir filesep() 'Contact_Mapping']; +mappingFiles = nameFiles(cMapDir, 'showhiddenfiles', false, 'extension', '.csv'); +for i = 1:numel(mappingFiles) + [info, varnames, data] = loadensitex_dxldata([studyDir filesep() 'Contact_Mapping' filesep() mappingFiles{i}]); + dataFile{i}.info = info; %#ok<*AGROW> + dataFile{i}.varnames = varnames; + dataFile{i}.data = data; +end % save('/Users/steven/Desktop/dataFiles.mat', 'dataFile'); -s = load('/Users/steven/Desktop/dataFiles.mat'); -dataFile = s.dataFile; +% s = load('/Users/steven/Desktop/dataFiles.mat'); +% dataFile = s.dataFile; % find the map name iContactMapFile = local_findFile('Map_CV_omni.csv', dataFile); diff --git a/import_precision.m b/import_precision.m index 5509af6..6eb2f1b 100644 --- a/import_precision.m +++ b/import_precision.m @@ -1,4 +1,4 @@ -function userdata = import_precision(varargin) +function [userdata, files] = import_precision(varargin) % IMPORT_PRECISION is used to import Precision data. % % Usage: @@ -9,6 +9,8 @@ % Where: % directory - an absolute folder path (if empty, user will be asked) % userdata - see importcarto_mem +% files - a data structure containing the contents of additional files +% exported from Precision % % IMPORT_PRECISION accepts the following parameter-value pairs % 'filematch' {} | String @@ -86,11 +88,15 @@ % There appears to be only activation OR voltage data in the mapping file and not both if isfield(data.modelgroups(iDxInd).dxgeo, 'act') act = data.modelgroups(iDxInd).dxgeo.act; + % remove invalid data, beyond interpolation distance + act(data.modelgroups(iDxInd).dxgeo.map_status==2) = NaN; else act = repmat(NaN, size(X)); end if isfield(data.modelgroups(iDxInd).dxgeo, 'bip') bip = data.modelgroups(iDxInd).dxgeo.bip; + % remove invalid data, beyond interpolation distance + bip(data.modelgroups(iDxInd).dxgeo.map_status==2) = NaN; else bip = repmat(NaN, size(X)); end @@ -136,6 +142,8 @@ userdata.electric.annotations.woi(:,2) = size(userdata.electric.egm,2) - userdata.electric.annotations.referenceAnnot; userdata.electric.voltages.bipolar = dxldata(i_dxl).peak2peak'; + userdata.electric.include = dxldata(i_dxl).utilized'; + userdata.electric.names = strcat('P', strsplit(num2str(dxldata(i_dxl).ptnumber)))'; else warning('OPENEP/IMPORT_PRECISION: This code is not fully tested and likely to yield errors') userdata.electric.electrodeNames_uni = dxldata(i_dxl).rovtrace_pts'; @@ -162,4 +170,7 @@ % userdata.rf.originaldata.ablparams.impedance = % userdata.rf.originaldata.ablparams.distaltemp = +% Return the additional files data +files = data; + end \ No newline at end of file diff --git a/importcarto_mem.m b/importcarto_mem.m index bbe724d..fda2ba6 100644 --- a/importcarto_mem.m +++ b/importcarto_mem.m @@ -31,7 +31,9 @@ % 'savefilename' {''}|string % The full path to the location in which to save the output. % 'verbose' {true}|false -% Not yet implemented +% Show progress dialogs and prompt to save the imported data. +% 'progresscallback' {[]}|function_handle +% Optional callback invoked as callback(stage, fraction, message). % Example of command line entry ... % userdata = importcarto_mem(, ... % 'maptoread', 1693, ... @@ -116,7 +118,7 @@ hWait = []; if nargin >= 1 - userinput = varargin{1}; + userinput = char(varargin{1}); else dialog_title = 'Select the Carto Study xml in the unzipped folder (eg "Study 1 11_20_2012 21-02-32.xml"), or mat file.'; filterSpec = {'*.zip;*.xml;*.mat', 'Appropriate files (*.zip;*.xml;*.mat)' ; '*.zip','Zip (*.zip)' ; '*.xml','XML (*.xml)' ; '*.mat','Matlab (*.mat)' ; '*.*','All files (*.*)'}; @@ -153,9 +155,10 @@ channelECG_cli = ''; saveFileName_cli = ''; verbose = true; +progressCallback_cli = []; if nargin > nStandardArgs for i = nStandardArgs+1:2:nargin - switch varargin{i} + switch lower(char(varargin{i})) case 'maptoread' mapToRead_cli = varargin{i+1}; case 'refchannel' @@ -167,6 +170,12 @@ saveFileName_cli = varargin{i+1}; case 'verbose' verbose = varargin{i+1}; + case 'progresscallback' + progressCallback_cli = varargin{i+1}; + if ~isempty(progressCallback_cli) && ... + ~isa(progressCallback_cli, 'function_handle') + error('IMPORTCARTO_MEM: progresscallback must be a function handle.') + end otherwise error('IMPORTCARTO_MEM: Unrecognized input.') end @@ -180,14 +189,16 @@ allfilenames{i} = studyDirInfo(i).name; end -disp(['Accessing: ' userinput]); +if verbose + disp(['Accessing: ' userinput]); +end -hWait = waitbar(0, 'Getting study information'); +hWait = openProgress(verbose, 'Getting study information'); Pref.Str2Num = 'never'; [tree, ~, ~] = xml_read(userinput, Pref); -delete(hWait) +closeProgress(hWait) hWait = []; study = tree.ATTRIBUTE.name; %#ok @@ -222,9 +233,22 @@ end elseif ischar(mapToRead_cli) selection = find(strstartcmpi(mapToRead_cli, names)); + if isempty(selection) + error('IMPORTCARTO_MEM:MapNotFound', ... + 'Unable to identify map: %s', mapToRead_cli); + elseif numel(selection) > 1 + error('IMPORTCARTO_MEM:AmbiguousMap', ... + 'Map name is ambiguous: %s', mapToRead_cli); + end + else + error('IMPORTCARTO_MEM:InvalidMapSelection', ... + 'maptoread must be a map name or point count.'); end end +reportProgress(progressCallback_cli, 'initializing', 0, ... + 'Initializing CARTO import.'); + % Get the tags from the ID nTags = str2double(tree.Maps.TagsTable.ATTRIBUTE.Count); tagNames = cell(nTags,1); @@ -251,8 +275,11 @@ isFirstPointRead = false; nPoints = str2double(cartoMap.CartoPoints.ATTRIBUTE.Count); if nPoints>0 - filename = [filenameroot '_P' num2str(cartoMap.CartoPoints.Point(1).ATTRIBUTE.Id) '_ECG_Export.txt']; - filename = mycheckfilename(filename, allfilenames, ['P' num2str(cartoMap.CartoPoints.Point(1).ATTRIBUTE.Id) '_ECG_Export']); + filename = [filenameroot '_P' num2str(cartoMap.CartoPoints.Point(1).ATTRIBUTE.Id) '_Point_Export.xml']; + filename = mycheckfilename(filename, allfilenames, ['P' num2str(cartoMap.CartoPoints.Point(1).ATTRIBUTE.Id) '_Point_Export']); + pointTree = xml_read(fullfile(studyDir, filename), Pref); + filename = pointTree.ECG.ATTRIBUTE.FileName; + if ~isempty(filename) ecgFileHeader = read_ecgfile_v4(fullfile(studyDir, filename)); names = ecgFileHeader.channelNames; @@ -262,21 +289,27 @@ [kRef,ok] = listdlg( 'ListString', names , 'SelectionMode','single' , 'PromptString','Which signal is Ref?' , 'ListSize',[300 300] ); if ~ok; return; end channelRef_cli = names{kRef}; else - kRef = find(strstartcmpi(channelRef_cli, names)); + kRef = find(strcmpi(channelRef_cli, names)); if isempty(kRef) || numel(kRef)>1 - error(['IMPORTCARTO_MEM: Unable to uniquely identify the specified reference channel: ' channelRef_cli]); + error('IMPORTCARTO_MEM:InvalidReferenceChannel', ... + 'Unable to uniquely identify the specified reference channel: %s', ... + channelRef_cli); end end + kRefBu = kRef; if isempty(channelECG_cli) [kEcg,ok] = listdlg( 'ListString', names , 'SelectionMode','multiple' , 'PromptString','Which other signals should be downloaded with each point (typically one or more ECG signals)?' , 'ListSize',[300 300] ); if ~ok; return; end channelECG_cli = names(kEcg); else kEcg = zeros(1,numel(channelECG_cli)); for i = 1:numel(channelECG_cli) - kEcg(i) = find(strcmpi(channelECG_cli{i}, names)); - if isempty(kEcg(i)) - error(['IMPORTCARTO_MEM: Unable to uniquely identify the specified ECG channel: ' channelECG_cli]); + matches = find(strcmpi(channelECG_cli{i}, names)); + if numel(matches) ~= 1 + error('IMPORTCARTO_MEM:InvalidEcgChannel', ... + 'Unable to uniquely identify the specified ECG channel: %s', ... + channelECG_cli{i}); end + kEcg(i) = matches; end end isFirstPointRead = true; @@ -323,6 +356,9 @@ if nPoints>0 for iPoint = 1:nPoints + reportLoopProgress(progressCallback_cli, 'point_metadata', ... + 0, 0.05, iPoint, nPoints, ... + 'Reading CARTO point metadata'); map.xyz(iPoint,:) = str2num(cartoMap.CartoPoints.Point(iPoint).ATTRIBUTE.Position3D); %map.xyzSurf(iPoint,:) = cartoMap.CartoPoints.Point(iPoint).VirtualPoint.ATTRIBUTE.Position3D; %not reliable %map.projDist(iPoint,:) = cartoMap.CartoPoints.Point(iPoint).VirtualPoint.ATTRIBUTE.ProjectionDistance; %not reliable @@ -374,7 +410,8 @@ end %%% Now get the point WOI, Reference time and Annotation time - hWait = waitbar(0, ['Getting annotation data for ' num2str(nPoints) ' points']); + hWait = openProgress(verbose, ... + ['Getting annotation data for ' num2str(nPoints) ' points']); pointExport_WOI = NaN(nPoints,2); pointExport_ReferenceAnnotation = NaN(nPoints,1); pointExport_MapAnnotation = NaN(nPoints,1); @@ -389,7 +426,10 @@ if nPoints>0 for iPoint = 1:nPoints - waitbar(iPoint/nPoints, hWait); + reportLoopProgress(progressCallback_cli, 'annotations', ... + 0.05, 0.20, iPoint, nPoints, ... + 'Reading CARTO annotations'); + updateProgress(hWait, iPoint/nPoints); filename_pointExport = [filenameroot '_' map.pointNames{iPoint} '_Point_Export.xml']; if ~isfile(fullfile(studyDir, filename_pointExport)) disp(['File not found: ' , filename_pointExport]) @@ -411,7 +451,7 @@ end end end - delete(hWait) + closeProgress(hWait) hWait = []; %%% Now get the details for the xml files of each point. @@ -435,32 +475,85 @@ nameEcgFull = namesFull(kEcg); %%% Now get the electrograms - hWait = waitbar(0, ['Getting electrical data for ' num2str(nPoints) ' points']); + hWait = openProgress(verbose, ... + ['Getting electrical data for ' num2str(nPoints) ' points']); for iPoint = 1:nPoints - waitbar(iPoint/nPoints , hWait ) - filename = [filenameroot '_' map.pointNames{iPoint} '_ECG_Export.txt']; - filename = mycheckfilename(filename, allfilenames, [map.pointNames{iPoint} '_ECG_Export']); + reportLoopProgress(progressCallback_cli, 'electrograms', ... + 0.20, 0.90, iPoint, nPoints, ... + 'Reading CARTO electrograms'); + updateProgress(hWait, iPoint/nPoints); + filename = [filenameroot '_' map.pointNames{iPoint} '_Point_Export.xml']; + filename = mycheckfilename(filename, allfilenames, [map.pointNames{iPoint} '_Point_Export']); + pointTree = xml_read(fullfile(studyDir, filename), Pref); + filename = pointTree.ECG.ATTRIBUTE.FileName; if ~isempty(filename) - [headerInfo, voltages] = read_ecgfile_v4(fullfile(studyDir, filename)); + [headerInfo, voltages] = read_ecgfile_v4(fullfile(studyDir, filename), ecgFileHeader.gain); voltages = voltages * headerInfo.gain; names = headerInfo.channelNames; - electrodeNames_bip{iPoint} = headerInfo.bipMapChannel; - electrodeNames_uni{iPoint,1} = headerInfo.uniMapChannel; - electrodeNames_uni{iPoint,2} = headerInfo.uniMapChannel2; + + if isfield(headerInfo, 'bipMapChannel') + % mapping channels are identified in the ECG file + electrodeNames_bip{iPoint} = headerInfo.bipMapChannel; + electrodeNames_uni{iPoint,1} = headerInfo.uniMapChannel; + electrodeNames_uni{iPoint,2} = headerInfo.uniMapChannel2; + else + % we neeed to access mapping channels from the point XML file + if verbose + disp('getting mapping channels'); + end + electrodeNames_bip{iPoint} = pointTree.ECG.ATTRIBUTE.BipolarMappingChannel; + electrodeNames_uni{iPoint,1} = pointTree.ECG.ATTRIBUTE.UnipolarMappingChannel; + electrodeNames_uni{iPoint,2} = incrementUnipoleName(pointTree.ECG.ATTRIBUTE.UnipolarMappingChannel); + + % check that the second unipole name actually exists, otherwise decrement the unipole name + if isempty(find(strcmpi(electrodeNames_uni{iPoint,2}, names))) + electrodeNames_uni{iPoint,2} = decrementUnipoleName(pointTree.ECG.ATTRIBUTE.UnipolarMappingChannel); + end + % check that the new second unipole name actually exists, otherwise throw an error (for now) + if isempty(find(strcmpi(electrodeNames_uni{iPoint,2}, names))) + error('OPENEP/IMPORTCARTO_MEM: Unable to identify second unipole channel') + end + + % if possible check that the assumed second unipole exists in bipole name - this is only possible if two unipoles are identified in the bipole name + if numel(regexp(electrodeNames_bip{iPoint}, '[0-9]+')) == 2 + + % extract the number in the second unipole + uni2Number = regexp(electrodeNames_uni{iPoint,2}, '[0-9]+', 'match'); + if numel(uni2Number) ~= 1 + error('OPENEP/IMPORTCARTO_MEM: There should only be one unipole number') + end + uni2Number = uni2Number{1}; + + % check that the assumed second unipole number exists in bipole name electrodeNames_bip{iPoint} + if isempty(regexp(electrodeNames_bip{iPoint}, uni2Number)) + warning('OPENEP/IMPORTCARTO_MEM: Second unipole channel incorrectly identified: alternatively decrementing unipole number') + electrodeNames_uni{iPoint,2} = decrementUnipoleName(pointTree.ECG.ATTRIBUTE.UnipolarMappingChannel); + % check that the new second unipole name actually exists, otherwise throw an error (for now) + if isempty(find(strcmpi(electrodeNames_uni{iPoint,2}, names))) + error('OPENEP/IMPORTCARTO_MEM: Unable to identify second unipole channel') + end + end + + end + + end pointFileName = allPointExport.Point(iPoint).ATTRIBUTE.File_Name; pointFileName = fullfile(homeDir, pointFileName); - - if any(kRef>numel(names)) || any(kEcg>numel(names)) || any(~strcmpi(names(kRef),nameRef)) || any(~strcmpi(names(kEcg),nameEcg)) beep() warning(['IMPORTCARTO_MEM: The columns containing data in the .txt files change names. In ' filename]) kRef = find( strcmpi(nameRef, names) ); if isempty(kRef) warning(['IMPORTCARTO_MEM: The requested reference channel, ' nameRef ' was not found in file: ' filename '. NaN values will be assigned as the reference for this point' ]); + % % try switching the names CS to DECA + % % TODO add in code here which translates CS + % to DECA, as this is the only use case for + % this we have come across yet + kRef = NaN; end for i = 1:numel(kEcg) @@ -503,12 +596,17 @@ end else warning('IMPORTCARTO_MEM: No electrode found ... check "OnAnnotation" file ...') - disp(filename) - disp('') + if verbose + disp(filename) + disp('') + end end end + if isnan(kRef) + kRef = kRefBu; + end end - delete(hWait) + closeProgress(hWait) hWait = []; end @@ -536,10 +634,14 @@ t_lateralAngle = nan(nPoints,max(size(t_T)),2); %%% Now we get the forces - hWait = waitbar(0, ['Getting force data for ' num2str(nPoints) ' points']); + hWait = openProgress(verbose, ... + ['Getting force data for ' num2str(nPoints) ' points']); hasWarned = false; for iPoint = 1:nPoints - waitbar(iPoint/nPoints, hWait); + reportLoopProgress(progressCallback_cli, 'force', ... + 0.90, 0.98, iPoint, nPoints, ... + 'Reading CARTO contact-force data'); + updateProgress(hWait, iPoint/nPoints); filename_force = [filenameroot '_' map.pointNames{iPoint} '_ContactForce.txt']; filename_force = mycheckfilename(filename_force, allfilenames, [map.pointNames{iPoint} '_ContactForce.txt']); if ~isempty(filename_force) @@ -580,13 +682,13 @@ end end - delete(hWait) + closeProgress(hWait) hWait = []; end else nameRef = []; nameEcg = []; - delete(hWait) + closeProgress(hWait) hWait = []; end @@ -649,6 +751,7 @@ userdata.electric.egmSurfX = []; userdata.electric.barDirection = []; end + userdata.surface.triRep = meshAsStruct(userdata.surface.triRep); % Now store the CF and RF data if the files existed if ~isempty(iContactForceInRfFiles) @@ -680,18 +783,23 @@ for i = 1:numel(userdata.electric.electrodeNames_uni) userdata.electric.electrodeNames_uni{i} = [userdata.electric.electrodeNames_uni{i} , '(']; end - userdata.electric.egmRefNames = nameRefFull; - userdata.electric.ecgNames = nameEcgFull; + + % Commented out, 25-6-25 - unsure why we are storing these. Format is + % e.g. V2(23) rather than V2. Can be added back in if needed. + % userdata.electric.egmRefNames = nameRefFull; + % userdata.electric.ecgNames = nameEcgFull; % Encourage user to save the data + reportProgress(progressCallback_cli, 'complete', 1, ... + 'Finished CARTO import.'); if ~isempty(saveFileName_cli) save(saveFileName_cli, 'userdata'); matFileFullPath = saveFileName_cli; - else + elseif verbose defaultName = [map.studyName '_' map.name]; defaultName(isspace(defaultName)) = '_'; originalDir = cd(); @@ -703,6 +811,8 @@ save([saveDir filename], 'userdata','-v7.3'); %needed as sometimes >2GB matFileFullPath = fullfile(saveDir, filename); end + else + matFileFullPath = []; end @@ -721,6 +831,60 @@ end +function hWait = openProgress(verbose, message) +hWait = []; +if verbose + hWait = waitbar(0, message); +end +end + +function updateProgress(hWait, fraction) +if ~isempty(hWait) && isgraphics(hWait) + waitbar(fraction, hWait); +end +end + +function closeProgress(hWait) +if ~isempty(hWait) && isgraphics(hWait) + delete(hWait); +end +end + +function mesh = meshAsStruct(mesh) +if isempty(mesh) || isstruct(mesh) + return +end + +if isa(mesh, 'triangulation') + mesh = struct('X', mesh.Points, ... + 'Triangulation', mesh.ConnectivityList); +else + mesh = struct('X', mesh.X, ... + 'Triangulation', mesh.Triangulation); +end +end + +function reportLoopProgress(callback, stage, startFraction, endFraction, ... + index, count, message) +if isempty(callback) || count < 1 + return +end + +interval = max(1, ceil(count / 25)); +if index == 1 || index == count || mod(index, interval) == 0 + fraction = startFraction + ... + (endFraction - startFraction) * index / count; + reportProgress(callback, stage, fraction, ... + sprintf('%s: %d of %d.', message, index, count)); +end +end + +function reportProgress(callback, stage, fraction, message) +if ~isempty(callback) + callback(stage, fraction, message); +end +end + function fname = mycheckfilename(filename, allfilenames, searchstring) % Check that filename has an exact match in allfilenames. If not, then % search through filenames to see if there is a single string that contains @@ -755,5 +919,3 @@ warning(['IMPORTCARTO3: the filename relating to ' char(39) searchstring char(39) ' is unexpected but a match was found - ' fname]) end end - - diff --git a/importensitex.m b/importensitex.m new file mode 100644 index 0000000..e38964b --- /dev/null +++ b/importensitex.m @@ -0,0 +1,276 @@ +function info = importensitex(varargin) +% IMPORTPRECISION loads the data from a Precision case. +% Usage: +% info = importprecision() +% info = importprecision(directory) +% info = importprecision( ... , Name,Value ... ) +% Output: +% info - see 'format' below for details +% Inputs: +% directory - an absolute folder path (if empty, use will be asked) +% Name,Value pairs ... +% 'filematch' - a string that gives a PARTIAL match to the file(s) to +% be loaded. e.g. 'bipol_RAW', 'Location'. Not case sensitive +% and does NOT need to be a full match. This is useful if you do +% not want to read in all files (save time + memory). +% 'format' +% - 'raw' - info is a cell array with each cell +% corresponding to the loadfunction for each file that +% has been read (this is the default) +% - 'default' - info is a structure with the following fields, which +% contain the info from the Data Element of similar +% name to the fieldname. Where there are n (n>1) files +% with the same Data Element, the field will have +% dimension n. +% .epcath_bip_raw +% .epcath_bip_filt +% .epcath_uni_raw +% .epcath_uni_filt +% .epcath_uni_comp +% .ecg_raw +% .ecg_filt +% .respiration +% .locations +% .channels +% .dxldata +% .modelgroups +% +% +% IMPORTPRECISION loads the wave data from a folder. All wavedata is +% loaded as if it is from a 'catheter' - see LOADVELOCITY_EGMDATA for more +% details. +% +% Author: Steven Williams, Nick Linton (2017) +% +% Info on Code Testing: +% --------------------------------------------------------------- +% +% --------------------------------------------------------------- +% +% --------------------------------------------------------------- +% code +% --------------------------------------------------------------- + + persistent caseDirec + if isempty(caseDirec) + caseDirec = local_homedirec(); + end + + p = inputParser; + p.addParameter('direc', caseDirec, @(x) isfolder(x)); + p.addParameter('filematch', {}, @(x) validateattributes(x,{'char','string','cell'}, {'vector'})); + p.addParameter('format', 'default', @(x) validateattributes(x,{'string'} )); + p.parse(varargin{:}) + + if any(strcmp('direc',p.UsingDefaults)) + direc = uigetdir(p.Results.direc,'Select the folder for the Precision Case'); + if direc == 0 + info = {}; return + else + caseDirec = direc; + end + else + caseDirec = p.Results.direc; + end + + [fileList, fullFileList] = local_get_filelist(p.Results.filematch, caseDirec); + + functionList = { @loadprecision_wavefile ... + ,@loadprecision_modelgroups ... + ,@loadprecision_dxldata ... + ...%,@loadprecision_shadows ... + ...%,@loadprecision_channels ... + }; + + info = cell(numel(fullFileList),1); + + hBar = waitbar(0,'Reading data files'); + cleanupWaitBar = onCleanup(@()close(hBar)); + set(findall(hBar, 'type', 'text'), 'Interpreter', 'none') + + %disable warnings about invalid file - we will monitor + oldWarningState = warning('query', 'LoadPrecision:InvalidFile'); + warning('off','LoadPrecision:InvalidFile'); + cleanupWarning = onCleanup(@()warning(oldWarningState)); + + for iFile = 1:length( fullFileList ) + for iFun = 1:numel(functionList) + temp = functionList{iFun}(fullFileList{iFile}); + if ~isempty(temp) + info{iFile} = temp; + break %no need to try other functions as we have succeeded + elseif iFun == numel(functionList) %we have not loaded despite trying all functions + warning(['IMPORTPRECISION: ' fileList{iFile} ' was not loaded.']) + end + end + waitbar(iFile/length(fullFileList), hBar); + end + + if isempty(info) + return + end + + info = local_reformat_precision_info(info, p.Results.format); + info.directory = caseDirec; + +end + + + +function [fileList, fullFileList] = local_get_filelist(desiredFileNames, caseDirec) + %make a list of all the '.txt. files in caseDir + fullFileList = []; + fileList = []; + d = dir(caseDirec); + if isa(desiredFileNames,'char') + desiredFileNames = {desiredFileNames}; + end + for iGTF = 1:length(d) + if strcmp(d(iGTF).name,'.') || strcmp(d(iGTF).name,'..') || d(iGTF).isdir + %do nothing + else + %check we have a match with listed filenames + isToAddFile = true; + for i = 1:numel(desiredFileNames) + isToAddFile = false; + startIndex = regexp(lower(d(iGTF).name), lower(desiredFileNames{i}), 'once'); + if ~isempty(startIndex) + isToAddFile = true; + break + end + end + if isToAddFile + fullFileList{end+1} = [caseDirec filesep() d(iGTF).name]; %#ok + fileList{end+1} = d(iGTF).name; %#ok + end + end + end +end + + +function newInfo = local_reformat_precision_info(info, format) +% LOCAL REFORMAT_PRECISION_INFO reformats data from IMPORTPRECISION see below. +% Usage: +% newInfo = reformat_precision_info(info, 'format') +% Where: +% info is the data returned from importprecision +% 'format' is an identifier ... +% 'raw' +% 'default' +% 'SW01' - Steve Williams v01 +% 'NL01' - Nick Linton v01 +% Put the rest of the help above under help for IMPORTPRECISION +% +% REFORMAT_PRECISION_INFO is necessary to maintain backwards compatibility +% due to the rapidly changing nature of the SJM exports! +% +% Author: Nick Linton (2017) +% +% Info on Code Testing: +% --------------------------------------------------------------- +% +% --------------------------------------------------------------- +% +% --------------------------------------------------------------- +% code +% --------------------------------------------------------------- + + %check that all info is from the same study + + nInfo = numel(info); + local_checkstudy(info); + + switch lower(format) + case 'raw' + newInfo = info; + case 'default' + newInfo = local_default(info); + case 'sw01' + newInfo = local_stevewilliams_01(info); + case 'nl01' + newInfo = local_nicklinton_01(info); + otherwise + error('REFORMAT_PRECISION_INFO: format not found.') + end + +end + +function newInfo = local_default(info) +% Create newInfo according to the Data Export Element. As the names of +% the Data Export Element have subtly changed some standardization is +% necessary. + newInfo = {}; + allDataExportTypes = translateDataExportElement(); + isRead = false(numel(info),1); + + for iDET=1:numel(allDataExportTypes) + isMatch = false(numel(info), 1); + thisDET = allDataExportTypes{iDET}; + for iIn=1:numel(info) + if isempty(info{iIn}) + isRead(iIn) = true; + elseif isfield(info{iIn}, 'dataElement') + infoDET = info{iIn}.dataElement; + infoDET = translateDataExportElement(infoDET); + if strcmp(infoDET,thisDET) + isMatch(iIn) = true; + end + end + end + ind = find(isMatch); + if numel(ind)>0 + temp = info{ind(1)}; + for i=2:numel(ind) + temp(i)=info{ind(i)}; %concatenate the structures if more than one of same type + end + newInfo = setfield(newInfo,thisDET,temp); %#ok + isRead(isMatch)=true; + end + end + if any(~isRead) + beep() + warning('REFORMAT_PRECISION_INFO: some info was not reformatted.'); + end +end + +function newInfo = local_stevewilliams_01(info) + newInfo=info; + % info.egmdata = egmdata; %loadprecision_egmdata.m + % info.modelgroups = modelgroups; %loadprecision_modelgroups.m + % info.shadows = shadowsdata; %loadprecision_shadows.m + % info.egmlocations = egmlocations; %loadprecision_electrodepositions.m + % info.enguidesettings = enguidesettings; %loadprecision_channels.m + % info.dxldata = dxldata; %loadprecision_dxldata.m case 'nl01' +end + +function newInfo = local_nicklinton_01(info) + % Create newInfo according to the Data Export Element. As the names of + % the Data Export Element have subtly changed some standardization is + % necessary. + newInfo = info; +end + +function local_checkstudy(info) + lastStudy = ''; + for i = 1:numel(info) + if ~isempty(info{i}) && isfield(info,'study') && ~isempty(info{i}.study) + if isempty(lastStudy) + lastStudy = info{i}.study; + else + if ~strcmp(info{i}.study,lastStudy) + error('REFORMAT_PRECISION_INFO: files are from different studies') + end + end + end + end +end + +function hd = local_homedirec() +%HOMEDIREC returns the user's home directory. + + if ispc + hd = [getenv('HOMEDRIVE') getenv('HOMEPATH')]; + else + hd = getenv('HOME'); + end +end \ No newline at end of file diff --git a/importensitex_case.m b/importensitex_case.m new file mode 100644 index 0000000..a56d3e2 --- /dev/null +++ b/importensitex_case.m @@ -0,0 +1,170 @@ +function [openepCase, matFileFullPath] = importensitex_case(studyDir, varargin) +%IMPORTENSITEX_CASE Import multiple EnSiteX recording modes into one MAT file. +% +% openepCase = importensitex_case(studyDir) +% openepCase = importensitex_case(studyDir, ... +% 'maptoread', mapName, ... +% 'modes', {'bi', 'uni', 'omni'}, ... +% 'savefilename', outputFile); +% Optional progresscallback is invoked as callback(stage, fraction, message). + +p = inputParser; +addRequired(p, 'studyDir', @(x) ischar(x) || isstring(x)); +addParameter(p, 'maptoread', '', @(x) ischar(x) || isstring(x)); +addParameter(p, 'modes', {}, @(x) ischar(x) || isstring(x) || iscellstr(x)); +addParameter(p, 'maptype', 'asegm', @(x) ischar(x) || isstring(x)); +addParameter(p, 'savefilename', '', @(x) ischar(x) || isstring(x)); +addParameter(p, 'showprogress', false, @(x) islogical(x) && isscalar(x)); +addParameter(p, 'progresscallback', [], ... + @(x) isempty(x) || isa(x, 'function_handle')); +parse(p, studyDir, varargin{:}); +opts = p.Results; + +studyDir = char(studyDir); +reportProgress(opts.progresscallback, 'discovering_exports', 0, ... + 'Discovering EnSiteX exports.'); +manifest = inspectensitex_export(studyDir); +assert(~isempty(manifest.exports), ... + 'IMPORTENSITEX_CASE: No EnSiteX exports found in %s.', studyDir); + +[mapName, mapExports] = selectMapExports(manifest.exports, opts.maptoread); +requestedModes = normalizeRequestedModes(opts.modes, mapExports); +datasets = repmat(emptyDataset(), numel(requestedModes), 1); +reportProgress(opts.progresscallback, 'discovered_exports', 0.05, ... + sprintf('Found %d requested recording mode(s).', numel(requestedModes))); + +for iMode = 1:numel(requestedModes) + mode = requestedModes{iMode}; + export = selectModeExport(mapExports, mode); + fprintf('\nImporting EnSiteX map "%s", mode %s\n', ... + normalizeMapName(mapName), mode); + reportProgress(opts.progresscallback, ['mode_', mode], ... + (iMode - 1) / numel(requestedModes), ... + sprintf('Importing EnSiteX mode %s (%d of %d).', ... + mode, iMode, numel(requestedModes))); + + [userdata, ~] = importensitex_openep( ... + export.folder, ... + 'maptoread', normalizeMapName(mapName), ... + 'egmtype', mode, ... + 'maptype', char(opts.maptype), ... + 'showprogress', opts.showprogress, ... + 'saveoutput', false); + + datasets(iMode).id = mode; + datasets(iMode).recordingMode = mode; + datasets(iMode).mapName = normalizeMapName(mapName); + datasets(iMode).sourceFolder = export.folder; + datasets(iMode).detection = struct( ... + 'confidence', export.confidence, ... + 'evidence', {export.evidence}, ... + 'warnings', {export.warnings}); + datasets(iMode).userdata = userdata; + reportProgress(opts.progresscallback, ['mode_', mode], ... + iMode / numel(requestedModes), ... + sprintf('Finished EnSiteX mode %s (%d of %d).', ... + mode, iMode, numel(requestedModes))); +end + +openepCase = struct(); +openepCase.schemaName = 'OpenEP multi-dataset case'; +openepCase.schemaVersion = '1.0'; +openepCase.source = struct( ... + 'system', 'ensitex', ... + 'rootFolder', studyDir, ... + 'importedAt', char(datetime('now', 'Format', 'yyyy-MM-dd HH:mm:ss Z'))); +openepCase.mapName = normalizeMapName(mapName); +openepCase.datasets = datasets; + +matFileFullPath = char(opts.savefilename); +if ~isempty(matFileFullPath) + save(matFileFullPath, 'openepCase', '-v7.3'); +end + +function reportProgress(callback, stage, fraction, message) +if ~isempty(callback) + callback(stage, fraction, message); +end +end +end + +function [mapName, mapExports] = selectMapExports(exports, requestedMap) +normalizedNames = cellfun(@normalizeMapName, {exports.mapName}, ... + 'UniformOutput', false); +uniqueNames = unique(normalizedNames, 'stable'); + +if isempty(requestedMap) + if ~isscalar(uniqueNames) + error(['IMPORTENSITEX_CASE: Multiple maps found. Specify maptoread. ', ... + 'Available maps: %s'], strjoin(uniqueNames, ', ')); + end + selectedName = uniqueNames{1}; +else + requestedMap = normalizeMapName(requestedMap); + matches = strcmpi(uniqueNames, requestedMap); + if ~any(matches) + matches = startsWith(uniqueNames, requestedMap, 'IgnoreCase', true); + end + if sum(matches) ~= 1 + error('IMPORTENSITEX_CASE: maptoread did not identify exactly one map.'); + end + selectedName = uniqueNames{matches}; +end + +mapExports = exports(strcmp(normalizedNames, selectedName)); +mapName = mapExports(1).mapName; +end + +function modes = normalizeRequestedModes(requestedModes, exports) +if isempty(requestedModes) + detected = {exports.recordingMode}; + canonical = {'bi', 'uni', 'omni'}; + modes = canonical(ismember(canonical, detected)); + if isempty(modes) + error(['IMPORTENSITEX_CASE: No recording modes could be detected. ', ... + 'Specify modes explicitly.']); + end +else + modes = cellstr(requestedModes); + modes = cellfun(@(x) lower(strtrim(x)), modes, 'UniformOutput', false); + modes = unique(modes, 'stable'); +end + +validModes = {'bi', 'uni', 'omni'}; +if any(~ismember(modes, validModes)) + error('IMPORTENSITEX_CASE: modes must contain only bi, uni or omni.'); +end +end + +function export = selectModeExport(exports, mode) +detectedModes = {exports.recordingMode}; +matches = strcmp(detectedModes, mode); +if sum(matches) == 1 + export = exports(matches); + return +end + +unknown = strcmp(detectedModes, 'unknown'); +if ~any(matches) && sum(unknown) == 1 + export = exports(unknown); + return +end + +error(['IMPORTENSITEX_CASE: Mode %s did not identify exactly one export. ', ... + 'Detected modes: %s'], mode, strjoin(detectedModes, ', ')); +end + +function name = normalizeMapName(name) +name = strrep(char(name), sprintf('\t'), ' '); +name = strtrim(regexprep(name, '\s+', ' ')); +end + +function dataset = emptyDataset() +dataset = struct( ... + 'id', '', ... + 'recordingMode', '', ... + 'mapName', '', ... + 'sourceFolder', '', ... + 'detection', struct(), ... + 'userdata', struct()); +end diff --git a/importensitex_dxldata.m b/importensitex_dxldata.m new file mode 100644 index 0000000..abbab89 --- /dev/null +++ b/importensitex_dxldata.m @@ -0,0 +1,466 @@ +function data = importensitex_dxldata(varargin) +% IMPORTPRECISION_DXLDATA Loads all data from a given DxL case +%{ +Imports all the ECG data from a Precision case folder, using the +loadprecision_dxldata.m file. Based in part on 'importprecision.m' file, +potentially looking to integrate later + +Parameters +---------- +direc : str + Base directory in which to search for DxL data files + +Flags +----- +'filematch' : cell of str + Cell array of strings that are required to be matched in some part of + the pathname/filename. Default={'.csv'} +'fileexclude' : cell of str + Cell array of strings that are to be used to exclude matches, applied + after the filematch criteria have been satisfied. + Default={'.tif', '.fig', '.mp4', '.mpg', '.jpg', '.xml', '.png', '.surf', '.pts'} +'recursive' : bool + true or false, whether or not the search for DxL files should be + recursive from the base directory. Default=false +'combine' : bool + true or false, whether to combine the individual DxL data files into + one output for a given study. Default=true +'post_process' : bool + true or false, whether to conduct the following post-proccessing steps: + Zero rovLAT by min(refLAT). Default=true +'warning_files' : bool + Whether to display warnings about being unable to load particular files + or not. Default=false +'warning_bipole' : bool + Whether to display warnings about determining the bipole/unipole data. + Default=true +'warning_data' : bool + Whether to display warnings about combining data effectively. + Default=true + +Returns +------- +data : cell of struct + Cell array of structures, each structure containing the data for an + individual DxL file or several combined DxL files, depending on flags. + Contains: + - folder + - num_files + - filenameList + - study + - sampleFreq + - mapId + - fileIndices + - expStartTime + - expEndTime + - expStartTimeAbs + - expEndTimeAbs + - rovtrace + - spare1trace + - spare2trace + - rovtrace_pts + - bipole + - ptnumber + - rovingx + - rovingy + - rovingz + - surfPtx + - surfPty + - surfPtz + - rovLAT + - peak2peak + - peakneg + - endtime + - CFEmean + - CFEstddev + +Revision History +---------------- +File created: Philip Gemmell (2020-02-11) +Added bipole/unipole flag: Philip Gemmell (2020-04-22) + +%} + +%% Check libraries have been added, & the relevant functions are available +% Need access to: +% - filebytes2end +% search_paths = {'private'}; +% for i_path = 1:length(search_paths) +% if ~contains(path, search_paths{i_path}) +% addpath(search_paths{i_path}) +% end +% end + +%% Parse input parameters +persistent caseDirec +if isempty(caseDirec) + caseDirec = get_homedir(); +end + +p = inputParser; +p.addOptional('direc',caseDirec, @(x) all(isfolder(x))); +p.addParameter('filematch',... + {'.csv'},... + @(x) validateattributes(x, {'string', 'cell'}, {'vector'})); +p.addParameter('fileexclude',... + {'.tif', '.fig', '.mp4', '.mpg', '.jpg', '.xml', '.png', '.surf', '.pts'},... + @(x) validateattributes(x, {'string', 'cell'}, {'vector'})); +p.addParameter('recursive',... + true,... + @islogical); +p.addParameter('combine',... + true,... + @islogical); +p.addParameter('post_process',... + true,... + @islogical); +p.addParameter('warning_files',... + false,... + @islogical); +p.addParameter('warning_bipole',... + true,... + @islogical); +p.addParameter('warning_data',... + true,... + @islogical); + +p.addParameter('format',... + 'default',... + @(x) validateattributes(x, {'string'} )); +p.parse(varargin{:}) + +if p.Results.warning_files + warning('on', 'LoadEnsiteX:InvalidFile'); + warning('on', 'importprecision_dxldata:InvalidFile'); +else + warning('off', 'LoadEnsiteX:InvalidFile'); + warning('off', 'importprecision_dxldata:InvalidFile'); +end + +if p.Results.warning_bipole + warning('on', 'importprecision_dxldata:bipolePosition'); +else + warning('off', 'importprecision_dxldata:bipolePosition'); +end + +if p.Results.warning_data + warning('on', 'importprecision_dxldata:dataEntry'); +else + warning('off', 'importprecision_dxldata:dataEntry'); +end + +% Get working directory from inputs, if provided +if any(strcmp('direc', p.UsingDefaults)) + direc = uigetdir(p.Results.direc, 'Select the folder for the Precision Case'); + if direc == 0 + data = {}; + return + else + caseDirec = direc; + end +else + if ~iscell(p.Results.direc) + caseDirec = {p.Results.direc}; + else + caseDirec = p.Results.direc; + end +end + +% Adapt working directory to be recursive, if required +if p.Results.recursive + for i_dir = 1:length(caseDirec) + if strcmpi(caseDirec{i_dir}(end),'/') + caseDirec{i_dir} = [caseDirec{i_dir}, '**']; + else + caseDirec{i_dir} = [caseDirec{i_dir}, '/**']; + end + end +end + +[fileList, fullFileList] = get_filelist(caseDirec, p.Results.filematch,... + p.Results.fileexclude); + +if length(fileList) >= 500 + fprintf(1, "Many files found - maybe take a look?\n") + keyboard +end + +% data = cell(numel(fullFileList), 1); +data = struct; +data_bool = true(numel(fullFileList), 1); + +%% Process data + +% Set-up progress bar +if usejava('desktop') + hBar = waitbar(0, 'Reading data files'); + cleanupWaitBar = onCleanup(@()close(hBar)); + set(findall(hBar, 'type', 'text'), 'Interpreter', 'none') +else + reverseStr = ''; + fprintf('Percent done: '); +end + +% Disable warnings about invalid file - we will monitor +oldWarningState = warning('query', 'loadensitex_dxldata:InvalidFile'); +warning('off', 'loadensitex_dxldata:InvalidFile'); +cleanupWarning = onCleanup(@()warning(oldWarningState)); + +for i_file = 1:length(fullFileList) + % Extract sum total of data (while flagging for removal those entries + % that don't provide any data) + try + [info, pts, egm] = loadensitex_dxldata(fullFileList{i_file}); + catch + fprintf(1,"Can't read %s\n", fullFileList{i_file}) + continue + end + if isempty(info) + warning('importprecision_dxldata:InvalidFile', ... + ['loadensitex_dxldata: ', fileList{i_file}, ' was not loaded.']) + data_bool(i_file) = false; + continue + end + + % Reformat data to save only ECG output + info_fieldnames = {'study', 'sampleFreq', 'mapName', ... + 'startTime', 'endTime', 'startTimeAbs', 'endTimeAbs'}; + info_fieldnames_new = {'study', 'sampleFreq', 'mapName', ... + 'expStartTime', 'expEndTime', 'expStartTimeAbs', 'expEndTimeAbs'}; + data(i_file).filename = fullFileList{i_file}; + for iFieldname = 1:length(info_fieldnames) + data(i_file).(info_fieldnames_new{iFieldname}) = info.(info_fieldnames{iFieldname}); + end + + % Save all EGM data (rovtrace, reftrace, spare1trace, spare2trace, + % spare3trace) + egm_elements = fieldnames(egm); + for iEgm = 1:length(egm_elements) + %if ~strcmpi(egm_elements{iEgm}, 'reftrace') + data(i_file).(egm_elements{iEgm}) = egm.(egm_elements{iEgm}); + %end + end + + % Save bipole/unipole data, with checks to make sure data all uniform. + % Assume that all bipole data are in form "DD20 4-5", and all unipole + % data are in form "DD20 + 12". Where such data are not recorded, + % confirm that this is because no data recorded at all for rovtrace + % (data may still be recorded for the ECG in spare1trace, etc., hence + % not removing the data) + data(i_file).rovtrace_pts = {pts(:).rovtrace}; + bipole_check = regexp(data(i_file).rovtrace_pts(:),'-'); + bipole_check = ~cellfun(@isempty, bipole_check); + unipole_check = regexp(data(i_file).rovtrace_pts(:),'+'); + unipole_check = ~cellfun(@isempty, unipole_check); + if all(bipole_check) + assert(~all(unipole_check), 'Unipole signals detected in bipole'); + data(i_file).bipole = true; + elseif all(unipole_check) + assert(~all(bipole_check), 'Bipole signals detected in unipole'); + data(i_file).bipole = false; + elseif any(bipole_check) + assert(sum(unipole_check)==0, 'Both unipole and bipole signals detected') + i_noBipoleData = find(~bipole_check); + for i_pts = 1:length(i_noBipoleData) + assert(all(~data(i_file).rovtrace(:,i_noBipoleData(i_pts))),... + 'Data recorded with no bipole position data.') + end + data(i_file).bipole = true; + warning('importprecision_dxldata:bipolePosition',... + ['Not all bipole positions known in ', fullFileList{i_file}]) + elseif any(unipole_check) + assert(sum(bipole_check)==0, 'Both unipole and bipole signals detected') + data(i_file).bipole = false; + warning('importprecision_dxldata:bipolePosition',... + ['Not all unipole positions known in ', fullFileList{i_file}]) + end + + % Save remaining potentially useful data + pts_fieldnames = {'ptnumber', 'rovingx', 'rovingy', 'rovingz',... + 'surfPtx', 'surfPty', 'surfPtz',... + 'rovLAT', 'refLAT', 'peak2peak', 'peakneg', 'endtime', 'CFEmean', 'CFEstddev' ... + 'utilized', 'displayed' ... + }; + for iFieldname = 1:length(pts_fieldnames) + data(i_file).(pts_fieldnames{iFieldname}) = [pts(:).(pts_fieldnames{iFieldname})]; + end + + if usejava('desktop') + waitbar_msg = ['Reading data files (', num2str(i_file), '/',... + num2str(length(fullFileList)), ')']; + waitbar(i_file/length(fullFileList), hBar, waitbar_msg); + else + percentDone = 100 * i_file / length(fullFileList); + msg = sprintf('%3.1f', percentDone); + fprintf([reverseStr, msg]); + reverseStr = repmat(sprintf('\b'), 1, length(msg)); + end +end +data = data(data_bool); + +if isempty(data) + fprintf(1, "No data recovered that matches requirements.\n") + return +end + +% Concatenate all those DxL files within a single folder +if p.Results.combine + data = combine_data(data); +end + +% Conduct requested post-processing +if p.Results.post_process + data = post_process(data); +end + +end + +function data_unique = combine_data(data) + +% Extract filenames and associated folder +n_data = length(data); +filelist = cell(n_data, 1); +folderlist = cell(n_data, 1); +% filelist = struct; +% folderlist = struct; +for i=1:n_data + filelist{i} = data(i).filename; + folder_limiter = find(filelist{i}==filesep); + folder_limiter = folder_limiter(end); + folderlist{i} = filelist{i}(1:folder_limiter); +end + +% Confirm expected file numbers for each folder, and determine correct +% order in which to concatenate files +folderlist_unique = unique(folderlist); +n_unique = length(folderlist_unique); +data_unique = struct; +for i=1:n_unique + data_unique(i).folder = folderlist_unique{i}; + data_unique(i).num_files = sum(contains({data.filename}, folderlist_unique{i})); + i_match = find(contains({data.filename}, folderlist_unique{i})); + for j=1:length(i_match) + try + assert(data(i_match(j)).fileIndices(2) == data_unique(i).num_files,... + 'File number mismatch!'); + catch + keyboard + end + end +end +% Remove redundant section of data.fileIndices now it's been confirmed +for i=1:n_data + data(i).fileIndices = data(i).fileIndices(1); +end +file_order = zeros(n_data,1); +i_file = 1; +for i=1:n_unique + for j=1:data_unique(i).num_files + file_order(i_file) = find(contains({data.filename}, folderlist_unique{i})... + & [data.fileIndices]==j); + i_file = i_file+1; + end +end + +% Combine data if same source folder +fieldnames_nocombine = {'filename', 'study', 'sampleFreq', 'mapId',... + 'bipole', 'fileIndices', 'expStartTime', 'expEndTime', 'expStartTimeAbs',... + 'expEndTimeAbs'}; +fieldnames_nocompare = {'filename', 'fileIndices'}; +folder_processed = zeros(n_unique, 1); +% dxlorder_flag = true; +% Loop over all available data +for i_order=1:n_data + i_data = file_order(i_order); + i_folder = find(strcmp(folderlist_unique, folderlist{i_data})==1); + fieldname_list = fieldnames(data(i_data)); + % If folder has been processed already + if folder_processed(i_folder) + for i_fieldname=1:length(fieldname_list) + + if ~ismember(fieldname_list{i_fieldname}, fieldnames_nocombine) + % Combine data when appropriate e.g. rovtrace + data_unique(i_folder).(fieldname_list{i_fieldname}) = ... + [data_unique(i_folder).(fieldname_list{i_fieldname}), ... + data(i_data).(fieldname_list{i_fieldname})]; + else + + if ~ismember(fieldname_list{i_fieldname}, fieldnames_nocompare) + % Confirm that entries that are meant to be the same + % are actually the same e.g. sampleFreq + if ischar(data(i_data).(fieldname_list{i_fieldname})) + assert(strcmp(data(i_data).(fieldname_list{i_fieldname}),... + data_unique(i_folder).(fieldname_list{i_fieldname})),... + [fieldname_list{i_fieldname}, " doesn't match"]); + else + if ~isempty(data(i_data).(fieldname_list{i_fieldname})) + assert(data(i_data).(fieldname_list{i_fieldname}) == ... + data_unique(i_folder).(fieldname_list{i_fieldname}),... + [fieldname_list{i_fieldname}, " doesn't match"]); + else + warning('importprecision_dxldata:dataEntry',... + ['Empty entry for data_unique(',... + num2str(i_folder), ').', fieldname_list{i_fieldname}]) + end + end + else + + % Add filename to list of filenames for the folder + if strcmpi(fieldname_list{i_fieldname}, 'filename') + data_unique(i_folder).filenameList{end+1} = data(i_data).filename; + n_files = length(data_unique(i_folder).filenameList); + assert(data(i_data).fileIndices == n_files, 'Bugger') + end + end + end + end + else % If folder hasn't been processed yet, start from scratch + folder_processed(i_folder) = 1; + for i_fieldname=1:length(fieldname_list) + if ~strcmp(fieldname_list(i_fieldname), 'filename') + data_unique(i_folder).(fieldname_list{i_fieldname}) = ... + data(i_data).(fieldname_list{i_fieldname}); + else + data_unique(i_folder).filenameList = {data(i_data).filename}; + end + end + data_unique(i_folder).folder = folderlist_unique{i_folder}; + assert(data(i_data).fileIndices(1) == 1, 'First DxL file not recorded first!') + end +end + +assert(all(folder_processed), 'Not all unique folders processed!'); + +end + +function data = post_process(data) + +% Use points.endtime and points.rovLAT to calculate local AT +for i = 1:length(data) + + % process the roving LAT + [n_data, ~] = size(data(i).rovtrace); + assert(n_data ~= length(data(i).rovtrace_pts)); % Make sure we've got the right number! + traceLength = n_data-1; + + % calculate the time in seconds before the end of the trace + ref_s_beforeEndOfTrace = data(i).endtime - data(i).refLAT; + rov_s_beforeEndOfTrace = data(i).endtime - data(i).rovLAT; + + % convert to samples + ref_samp_beforeEndOfTrace = floor(ref_s_beforeEndOfTrace * data(i).sampleFreq); + rov_samp_beforeEndOfTrace = floor(rov_s_beforeEndOfTrace * data(i).sampleFreq); + if ref_samp_beforeEndOfTrace == 0 + ref_samp_beforeEndOfTrace = 1; + end + if rov_samp_beforeEndOfTrace == 0 + rov_samp_beforeEndOfTrace = 1; + end + + % convert to samples from the start of the trace + data(i).refLAT = traceLength - ref_samp_beforeEndOfTrace; + data(i).rovLAT = traceLength - rov_samp_beforeEndOfTrace; + +end + +end \ No newline at end of file diff --git a/importensitex_openep.m b/importensitex_openep.m new file mode 100644 index 0000000..6faec7c --- /dev/null +++ b/importensitex_openep.m @@ -0,0 +1,1632 @@ +function [userdata, matFileFullPath] = importensitex_openep(varargin) +% IMPORTENSITEX_OPENEP provides a data structure from multiple Precision files. +% Usage: +% userdata = importprecision_openep(userinput) +% userdata = importprecision_openep() +% [userdata, matFileFullPath] = ... +% Where: +% dirName is the directory with all of the files corresponding to a map +% userdata is a single data structure +% matFileFullPath is the path to the .mat file, if opened or saved +% +% IMPORTENSITEX_OPENEP accepts the following parameter-value pairs: +% 'maptoread' {''}|string|double +% Specifies which map to read. Can be a string referring +% to the map name or a double referring to the number of points in the +% map. If there are multiple maps with the same number of points an error +% will be thrown. +% Specifies whether to import the bipolar, omnipolar or unipolar +% electrograms. +% 'egmtype' 'bi'|'omni'|'uni' +% 'maptype' {'asegm'}|'all' +% Specifies whether to only import the surface map linked to the +% chosen electrograms; or to search for additional matching +% Contact_Mapping_Model.xml files and import them too. +% 'savefilename' {''}|string +% The full path to the location in which to save the output. +% 'showprogress' {true}|false +% Show progress windows while loading map and waveform CSV files. +% 'saveoutput' {true}|false +% Save or prompt to save userdata. Case-level importers set this false. + +% +% IMPORTENSITEX_OPENEP is for parsing data from the EnsiteX mapping system. +% The function handles data in bipolar, omnipolar and unipolar format. One, +% two or all of these formats can be present. The function works on a +% single map. +% +% Some important considerations: +% +% re: egmtype There is no option to import all the electrograms. - if this +% is desired multiple imports must be run creating different OpenEP files. +% +% (1) If maptype is 'all', then the following convetions apply: +% - act and bip taken from bipolar map folder +% - uni taken from unipolar map folder, imp and frc are not populated +% - all other available maps are stored as surface properties +% - if any of these folders are missing, a warning is given and the +% relevant data fields are empty +% - geometry is taken from the first available folder in the order of +% preference of bipolar > omnipolar > unipolar +% (2) If maptype is one of 'bipolar', 'omnipolar' or 'unipolar' then not +% all data fields will be populated. Specifically: +% - 'bipolar' - act, bip populated; uni not available +% - 'omnipolar' - act populated, bip and uni not available +% - 'unipolar' - act and uni populated, bip not available +% - all other available maps are stored as surface properties +% - geometry is taken from the specified folder +% - if the specified folder does not exist, an error is thrown +% +% Example of command line entry ... +% userdata = importensitex_openep(, ... +% 'savefilename', +% '~/Desktop/test.mat'); +% +% userdata structure ... +% .surface +% .triRep - TriRep object for the surface +% .isVertexAtRim - logical array indicating vertices at a 'rim' +% .act_bip - nVertices*2 array of activation and voltage data +% .uni_imp_frc - nVertices*3 array of uni voltage, impedance and contact force +% .electric +% .isPointLocationOnly - logical array +% .tags +% .names +% .egmX - location of point +% .egmSurfX - location of surface nearest point +% .barDirection - normal to surface at egmSurfX +% .egm - primary roving electrogram for the selected mode +% .egmUni - numPoints-by-numSamples-by-numComponents unipolar +% electrograms; omni order is corner, along, across +% .egmUniX - location of unipolar points +% .egmRefNames - names of egmRef +% .egmRef - electrogram of reference +% .ecgNames - ecg names (or other channel names) +% .ecg - ecg +% .force +% .force - instantaneous force recording +% .axialAngle - axial angle +% .lateralAngle - lateral angle +% .time_force - time course of force [(:,:,1)=time, (:,:,2)=force] +% .time_axial - time course of axial angle [(:,:,1)=time, (:,:,2)=axial angle] +% .time_lateral - time course of lateral angle [(:,:,1)=time, (:,:,2)=lateral angle] +% +% Author: Steven Williams (2025) +% SPDX-License-Identifier: Apache-2.0 +% +% --------------------------------------------------------------- +% testing +% --------------------------------------------------------------- +% +% --------------------------------------------------------------- +% code +% --------------------------------------------------------------- + +%% Identify the folder containing the exported files +% Folder identification is either via the command line or via a pop up +% dialog box +persistent saveDir homeDir +if isempty(saveDir) || ~ischar(saveDir) || ~isfolder(saveDir) + saveDir = local_homedirec(); +end +if isempty(homeDir) || ~isfolder(homeDir); homeDir = saveDir; end +if nargin >= 1 + userinput = varargin{1}; +else + dialog_title = 'Select the folder containing the exported EnsiteX files'; + if ~ispc() + uiwait(msgbox(dialog_title,'modal')) + end + userinput = uigetdir(homeDir, dialog_title); + if userinput == 0 + return + else + homeDir = userinput; + end +end +if ~isfolder(userinput) + error('IMPORTENSITEX_OPENEP: Only specify a folder as user input') +else + studyDir = userinput; + homeDir = studyDir; +end + + + + + + + + + + +%% Parse command line input +% Additional command line input is parsed to determine the save location +% and whether a conventional or an omnipolar map is being assessed. +nStandardArgs = 1; % UPDATE VALUE +mapToRead = ''; +egmtype = ''; +maptype = 'asegm'; +saveFileName = ''; +showProgress = true; +saveOutput = true; + +if nargin > nStandardArgs + for i = nStandardArgs+1:2:nargin + switch varargin{i} + case 'maptoread' + mapToRead = varargin{i+1}; + case 'egmtype' + egmtype = varargin{i+1}; + case 'maptype' + maptype = varargin{i+1}; + case 'savefilename' + saveFileName = varargin{i+1}; + case 'showprogress' + showProgress = varargin{i+1}; + case 'saveoutput' + saveOutput = varargin{i+1}; + otherwise + error('IMPORTENSITEX_OPENEP: Unrecognized input.') + end + end +end + + + + + + + + + + +%% Identify all available maps and export styles (uni, omni, bip) + +% This logic is reasonably robust but there are some requirements. +% All wave and Map files that are related to each other are stored in +% separate directories. This is the default way that the files come out of the +% system, named by a timestamp plus any other text that the user added. +% However, if the user has moved these files +% to a different location this code will throw an error since _almost +% certainly_ the number of points in wave and map files will no longer +% match. + +ensiteManifest = inspectensitex_export(studyDir); +if isempty(ensiteManifest.exports) + error('IMPORTENSITEX_OPENEP: No EnSiteX DXL exports were identified.'); +end + +allCsvHeaders = cell(1, numel(ensiteManifest.files)); +for iManifestFile = 1:numel(ensiteManifest.files) + fileInfo = ensiteManifest.files(iManifestFile); + allCsvHeaders{iManifestFile} = struct( ... + 'mapName', fileInfo.mapName, ... + 'mapType', fileInfo.mapType, ... + 'numPoints', fileInfo.numPoints, ... + 'filename', fileInfo.path); +end + +rawMapNames = unique({ensiteManifest.exports.mapName}, 'stable'); +locations = cell(numel(rawMapNames), 6); +for iMap = 1:numel(rawMapNames) + exportIndices = find(strcmp({ensiteManifest.exports.mapName}, rawMapNames{iMap})); + mapExports = ensiteManifest.exports(exportIndices); + modes = {mapExports.recordingMode}; + + for iExport = 1:numel(mapExports) + if strcmp(modes{iExport}, 'conflict') + error('IMPORTENSITEX_OPENEP: %s', strjoin(mapExports(iExport).errors, ' ')); + elseif strcmp(modes{iExport}, 'unknown') + if isempty(egmtype) + error(['IMPORTENSITEX_OPENEP: Recording mode could not be identified for ', ... + mapExports(iExport).folder, '. Specify egmtype explicitly.']); + end + modes{iExport} = lower(char(egmtype)); + warning(['IMPORTENSITEX_OPENEP: Recording mode was not identifiable from ', ... + 'content; using explicit egmtype=%s for %s.'], ... + modes{iExport}, mapExports(iExport).folder); + end + for iWarning = 1:numel(mapExports(iExport).warnings) + warning('IMPORTENSITEX_OPENEP: %s', mapExports(iExport).warnings{iWarning}); + end + if isempty(mapExports(iExport).geometryFile) + error('IMPORTENSITEX_OPENEP: Contact_Mapping_Model.xml was not found for %s.', ... + mapExports(iExport).folder); + end + if numel(mapExports(iExport).numPoints) ~= 1 + error('IMPORTENSITEX_OPENEP: Map files have inconsistent point counts in %s.', ... + mapExports(iExport).folder); + end + end + + locations{iMap,1} = strrep(rawMapNames{iMap}, sprintf('\t'), ' '); + locations{iMap,2} = {mapExports.folder}; + locations{iMap,3} = modes; + locations{iMap,4} = {mapExports.geometryFile}; + locations{iMap,5} = [mapExports.numPoints]; + locations{iMap,6} = exportIndices; +end + +variableNames = {'mapname', 'egmfiles', 'egmtype', 'mapfiles', 'numpts', 'exportindex'}; +T = cell2table(locations, 'variablenames', variableNames); + + + + + + + + +%% Identify the relevant subfolders + +% Naming of these folders needs to be done by the user either at the time +% of data export or afterwards. Although this is a manual step it avoids +% any ambiguity over which folder to import. Details are given in the SOP, +% "Instructions to convert Abbott Precision and EnSiteX data into OpenEP +% format" +% omniDir = local_findDirectory('omnipole', studyDir); +% bipDir = local_findDirectory('bipole', studyDir); +% uniDir = local_findDirectory('unipole', studyDir); + + + + + + +%% Ask the user which map they want to import +names = T.mapname; +numPtsPerMap = T.numpts; + +if isempty(mapToRead) + [selection,ok] = listdlg( 'ListString', names ... + , 'SelectionMode', 'single' ... + , 'PromptString', 'Which map do you want to access?' ... + , 'ListSize', [300 300] ... + ); + if ~ok + return + end + mapToRead = names{selection}; +else + if isnumeric(mapToRead) + selection = numel(find(numPtsPerMap==mapToRead)); + if numel(selection)>1 + error(['IMPORTENSITEX_OPENEP: Multiple maps with ' ... + num2str(mapToRead) ... + ' points identified. Use an alternative method to identify map.']); + elseif isempty(selection) + error(['IMPORTENSITEX_OPENEP: No map with ' ... + num2str(mapToRead) ... + ' points identified. Check the number of points specified is correct.']); + else + [selection, ~] = find(numPtsPerMap==mapToRead); + end + elseif ischar(mapToRead) + selection = find(strstartcmpi(mapToRead, names)); + end +end +mapID = selection; % calling it map ID to be more understandable. MapID maps into rows of T. + + + + + + + +%% Ask the user which mapping style they want to import, based on the available mapping styles +names = T(mapID, 'egmtype'); +names = names{1,:}; +uNames = unique(names); % convert to cell array and identify the unique names +if isempty(egmtype) + [selection,ok] = listdlg( 'ListString', uNames ... + , 'SelectionMode', 'single' ... + , 'PromptString', 'Which electrogram type do you want?' ... + , 'ListSize', [300 300] ... + ); + if ~ok + return + end + reqEgmType = uNames{selection}; +else + selection = find(strcmpi(egmtype, uNames)); + if isempty(selection) + selection = find(strstartcmpi(egmtype, uNames)); + end + if isempty(selection) + error(['IMPORTENSITEX_OPENEP: No electrogram type matching ' egmtype ' was found for map ' mapToRead]); + elseif numel(selection)>1 + error(['IMPORTENSITEX_OPENEP: Multiple electrogram types matching ' egmtype ' were found for map ' mapToRead]); + end + reqEgmType = uNames{selection}; +end +egmID = find(strcmpi(names, reqEgmType)); +% note that egmID by itself is not interpretable, but it indexes into T +% table entries to ensure that the desired electrograms are read +egmtype = reqEgmType; + +if numel(egmID)>1 + warningMessage = ['Multiple ' reqEgmType ' electrograms identified for map ' mapToRead '. Which folder of electrograms do you want to import?']; + warning(['IMPORTENSITEX_OPENEP: ' warningMessage]); + + names = T.egmfiles(1,egmID); + shortNames = local_lastTwoParts(names); + + [selection,ok] = listdlg( 'ListString', shortNames ... + , 'SelectionMode', 'single' ... + , 'PromptString', warningMessage ... + , 'ListSize', [600 300] ... + ); + if ~ok + return + end + egmID = egmID(selection); + egmtype = shortNames{selection}; +end + +selectedExportIndex = locations{mapID,6}(egmID); +selectedExport = ensiteManifest.exports(selectedExportIndex); + + + + + + + + +%% Parse the geometry and surface mapping data +% By loading the relevant Contact_Mapping_Model XML file to get the geometry + +contactMappingModel = selectedExport.geometryFile; +data_geometry = loadprecision_modelgroups(contactMappingModel); + +% switch maptype +% case 'bipolar' +% data_geometry = loadprecision_modelgroups(fullfile(bipDir, 'Contact_Mapping_Model.xml')); +% +% case 'omnipolar' +% data_geometry = loadprecision_modelgroups(fullfile(omniDir, 'Contact_Mapping_Model.xml')); +% +% case 'unipolar' +% data_geometry = loadprecision_modelgroups(fullfile(uniDir, 'Contact_Mapping_Model.xml')); +% +% case 'all' +% if isfolder(bipDir) +% data_geometry = loadprecision_modelgroups(fullfile(bipDir, 'Contact_Mapping_Model.xml')); +% elseif isfolder(omniDir) +% data_geometry = loadprecision_modelgroups(fullfile(omniDir, 'Contact_Mapping_Model.xml')); +% elseif isfolder(uniDir) +% data_geometry = loadprecision_modelgroups(fullfile(uniDir, 'Contact_Mapping_Model.xml')); +% end +% end + +TRI = data_geometry.dxgeo.triangles; +X = data_geometry.dxgeo.vertices(:,1); +Y = data_geometry.dxgeo.vertices(:,2); +Z = data_geometry.dxgeo.vertices(:,3); +tr = TriRep(TRI, X, Y, Z); +t.X = tr.X; +t.Triangulation = tr.Triangulation; +normals = data_geometry.dxgeo.normals; + + + + + + +%% Parse the mapping data according to the users wishes +% Note that in this section, any time we store mapping data we also must +% check the map status to determine whether values should be replaced by +% NaN values. +lenX = size(tr.X,1); +act = NaN(lenX,1); +bip = NaN(lenX,1); +uni = NaN(lenX,1); +mapData = []; +mapType = []; + +actSaved = false; +bipSaved = false; +uniSaved = false; + +switch maptype + case 'asegm' + if ~isempty(data_geometry.dxgeo.act) + act = data_geometry.dxgeo.act; + iStatus = data_geometry.dxgeo.map_status; + act(iStatus==2) = NaN; + actSaved = true; + end + if ~isempty(data_geometry.dxgeo.bip) + bip = data_geometry.dxgeo.bip; + iStatus = data_geometry.dxgeo.map_status; + bip(iStatus==2) = NaN; + bipSaved = true; + end + if ~isempty(data_geometry.dxgeo.uni) + uni = data_geometry.dxgeo.uni; + iStatus = data_geometry.dxgeo.map_status; + uni(iStatus==2) = NaN; + uniSaved = true; + end + if isfield(data_geometry.dxgeo, 'mapdata') + if ~isempty(data_geometry.dxgeo.mapdata) + mapData = data_geometry.dxgeo.mapdata; + mapType = data_geometry.dxgeo.maptype; + iStatus = data_geometry.dxgeo.map_status; + mapData(iStatus==2) = NaN; + end + end + + case 'all' + + % Lots of logic has to go into here - finding all XML files in + % folders or subfolders, loading these XML files, checking whether + % the geometry matches, if it does, load the corresponding map into + % the right place (act, bip, uni or mapData), removing values that + % should be NaN along the way. + + % First find all XML files in folder or subfolders + xmlFiles = local_findAllXmlFiles(studyDir); + + % Load all these XML files + for iXml = 1:numel(xmlFiles) + dataXml{iXml} = loadprecision_modelgroups(xmlFiles{iXml}); + end + + % Compare the geometry between the XML files and the existing geometry + % We define a match as an exact match of vetcies, triangles and + % normals. + for iXml = 1:numel(xmlFiles) + fileIsValid(iXml) = local_compareXmlFiles(dataXml{iXml}, data_geometry); + end + + % For every XML file that has a matching geometry, load the corresponding map + for iXml = 1:numel(xmlFiles) + dataIdentified = false; + if fileIsValid(iXml) + % First check for any of act, bip or uni + if ~isempty(dataXml{iXml}.dxgeo.act) + if ~actSaved + act = dataXml{iXml}.dxgeo.act; + iStatus = dataXml{iXml}.dxgeo.map_status; + act(iStatus==2) = NaN; + actSaved = true; + else + warning(['IMPORTENSITEX_OPENEP: Multiple local activation time surface maps identified. ...' ... + 'The first identified map comes from the file ', dataXml{iXml}.fileLoaded, ... + ' and is stored in .act_bip. The remaining maps are stored as surface properties.']); + mapData{end+1} = dataXml{iXml}.dxgeo.act; + mapType{end+1} = ['Additional LAT map ' num2str(numel(mapType))]; + iStatus = dataXml{iXml}.dxgeo.map_status; + mapData{end}(iStatus==2) = NaN; + end + dataIdentified = true; + + end + if ~isempty(dataXml{iXml}.dxgeo.bip) + if ~bipSaved + bip = dataXml{iXml}.bip; + iStatus = dataXml{iXml}.dxgeo.map_status; + bip(iStatus==2) = NaN; + bipSaved = true; + else + warning(['IMPORTENSITEX_OPENEP: Multiple bipolar voltage maps identified. ...' ... + 'The first identified map comes from the file ', dataXml{iXml}.fileLoaded, ... + ' and is stored in .act_bip. The remaining maps are stored as surface properties.']); + mapData{end+1} = dataXml{iXml}.dxgeo.bip; + mapType{end+1} = ['Additional BIP map ' num2str(nunmel(mapType))]; + iStatus = dataXml{iXml}.dxgeo.map_status; + mapData{end}(iStatus==2) = NaN; + end + dataIdentified = true; + + end + if ~isempty(dataXml{iXml}.dxgeo.uni) + if ~uniSaved + uni = dataXml{iXml}.uni; + iStatus = dataXml{iXml}.dxgeo.map_status; + uni(iStatus==2) = NaN; + uniSaved = true; + + else + warning(['IMPORTENSITEX_OPENEP: Multiple unipolar voltage maps identified. ...' ... + 'The first identified map comes from the file ', dataXml{iXml}.fileLoaded, ... + ' and is stored in .uni_imp_frc. The remaining maps are stored as surface properties.']); + mapData{end+1} = dataXml{iXml}.dxgeo.uni; + mapType{end+1} = ['Additional UNI map ' num2str(nunmel(mapType))]; + iStatus = dataXml{iXml}.dxgeo.map_status; + mapData{end}(iStatus==2) = NaN; + end + dataIdentified = true; + + end + + % Then check for any other mapping files + if ~dataIdentified + mapData{end+1} = dataXml{iXml}.dxgeo.mapdata; + mapType{end+1} = dataXml{iXml}.dxgeo.maptype; + + iStatus = dataXml{iXml}.dxgeo.map_status; + mapData{end}(iStatus==2) = NaN; + + end + else + warning(['IMPORTENSITEX_OPENEP: An XML mapping file which does ...' ... + 'not match the loaded geometry has been identified. File ...' ... + , dataXml{iXml}.fileLoaded ' will be ignored.']) + continue; + + end + end +end + +% switch maptype + % case 'bipolar' + % % we know we have a bipolar map of some sort, so we will check for + % % an activation map, a voltage map or any other maps. We know we + % % will not have a unipolar map so we will set uni to []; + % act = data_geometry.dxgeo.act; + % bip = data_geometry.dxgeo.bip; + % uni = []; + % mapData = data_geometry.dxgeo.mapdata; + % mapType = data_geometry.dxgeo.maptype; + % + % iStatus = data_geometry.dxgeo.map_status; + % act(iStatus==2) = NaN; + % bip(iStatus==2) = NaN; + % mapData(iStatus==2) = NaN; + % + % case 'omnipolar' + % % we know we will have an omnipolar map of some sort, but we will + % % not have a conventional bipolar LAT map, bipolar voltage map or + % % unipolar voltage map, so we will set act, uni and bip to []; + % act = []; + % bip = []; + % uni = []; + % mapData = data_geometry.dxgeo.mapdata; + % mapType = data_geometry.dxgeo.maptype; + % + % iStatus = data_geometry.dxgeo.map_status; + % mapData(iStatus==2) = NaN; + % + % case 'unipolar' + % % we know we will have a unipolar map of some sort, but we will not + % % have a convetional bipolar LAT map, or bipolar votlage map, so we + % % will check for a uni voltage map and set act and bip to[]; + % act = []; + % bip = []; + % uni = data_geometry.dxgeo.uni; + % mapData = data_geometry.dxgeo.mapdata; + % mapType = data_geometry.dxgeo.maptype; + % + % iStatus = data_geometry.dxgeo.map_status; + % uni(iStatus==2) = NaN; + % mapData(iStatus==2) = NaN; +% +% case 'all' +% act = []; +% bip = []; +% uni = []; +% mapData = []; +% mapType = []; +% +% % Lots of logic has to go into here - finding all XML files in +% % folders or subfolders, loading these XML files, checking whether +% % the geometry matches, if it does, load the corresponding map into +% % the right place (act, bip, uni or mapData), removing values that +% % should be NaN along the way. +% +% % First find all XML files in folder or subfolders +% xmlFiles = local_findAllXmlFiles(studyDir); +% +% % Load all these XML files +% for iXml = 1:numel(xmlFiles) +% dataXml{iXml} = loadprecision_modelgroups(xmlFiles{iXml}); +% end +% +% % Compare the geometry between the XML files and the existing geometry +% % We define a match as an exact match of vetcies, triangles and +% % normals. +% for iXml = 1:numel(xmlFiles) +% fileIsValid(iXml) = local_compareXmlFiles(dataXml{iXml}, data_geometry); +% end +% +% % For every XML file that has a matching geometry, load the corresponding map +% for iXml = 1:numel(xmlFiles) +% dataIdentified = false; +% if fileIsValid(iXml) +% % First check for any of act, bip or uni +% if ~isempty(dataXml{iXml}.dxgeo.act) +% if isempty(act) +% act = dataXml{iXml}.dxgeo.act; +% +% iStatus = dataXml{iXml}.dxgeo.map_status; +% act(iStatus==2) = NaN; +% +% else +% warning(['IMPORTENSITEX_OPENEP: Multiple local activation time surface maps identified. ...' ... +% 'The first identified map comes from the file ', dataXml{iXml}.fileLoaded, ... +% ' and is stored in .act_bip. The remaining maps are stored as surface properties.']); +% mapData{end+1} = dataXml{iXml}.dxgeo.act; +% mapType{end+1} = ['Additional LAT map ' num2str(nunmel(mapType))]; +% +% iStatus = dataXml{iXml}.dxgeo.map_status; +% mapData{end}(iStatus==2) = NaN; +% +% end +% dataIdentified = true; +% +% end +% if ~isempty(dataXml{iXml}.dxgeo.bip) +% if isempty(bip) +% bip = dataXml{iXml}.bip; +% +% iStatus = dataXml{iXml}.dxgeo.map_status; +% bip(iStatus==2) = NaN; +% +% else +% warning(['IMPORTENSITEX_OPENEP: Multiple bipolar voltage maps identified. ...' ... +% 'The first identified map comes from the file ', dataXml{iXml}.fileLoaded, ... +% ' and is stored in .act_bip. The remaining maps are stored as surface properties.']); +% mapData{end+1} = dataXml{iXml}.dxgeo.bip; +% mapType{end+1} = ['Additional BIP map ' num2str(nunmel(mapType))]; +% +% iStatus = dataXml{iXml}.dxgeo.map_status; +% mapData{end}(iStatus==2) = NaN; +% +% end +% dataIdentified = true; +% +% end +% if ~isempty(dataXml{iXml}.dxgeo.uni) +% if isempty(uni) +% uni = dataXml{iXml}.uni; +% +% iStatus = dataXml{iXml}.dxgeo.map_status; +% uni(iStatus==2) = NaN; +% +% else +% warning(['IMPORTENSITEX_OPENEP: Multiple unipolar voltage maps identified. ...' ... +% 'The first identified map comes from the file ', dataXml{iXml}.fileLoaded, ... +% ' and is stored in .uni_imp_frc. The remaining maps are stored as surface properties.']); +% mapData{end+1} = dataXml{iXml}.dxgeo.uni; +% mapType{end+1} = ['Additional UNI map ' num2str(nunmel(mapType))]; +% +% iStatus = dataXml{iXml}.dxgeo.map_status; +% mapData{end}(iStatus==2) = NaN; +% +% end +% dataIdentified = true; +% +% end +% +% % Then check for any other mapping files +% if ~dataIdentified +% mapData{end+1} = dataXml{iXml}.dxgeo.mapdata; +% mapType{end+1} = dataXml{iXml}.dxgeo.maptype; +% +% iStatus = dataXml{iXml}.dxgeo.map_status; +% mapData{end}(iStatus==2) = NaN; +% +% end +% else +% warning(['IMPORTENSITEX_OPENEP: An XML mapping file which does ...' ... +% 'not match the loaded geometry has been identified. File ...' ... +% , dataXml{iXml}.fileLoaded ' will be ignored.']) +% continue; +% +% end +% end +% end + +% IMP and FRC are not currently available through the EnsiteX export +% options + +imp = NaN(size(uni)); +frc = NaN(size(uni)); + +disp('!!!! FINISHED PARSING MAPPING DATA ACCCORDING TO USER WISHES !!!!') + + + + + + + + +%% Parse annotation metrics by loading the Map files + +mappingPointsFolder = selectedExport.folder; +isInFolder = cellfun(@(s) strcmp(fileparts(s.filename), mappingPointsFolder), ... + allCsvHeaders); +isMapFile = cellfun(@(s) ~strcmp(s.mapType, 'N/A'), allCsvHeaders); +csvHeaders = allCsvHeaders(isInFolder & isMapFile); +for iFile = 1:numel(csvHeaders) + [info, varnames, data] = loadensitex_dxldata( ... + csvHeaders{iFile}.filename, 'ShowProgress', showProgress); + mappingData{iFile}.info = info; + mappingData{iFile}.varnames = varnames; + mappingData{iFile}.data = data; +end + + +% work out the mapping points file names +% switch type +% case 'standard' +% mapCSV = 'Map_LAT_bi.csv'; +% voltCSV = 'Map_PP_bi.csv'; +% +% case 'omnipolar' +% mapCSV = 'Map_LAT_omni.csv'; +% voltCSV = 'Map_PP_omni.csv'; +% +% end +% %load the mapping points data +% latMapDir = []; %TEMP +% [info, varnames, data] = loadensitex_dxldata([latMapDir{:} filesep() 'Contact_Mapping' filesep() mapCSV]); +% mappingPoints.info = info; +% mappingPoints.varnames = varnames; +% mappingPoints.data = data; +% +% ppMapDir = []; % TEMP +% %additionally get the substrate mapping data for each point (there is unavoidable redundancy here) +% [info, varnames, data] = loadensitex_dxldata([ppMapDir{:} filesep() 'Contact_Mapping' filesep() voltCSV]); +% voltageData.info = info; +% voltageData.varnames = varnames; +% voltageData.data = data; +% +% % do some simple checks for compatibility between the PP and LAT datasets +% isError = false; +% if mappingPoints.info.numPoints ~= voltageData.info.numPoints +% warning('IMPORTENSITEX_OPENEP: Mismatch between number of points in the voltage and activation time datasets'); +% isError = true; +% end +% if ~strcmpi(mappingPoints.info.mapName, voltageData.info.mapName) +% warning('IMPORTENSITEX_OPENEP: Mismatch between map names in the voltage and activation time datasets'); +% isError = true; +% end +% if ~strcmpi(mappingPoints.info.study, voltageData.info.study) +% warning('IMPORTENSITEX_OPENEP: Mismatch between study names in the voltage and activation time datasets'); +% isError = true; +% end +% if isError +% error('IMPORTENSITEX_OPENEP: Error parsing data. See warnings above for hints'); +% end +% % TODO: there are likely to be other checks we could add in here +% +% % access the voltage data from the PP data and save along with the LAT data +% ppStr = 'P-P'; ppValidStr = 'P-P valid'; +% mappingPoints.varnames{end+1} = ppStr; +% mappingPoints.varnames{end+1} = ppValidStr; +% ppData = voltageData.data(:,strcmpi(voltageData.varnames, ppStr)); +% ppValidData = voltageData.data(:,strcmpi(voltageData.varnames, ppValidStr)); +% +% % concatenate +% mappingPoints.data = [mappingPoints.data ppData ppValidData]; + + + + + +%% Parse electrogram data by loading the Wave files + +wavesFolder = selectedExport.folder; + +% Get the reference and roving electrograms by semantic header role. +referenceWave = local_requireWaveRole(selectedExport, {'refs', 'reference'}); +[refInfo, refVarnames, refData] = loadensitex_dxldata( ... + referenceWave.path, 'ShowProgress', showProgress); + +rovingWave = local_requireWaveRole(selectedExport, {'rov', 'roving'}); +[rovInfo, rovVarnames, rovData] = loadensitex_dxldata( ... + rovingWave.path, 'ShowProgress', showProgress); + +componentInfo = {}; +componentVarnames = {}; +componentData = {}; +switch egmtype + case 'bi' + componentFiles = local_unipolarComponentFiles(selectedExport, 2); + case 'omni' + componentFiles = local_unipolarComponentFiles(selectedExport, 3); + case 'uni' + componentFiles = struct('role', {}, 'path', {}, 'source', {}); + otherwise + error('IMPORTENSITEX_OPENEP: Unsupported egmtype: %s', egmtype); +end + +for iComponent = 1:numel(componentFiles) + [componentInfo{iComponent}, componentVarnames{iComponent}, ... + componentData{iComponent}] = loadensitex_dxldata( ... %#ok + componentFiles(iComponent).path, 'ShowProgress', showProgress); +end + + + + + +% %% Now load the electrogram data by loading the Wave files (new equivalent of DxL files) +% +% % first load the rovinig trace (Wave_rov.csv), +% % next load the reference trace (Wave_ref.csv), +% % then load the unipolar electrograms, (Wave_uni_distal.csv, Wave_uni_along.csv), and +% % finally load any other wave files that are present +% +% %create loadedFiles boolean array to keep track of which wave files have already been loaded +% allFiles = nameFiles([latMapDir{:} filesep() 'Contact_Mapping']); +% waveFiles = find(~cellfun('isempty', regexp(allFiles, '^Wave', 'once'))); +% loadedFiles = false(size(waveFiles)); +% +% %get the reference electrograms +% thisFilename = 'Wave_refs.csv'; +% [refInfo, refVarnames, refData] = loadensitex_dxldata([latMapDir{:} filesep() 'Contact_Mapping' filesep() thisFilename]); +% iThisFile = find(~cellfun('isempty', regexp(allFiles, ['^' thisFilename], 'once'))); +% loadedFiles(iThisFile) = true; +% +% +% %get the roving bipolar electrograms +% thisFilename = 'Wave_rov.csv'; +% [rovInfo, rovVarnames, rovData] = loadensitex_dxldata([latMapDir{:} filesep() 'Contact_Mapping' filesep() thisFilename]); +% iThisFile = find(~cellfun('isempty', regexp(allFiles, ['^' thisFilename], 'once'))); +% loadedFiles(iThisFile) = true; +% +% %import the unipolar electrograms +% switch type +% case 'standard' +% %import uni distal +% thisFilename = 'Wave_uni_distal.csv' +% [uniDistInfo, uniDistVarnames, uniDistData] = loadensitex_dxldata([latMapDir{:} filesep() 'Contact_Mapping' filesep() thisFilename]); +% iThisFile = find(~cellfun('isempty', regexp(allFiles, ['^' thisFilename], 'once'))); +% loadedFiles(iThisFile) = true; +% +% %import uni proximal +% thisFilename = 'Wave_uni_proximal.csv'; +% [uniProxInfo, uniProxVarnames, uniProxData] = loadensitex_dxldata([latMapDir{:} filesep() 'Contact_Mapping' filesep() thisFilename]); +% iThisFile = find(~cellfun('isempty', regexp(allFiles, ['^' thisFilename], 'once'))); +% loadedFiles(iThisFile) = true; +% +% case 'omnipolar' +% %import uni across +% thisFilename = 'Wave_uni_across.csv'; +% [uniAcrossInfo, uniAcrossVarnames, uniAcrossData] = loadensitex_dxldata([latMapDir{:} filesep() 'Contact_Mapping' filesep() thisFilename]); +% iThisFile = find(~cellfun('isempty', regexp(allFiles, ['^' thisFilename], 'once'))); +% loadedFiles(iThisFile) = true; +% +% %import uni along +% thisFilename = 'Wave_uni_along.csv'; +% [uniAlongInfo, uniAlongVarnames, uniAlongData] = loadensitex_dxldata([latMapDir{:} filesep() 'Contact_Mapping' filesep() thisFilename]); +% iThisFile = find(~cellfun('isempty', regexp(allFiles, ['^' thisFilename], 'once'))); +% loadedFiles(iThisFile) = true; +% +% %import uni corner +% thisFilename = 'Wave_uni_corner.csv'; +% [uniCornerInfo, uniCornerVarnames, uniCornerData] = loadensitex_dxldata([latMapDir{:} filesep() 'Contact_Mapping' filesep() thisFilename]); +% iThisFile = find(~cellfun('isempty', regexp(allFiles, ['^' thisFilename], 'once'))); +% loadedFiles(iThisFile) = true; +% end + +% %check for any other wave or map files +% if any(~loadedFiles) +% % we have additional wave files, check whether to just load these or +% % ask the user what to do +% if loadallwavefiles +% extraFilesToLoad = allFiles(~loadedFiles); +% for iFile = 1:numel(extraFilesToLoad) +% if strcmpi(extraFilesToLoad{iFile}, 'Wave_refs2.csv') +% % This is to make sure that we do not attempt to load a +% % Wave_refs2.csv file, which for now seemt to be empty. +% % TODO: revisit Wave_refs2.csv files in the future if new +% % data is present +% continue +% else +% [extraFilesInfo{iFile}, extraFilesVarnames{iFile}, extraFilesData{iFile}] = loadensitex_dxldata([latMapDir{:} filesep() 'Contact_Mapping' filesep() extraFilesToLoad{iFile}]); +% end +% end +% else +% warning('IMPORTENSITEX_OPENEP: Extra wave files identified, but code to ask the user what to do has not yet been implemented. For now if you want access to these wavefiles, re-run this programme with the option loadallwavefiles set to TRUE'); +% end +% end + + + + + + + + +%% Calculate annotation times + +% first we need to find out which file stored in mappingData is labelled as +% a local activation time map. +isLAT = cellfun(@(s) contains(s.info.mapType, 'LAT'), mappingData); +if sum(isLAT)>1 + error(['IMPORTENSITEX_OPENEP: Too many local activation time mapping files found in folder ' wavesFolder '. Please ensure only one Map_LAT_*.csv file is present.']); +end + +% these are all in samples +refTick_adj = str2double(mappingData{isLAT}.data(:,strcmpi(mappingData{isLAT}.varnames, 'Ref Tick'))); % _adj because these already reflect the user adjustments +rovTick_adj = str2double(mappingData{isLAT}.data(:,strcmpi(mappingData{isLAT}.varnames, 'Rov Tick 1'))); + +%TODO: CHECK THESE TIMES ARE ADJUSTED APPROPRIATELY +leftCurtain = str2double(mappingData{isLAT}.data(:,strcmpi(mappingData{isLAT}.varnames, 'left curtain (ms)'))) / 1000 * rovInfo.sampleFreq; +rightCurtain = str2double(mappingData{isLAT}.data(:,strcmpi(mappingData{isLAT}.varnames, 'right curtain (ms)'))) / 1000 * rovInfo.sampleFreq; + +startTime_s = str2double(rovData(:,strcmpi(rovVarnames, 'startTime (abs)'))); % this comes from the roving wave file +refTime_s = str2double(mappingData{isLAT}.data(:,strcmpi(mappingData{isLAT}.varnames, 'refTime (abs)'))); % this comes from the mapping file +adjTime_ms = str2double(mappingData{isLAT}.data(:,strcmpi(mappingData{isLAT}.varnames, 'adjTime (ms)'))); % from the mapping file +lat_ms = str2double(mappingData{isLAT}.data(:,strcmpi(mappingData{isLAT}.varnames, 'LAT'))); % from the mapping file +adjTime_samples = adjTime_ms / 1000 * rovInfo.sampleFreq; + +% annot method time based +refAnnot_time = (refTime_s - startTime_s) * rovInfo.sampleFreq; +latAnnot_time = (refTime_s + lat_ms/1000) * rovInfo.sampleFreq; + +% annot method tick based +refAnnot_tick = refTick_adj; +latAnnot_tick = rovTick_adj; + +% adjust time YES +%now we need to do nothing to the tick marks +refAnnot_tick_adj = refAnnot_tick; +latAnnot_tick_adj = latAnnot_tick; + +%but we need to ADD the adjust time to the time-based times +refAnnot_time_adj = refAnnot_time + adjTime_samples; +latAnnot_time_adj = latAnnot_time + adjTime_samples; + +% adjust time NO +%now we need to subtract the adjust time (converted into samples) to the tick marks +refAnnot_tick_noadj = refAnnot_tick - adjTime_samples; +latAnnot_tick_noadj = latAnnot_tick - adjTime_samples; + +%but we do not need to do anything to the time based times +refAnnot_time_noadj = refAnnot_time; +latAnnot_time_noadj = latAnnot_time; + +%TODO now we have annotations using all the methods we can check them *** + +%Finally save the desired annotations +annotMethod = 'tickbased'; +adjustTimes = 'no'; +if strcmpi(annotMethod, 'timebased') && strcmpi(adjustTimes, 'yes') + refAnnot = refAnnot_time_adj; + latAnnot = latAnnot_time_adj; +end +if strcmpi(annotMethod, 'timebased') && strcmpi(adjustTimes, 'no') + refAnnot = refAnnot_time_noadj; + latAnnot = latAnnot_time_noadj; +end +if strcmpi(annotMethod, 'tickbased') && strcmpi(adjustTimes, 'yes') + refAnnot = refAnnot_tick_adj; + refAnnot = refAnnot_tick_adj; +end +if strcmpi(annotMethod, 'tickbased') && strcmpi(adjustTimes, 'no') + refAnnot = refAnnot_tick_noadj; + latAnnot = latAnnot_tick_noadj; +end + +% Calculate the windows of interest +leftCurtain = str2double(mappingData{isLAT}.data(:,strcmpi(mappingData{isLAT}.varnames, 'left curtain (ms)'))) / 1000 * rovInfo.sampleFreq; +rightCurtain = str2double(mappingData{isLAT}.data(:,strcmpi(mappingData{isLAT}.varnames, 'right curtain (ms)'))) / 1000 * rovInfo.sampleFreq; + + + + + + + + +%% Calculate voltages + +isPP = cellfun(@(s) contains(s.info.mapType, 'PP'), mappingData); +if sum(isPP)>1 + error(['IMPORTENSITEX_OPENEP: Too many peak to peak mapping files found in folder ' wavesFolder '. Please ensure only one Map_PP_*.csv file is present.']); +end + +bipolarVoltages = str2double(mappingData{isPP}.data(:,strcmpi(mappingData{isPP}.varnames, 'P-P'))); +includeFlag = str2double(mappingData{isPP}.data(:,strcmpi(mappingData{isPP}.varnames, 'utilized'))); +pointNumberFromFile = mappingData{isPP}.data(:,strcmpi(mappingData{isPP}.varnames, '(Point #)')); + + + + + + + + +%% Save all data in the OpenEP format + +% General data +userdata = openep_createuserdata(); +userdata.systemName = 'ensitex'; +userdata.notes{1} = [date() ': Created']; + +% this is the directory containing the Contact_Mapping folder that the +% electrograms came from; noting that Contact_Mapping_Model.xml files might +% have been parsed from adjacent directories. +userdata.notes{end+1} = [date() ': userdata.ensitexFolder stores the directory containing the Contact_Mapping folder that was parsed. Additional Contact_Mapping_Model.xml files might have been parsed from adjacent directories.']; +userdata.ensitexFolder = fileparts(T(mapID,:).egmfiles{egmID}); +userdata.electric.sampleFrequency = rovInfo.sampleFreq; + +% Geometry +userdata.surface.triRep = t; +userdata.surface.normals = normals; + +surfaceOfOrigin = data_geometry.dxgeo.surface_of_origin; +userdata = setSurfaceProperty(userdata, 'name', 'surfaceOfOrigin', 'map', surfaceOfOrigin, 'definedOn', 'elements'); + + +% Surface maps, removing invalid data beyond interpolation distance +userdata.surface.act_bip = [act bip]; +userdata.surface.uni_imp_frc = [uni imp frc]; + +% Electric data - this should be refactored and moved higher in the code. +% In this section we should only have pre-calculated variables and be +% storing them in userdata, for clarity. +userdata.electric.electrodeNames_bip = mappingData{isLAT}.data(:,strcmpi(mappingData{isLAT}.varnames, 'Rov trace')); +userdata.electric.egmX = [str2double(mappingData{isLAT}.data(:,strcmpi(mappingData{isLAT}.varnames, 'roving x'))) ... + str2double(mappingData{isLAT}.data(:,strcmpi(mappingData{isLAT}.varnames, 'roving y'))) ... + str2double(mappingData{isLAT}.data(:,strcmpi(mappingData{isLAT}.varnames, 'roving z')))]; +userdata.electric.egmSurfX = [str2double(mappingData{isLAT}.data(:,strcmpi(mappingData{isLAT}.varnames, 'surface x'))) ... + str2double(mappingData{isLAT}.data(:,strcmpi(mappingData{isLAT}.varnames, 'surface y'))) ... + str2double(mappingData{isLAT}.data(:,strcmpi(mappingData{isLAT}.varnames, 'surface z')))]; +userdata.electric.egmRef = local_concatdata(refData(:,strcmpi(refVarnames,'signals')) ... + ,refData(:,strcmpi(refVarnames,'Freeze Grp #')) ... + ,rovData(:,strcmpi(rovVarnames,'Freeze Grp #')) ... + ,refInfo.filename); +userdata.electric.egm = local_concatdata(rovData(:,strcmpi(rovVarnames,'signals')),[],[],rovInfo.filename); + +userdata.electric.annotations.referenceAnnot = refAnnot; +userdata.electric.annotations.mapAnnot = latAnnot; +userdata.electric.annotations.woi = leftCurtain; +userdata.electric.annotations.woi(:,2) = rightCurtain; + +%userdata.electric.annotations.woi = str2double(mappingPoints.data(:,strcmpi(mappingPoints.varnames, 'left curtain (ms)'))) / 1000 * userdata.electric.sampleFrequency; +%userdata.electric.annotations.woi(:,2) = str2double(mappingPoints.data(:,strcmpi(mappingPoints.varnames, 'right curtain (ms)'))) / 1000 * userdata.electric.sampleFrequency; + +userdata.electric.voltages.bipolar = bipolarVoltages; +userdata.electric.include = includeFlag; +userdata.electric.names = pointNumberFromFile; + +% userdata.electric.voltages.bipolar = str2double(mappingPoints.data(:,strcmpi(mappingPoints.varnames, 'P-P'))); +% userdata.electric.include = str2double(mappingPoints.data(:,strcmpi(mappingPoints.varnames, 'utilized'))); +% userdata.electric.names = mappingPoints.data(:,strcmpi(mappingPoints.varnames, '(Point #)')); + +% Unipolar electrograms +userdata.electric.electrodeNames_uni = local_parseuninames(mappingData{isLAT}.data(:,strcmpi(mappingData{isLAT}.varnames,'Electrodes'))); +if ~isempty(componentData) + [componentInfo, componentVarnames, componentData] = ... + local_orderComponentsByElectrode(componentInfo, componentVarnames, ... + componentData, mappingData{isLAT}, ... + userdata.electric.electrodeNames_uni); +end + +% Unipolar electrogram locations are stored differently for standard and omnipolar configurations +switch egmtype + case 'bi' + disp('IMPORTENSITEX_OPENEP: Parsing unipolar co-ordinates for bipolar configuration ...'); + + warning('IMPORTENSITEX_OPENEP: When a map is exported we are not given the individual unipole co-ordinates ... assuming uni distal and uni proximal are at the same location') + userdata.electric.egmUniX = cat(3, userdata.electric.egmX, userdata.electric.egmX); + + disp('IMPORTENSITEX_OPENEP: Parsing unipolar electrograms for bipolar configuration ...'); + userdata.electric.egmUni = zeros( ... + size(userdata.electric.egm, 1), size(userdata.electric.egm, 2), 2); + for iComponent = 1:2 + userdata.electric.egmUni(:,:,iComponent) = local_concatdata( ... + componentData{iComponent}(:,strcmpi(componentVarnames{iComponent},'signals')), ... + [], [], componentInfo{iComponent}.filename); + end + + case 'omni' + disp('IMPORTENSITEX_OPENEP: Parsing unipolar co-ordinates for omnipolar configuration ...'); + + % The third dimension is ordered corner, along, across. + userdata.electric.egmUniX = zeros( ... + size(userdata.electric.egmX, 1), 3, 3); + for iPoint = 1:size(userdata.electric.electrodeNames_uni,1) + if strcmpi(userdata.electric.electrodeNames_uni{iPoint,1}, mappingData{isLAT}.data(iPoint,strcmpi(mappingData{isLAT}.varnames,'Uni_Corner_Elec'))) + X = mappingData{isLAT}.data(iPoint,strcmpi(mappingData{isLAT}.varnames,'Uni_CornerX')); + Y = mappingData{isLAT}.data(iPoint,strcmpi(mappingData{isLAT}.varnames,'Uni_CornerY')); + Z = mappingData{isLAT}.data(iPoint,strcmpi(mappingData{isLAT}.varnames,'Uni_CornerZ')); + userdata.electric.egmUniX(iPoint,1:3,1) = str2double([X Y Z]); + else + error('IMPORTENSITEX_OPENEP: Electrode naming mismatch') + end + if strcmpi(userdata.electric.electrodeNames_uni{iPoint,2}, mappingData{isLAT}.data(iPoint,strcmpi(mappingData{isLAT}.varnames,'Uni_Along_Elec'))) + X = mappingData{isLAT}.data(iPoint,strcmpi(mappingData{isLAT}.varnames,'Uni_AlongX')); + Y = mappingData{isLAT}.data(iPoint,strcmpi(mappingData{isLAT}.varnames,'Uni_AlongY')); + Z = mappingData{isLAT}.data(iPoint,strcmpi(mappingData{isLAT}.varnames,'Uni_AlongZ')); + userdata.electric.egmUniX(iPoint,1:3,2) = str2double([X Y Z]); + else + error('IMPORTENSITEX_OPENEP: Electrode naming mismatch') + end + if strcmpi(userdata.electric.electrodeNames_uni{iPoint,3}, mappingData{isLAT}.data(iPoint,strcmpi(mappingData{isLAT}.varnames,'Uni_Across_Elec'))) + X = mappingData{isLAT}.data(iPoint,strcmpi(mappingData{isLAT}.varnames,'Uni_AcrossX')); + Y = mappingData{isLAT}.data(iPoint,strcmpi(mappingData{isLAT}.varnames,'Uni_AcrossY')); + Z = mappingData{isLAT}.data(iPoint,strcmpi(mappingData{isLAT}.varnames,'Uni_AcrossZ')); + userdata.electric.egmUniX(iPoint,1:3,3) = str2double([X Y Z]); + else + error('IMPORTENSITEX_OPENEP: Electrode naming mismatch') + end + end + + userdata.electric.egmUni = zeros( ... + size(userdata.electric.egm, 1), size(userdata.electric.egm, 2), 3); + for iComponent = 1:3 + userdata.electric.egmUni(:,:,iComponent) = local_concatdata( ... + componentData{iComponent}(:,strcmpi(componentVarnames{iComponent},'signals')), ... + [], [], componentInfo{iComponent}.filename); + end + + %userdata.electric.egmUniSurfX = userdata.electric.egmUniX; % note that we do not have co-ordinates for the second unipole + disp('IMPORTENSITEX_OPENEP: Finished parsing unipolar co-ordinates ...'); + + case 'uni' + userdata.electric.egmUni = userdata.electric.egm; + userdata.electric.egmUniX = userdata.electric.egmX; +end +local_validateEgmLayout(userdata, egmtype); + +% % Store any additional signals in the ecg array +% disp('dealing with ECG electrograms') +% userdata.electric.ecgNames = {}; +% if ~isempty(extraFilesInfo) +% % for speed first work out the dimensions and pre-populate +% fWait = waitbar(0, 'Storing additional ECG names'); +% for iEF = 1:numel(extraFilesInfo) +% if any(strcmpi(extraFilesVarnames{iEF}, 'signals')) +% % Then, this extra file contains signal data - store these in +% % the ECG array. +% +% % First check for unique electrode names in this file +% electrodes = unique(extraFilesData{iEF}(:,1)); +% +% % Remove any non-ASCII characters, leading or trailing spaces +% % and duplicate rows (cElectrodes for 'clean electrodes') +% cElectrodes = unique(cellfun(@(s) strtrim(regexprep(s, '[^\x00-\x7F]', '')), electrodes, 'UniformOutput', false)); +% +% % Add electrode names to the ecgNames cell array +% userdata.electric.ecgNames = union(userdata.electric.ecgNames, cElectrodes); +% else +% % Then, this extra file does not contain signal data. If the +% % file has not already been imported (we do not yet have a +% % check for this) then it is likely to be an additional mapping +% % file. Do nothing for the time being. +% end +% waitbar(iEF/numel(extraFilesInfo),fWait); +% end +% close(fWait); +% +% % prepopulate for speed +% userdata.electric.ecg = zeros([size(userdata.electric.egm) size(userdata.electric.ecgNames,1)]); +% +% fWait = waitbar(0, 'Storing additional ECG data'); +% for iEF = 1:numel(extraFilesInfo) +% % check if this is a signals file +% if any(strcmpi(extraFilesVarnames{iEF}, 'signals')) +% % Next we iterate through every signal and work out where to +% % put it in the ECG array. +% +% for jSg = 1:size(extraFilesData{iEF},1) +% thisSig = extraFilesData{iEF}(jSg,strcmpi(extraFilesVarnames{iEF},'signals')); +% thisName = extraFilesData{iEF}(jSg,strcmpi(extraFilesVarnames{iEF},'Trace')); +% +% % clean the name +% thisName = strtrim(regexprep(thisName, '[^\x00-\x7F]', '')); +% +% userdata.electric.ecg(jSg,:,strcmpi(userdata.electric.ecgNames, thisName)) = thisSig{:}; +% end +% else +% % Then, this extra file does not contain signal data. If the +% % file has not already been imported (we do not yet have a +% % check for this) then it is likely to be an additional mapping +% % file. Do nothing for the time being. +% end +% waitbar(iEF/numel(extraFilesInfo),fWait); +% end +% close(fWait); +% end + +% set up the surface normals +tr = getMesh(userdata, 'triangulation'); +[closestVertices,~] = findclosestvertex(tr, userdata.electric.egmX, true); +userdata.electric.barDirection = userdata.surface.normals(closestVertices,:); + +% we don't have impedance values, so create NaN values +userdata.electric.impedances.time = cell(7110,1); +userdata.electric.impedances.value = cell(7110,1); +[userdata.electric.impedances.value{:}] = deal(NaN); +[userdata.electric.impedances.time{:}] = deal(NaN); + +% we don't have the unipolar peak to peak voltages so we have to do something +userdata.electric.voltages.unipolar = NaN(size(userdata.electric.voltages.bipolar)); +userdata.electric.voltages.unipolar = calculatePeak2PeakVoltage( userdata.electric.egmUni, userdata.electric.annotations.referenceAnnot, userdata.electric.annotations.woi ); + +% Temp - remove signalMaps which, if empty, prevents the file being loaded in EP Workbench +userdata.surface = rmfield(userdata.surface, 'signalMaps'); +userdata.electric.tags = cell(length(userdata.electric.names),1); + + + + + + + + + + +%% Encourage user to save the data +matFileFullPath = []; +if ~saveOutput + matFileFullPath = []; +elseif ~isempty(saveFileName) + save(saveFileName, 'userdata'); + matFileFullPath = saveFileName; +else + defaultName = [mappingData{isLAT}.info.study '_' mapToRead]; + defaultName(isspace(defaultName)) = '_'; + originalDir = cd(); + matFileFullPath = fullfile(saveDir, defaultName); %default + cd(saveDir); + [filename,saveDir] = uiputfile('*.mat', 'Save the userdata to disc for future rapid access?',defaultName); + cd(originalDir); + % We save as -v7 because it's faster to load in OpenEP-py than -v7.3, + % and the saved file is significantly smaller compared to -v6 files. + if filename ~= 0 + save([saveDir filename], 'userdata','-v7.3'); + matFileFullPath = fullfile(saveDir, filename); + end +end + + + + + + + + + + +%% Local functions + + function [info, varnames, data] = local_concatenatedxldatasets(inInfo, inVarnames, inData) + % LOCAL_CONCATENATEDXLDATASETS is a function which combines + % datasets together into a single set of structures + % How many datasets are there + + disp('IMPORTENSITEX_OPENEP: concatenating data using local_concatenatedxldatasets') + numDatasets = numel(inInfo); + + % Concatenate the info data. + info = inInfo{1}; + + header = info.header; + info.header = []; + info.header{1} = header; + + fname = info.filename; + info.filename = []; + info.filename{1} = fname; + + for iD = 2:numDatasets % D for dataset + info.numPoints = info.numPoints + inInfo{iD}.numPoints; + info.header{iD} = inInfo{iD}.header; + info.filename{iD} = inInfo{iD}.filename; + end + + info.header = info.header'; + info.filename = info.filename'; + + % TODO: Add checks to make sure no other important aspects of rovInfoA and rovInfoB change. + + % Concatenate the varnames data. + % Save the first varnames, but check with the subsequent + % varnames for any differences. Throw an error if found. + varnames = inVarnames{1}; + for iD = 2:numDatasets + if ~comparestructure(inVarnames{iD}, inVarnames{iD-1}) + error('IMPORTENSITEX_OPENEP: Problem with source data - roving variable names changes between wave_across and wave_along'); + end + end + + % Concatenate the data + data = inData{1}; + for iD = 2:numDatasets + data = [data; inData{iD}]; + end + end + + function pathName = local_findDirectory(stub, studyDir) + allSubFolders = nameFolds(studyDir); + tf = cellfun(@(p) contains(p, stub, 'IgnoreCase', true), allSubFolders); + thisFolder = allSubFolders(tf); + % Check if more than one folder meets the critiera, and ask the user to choose + if numel(thisFolder)>1 + warning(['IMPORTENSITEX_OPENEP: More than one candidate folder selected for the export of ***' stub '*** data. Please choose one folder ...']) + [indx, tf] = listdlg('ListString', thisFolder ... + ,'ListSize', [480 300] ... + , 'name', ['Which is the correct ***' stub '*** folder?'] ... + , 'selectionmode', 'single' ... + ); + if ~tf + error('IMPORTENSITEX_OPENEP: Operation cancelled') + else + thisFolder = thisFolder{indx}; + end + end + pathName = fullfile(studyDir, thisFolder); + pathName = pathName{:}; + end + + function hd = local_homedirec() + %HOMEDIREC returns the user's home directory. + + if ispc + hd = [getenv('HOMEDRIVE') getenv('HOMEPATH')]; + else + hd = getenv('HOME'); + end + end + + function matrixData = local_concatdata(cellData, freezeGroupIn, freezeGroupOut, dataName) + % This function concatenates cell data into a matrix, optionally + % based on the ordering specified by freezeGroupIn and freezeGroupOut + f = waitbar(0, ['Reorganising data for:' dataName]); + if isempty(freezeGroupIn) + repMatrix = 1:numel(cellData); + else + freezeGroupIn = str2double(freezeGroupIn); + freezeGroupOut = str2double(freezeGroupOut); + for iGrp = 1:numel(freezeGroupOut) + repMatrix(iGrp,1) = find(freezeGroupIn==freezeGroupOut(iGrp)); + end + end + cellDataNew = cellData(repMatrix); + nCell = numel(cellDataNew); + + % pre-allocate for speed + matrixData = zeros(nCell,size(cellDataNew{1},2)); % we assume that all cells have the same length + % matrixData = cellDataNew{1}; + for iCell = 1:nCell + matrixData(iCell,:) = cellDataNew{iCell}; + waitbar(iCell/nCell, f); + end + + % destroy the waitbar + close(f) + end + + function uniNames = local_parseuninames(A) + % This function creates an Nx2 cell array for storing the unipole + % names + nPairs = size(A,1); + %uniNames = cell(nPairs,2); + for iPair = 1:nPairs + splt = strsplit(A{iPair}); + for j = 1:numel(splt) + if ~isempty(splt{j}) + uniNames{iPair,j} = splt{j}; + end + end + % uniNames{iPair,1} = splt{1}; + % uniNames{iPair,2} = splt{2}; + end + end + + function waveFile = local_requireWaveRole(exportInfo, acceptedRoles) + roles = {exportInfo.waveFiles.role}; + isAccepted = false(size(roles)); + for iRole = 1:numel(acceptedRoles) + isAccepted = isAccepted | strcmpi(roles, acceptedRoles{iRole}); + end + matches = find(isAccepted); + if isempty(matches) + error('IMPORTENSITEX_OPENEP: Required wave role was not found: %s', ... + strjoin(acceptedRoles, ' or ')); + elseif numel(matches) > 1 + error('IMPORTENSITEX_OPENEP: Multiple files provide wave role: %s', ... + strjoin(acceptedRoles, ' or ')); + end + waveFile = exportInfo.waveFiles(matches); + end + + function componentFiles = local_unipolarComponentFiles(exportInfo, expectedCount) + roles = {exportInfo.waveFiles.role}; + componentFiles = exportInfo.waveFiles(startsWith(roles, 'uni_', ... + 'IgnoreCase', true)); + if numel(componentFiles) ~= expectedCount + error(['IMPORTENSITEX_OPENEP: Expected %d unipolar component ', ... + 'wave files for egmtype=%s, found %d.'], ... + expectedCount, egmtype, numel(componentFiles)); + end + end + + function [orderedInfo, orderedVarnames, orderedData] = ... + local_orderComponentsByElectrode(inInfo, inVarnames, inData, ... + latMap, electrodeNames) + nComponents = numel(inData); + if size(electrodeNames, 2) ~= nComponents + error(['IMPORTENSITEX_OPENEP: Map electrode count (%d) does not ', ... + 'match component wave file count (%d).'], ... + size(electrodeNames, 2), nComponents); + end + + mapPointColumn = find(strcmpi(latMap.varnames, '(Point #)'), 1); + if isempty(mapPointColumn) + error('IMPORTENSITEX_OPENEP: LAT map does not contain (Point #).'); + end + mapPoints = local_stringValues(latMap.data(:, mapPointColumn)); + scores = zeros(nComponents, nComponents); + + for iComponentFile = 1:nComponents + traceColumn = find(strcmpi(inVarnames{iComponentFile}, 'Trace'), 1); + pointColumn = find(strcmpi(inVarnames{iComponentFile}, '(Point #)'), 1); + if isempty(traceColumn) || isempty(pointColumn) + error(['IMPORTENSITEX_OPENEP: Component wave file lacks ', ... + 'Trace or (Point #): %s'], inInfo{iComponentFile}.filename); + end + traces = local_stringValues(inData{iComponentFile}(:, traceColumn)); + wavePoints = local_stringValues(inData{iComponentFile}(:, pointColumn)); + [isMatched, mapRows] = ismember(wavePoints, mapPoints); + + for iElectrode = 1:nComponents + expected = electrodeNames(mapRows(isMatched), iElectrode); + observed = traces(isMatched); + scores(iComponentFile, iElectrode) = sum(cellfun( ... + @local_electrodeMatches, observed, expected)); + end + end + + assignments = perms(1:nComponents); + assignmentScores = zeros(size(assignments, 1), 1); + for iAssignment = 1:size(assignments, 1) + for iElectrode = 1:nComponents + assignmentScores(iAssignment) = assignmentScores(iAssignment) + ... + scores(assignments(iAssignment, iElectrode), iElectrode); + end + end + bestScore = max(assignmentScores); + bestRows = find(assignmentScores == bestScore); + if bestScore == 0 || numel(bestRows) ~= 1 + error(['IMPORTENSITEX_OPENEP: Component wave files could not be ', ... + 'matched unambiguously to map electrode labels.']); + end + + order = assignments(bestRows, :); + orderedInfo = inInfo(order); + orderedVarnames = inVarnames(order); + orderedData = inData(order); + end + + function values = local_stringValues(values) + values = cellstr(strtrim(string(values))); + end + + function tf = local_electrodeMatches(observed, expected) + observed = strtrim(char(observed)); + expected = strtrim(char(expected)); + tf = strcmpi(observed, expected) || ... + endsWith(observed, [' ', expected], 'IgnoreCase', true); + end + + function local_validateEgmLayout(userdataIn, recordingMode) + if ~ismember(recordingMode, {'bi', 'omni'}) + return + end + + nComponents = 2; + if strcmp(recordingMode, 'omni') + nComponents = 3; + end + + electric = userdataIn.electric; + nPoints = size(electric.egmX, 1); + if size(electric.egm, 1) ~= nPoints + error(['IMPORTENSITEX_OPENEP: egm row count (%d) does not ', ... + 'match mapping point count (%d).'], ... + size(electric.egm, 1), nPoints); + end + + expectedEgmUniSize = [nPoints, ... + size(electric.egm, 2), nComponents]; + if ~isequal(size(electric.egmUni), expectedEgmUniSize) + error(['IMPORTENSITEX_OPENEP: egmUni must have size ', ... + 'numPoints-by-numSamples-by-%d for egmtype=%s; found %s.'], ... + nComponents, recordingMode, mat2str(size(electric.egmUni))); + end + + expectedCoordinateSize = [nPoints, 3, nComponents]; + if ~isequal(size(electric.egmUniX), expectedCoordinateSize) + error(['IMPORTENSITEX_OPENEP: egmUniX must have size ', ... + 'numPoints-by-3-by-%d for egmtype=%s; found %s.'], ... + nComponents, recordingMode, mat2str(size(electric.egmUniX))); + end + + expectedNameSize = [nPoints, nComponents]; + if ~isequal(size(electric.electrodeNames_uni), expectedNameSize) + error(['IMPORTENSITEX_OPENEP: electrodeNames_uni must have size ', ... + 'numPoints-by-%d for egmtype=%s; found %s.'], ... + nComponents, recordingMode, ... + mat2str(size(electric.electrodeNames_uni))); + end + end + + function xmlFiles = local_findAllXmlFiles(parentDirectory) + % local_findAllXmlFiles Recursively finds all .xml files under parentDirectory. + + % Use dir with recursive wildcard + fileList = dir(fullfile(parentDirectory, '**', '*.xml')); + + % Filter hidden files + fileList = fileList(~startsWith({fileList.name}, '.')); + + % Extract full paths into a cell array + xmlFiles = fullfile({fileList.folder}, {fileList.name}); + end + + function tf = local_compareXmlFiles(S1, S2) + % compareMeshStructs Compare two mesh structures containing dxgeo subfields. + % + % Returns true only if S1.dxgeo and S2.dxgeo both contain the fields + % 'vertices', 'triangles', and 'normals', and all three arrays are exactly equal. + + % Required subfields within dxgeo + requiredFields = {'vertices', 'triangles', 'normals'}; + + % Check dxgeo exists in both structures + if ~isfield(S1, 'dxgeo') || ~isfield(S2, 'dxgeo') + tf = false; + return; + end + + % Check required subfields exist + for k = 1:numel(requiredFields) + f = requiredFields{k}; + if ~isfield(S1.dxgeo, f) || ~isfield(S2.dxgeo, f) + tf = false; + return; + end + end + + % Compare arrays for exact equality + tf = isequal(S1.dxgeo.vertices, S2.dxgeo.vertices) && ... + isequal(S1.dxgeo.triangles, S2.dxgeo.triangles) && ... + isequal(S1.dxgeo.normals, S2.dxgeo.normals); + end + + function out = local_lastTwoParts(paths) + % lastTwoParts returns the last two parts of each path in a cell array + % paths: 1×N or N×1 cell array of full paths + % out: 1×N cell array of "secondLastPart/lastPart" + + if ischar(paths) || isstring(paths) + paths = {char(paths)}; + elseif ~iscell(paths) + error('Input must be char, string, or cell array of char/string.'); + end + + N = numel(paths); + out = cell(1, N); + + for k = 1:N + p = paths{k}; + + % Get the last part + [~, lastPart, ext] = fileparts(p); + lastPartFull = [lastPart ext]; % include extension if any + + % Get the second-to-last folder + [parentFolder, secondLastPart] = fileparts(fileparts(p)); + + % Combine + out{k} = fullfile(secondLastPart, lastPartFull); + end + end + + + + + +end diff --git a/importprecision.m b/importprecision.m index 01dd9ad..5287f6b 100644 --- a/importprecision.m +++ b/importprecision.m @@ -126,7 +126,7 @@ desiredFileNames = {desiredFileNames}; end for iGTF = 1:length(d) - if strcmp(d(iGTF).name,'.') || strcmp(d(iGTF).name,'..') || d(iGTF).isdir + if strcmp(d(iGTF).name,'.') || strcmp(d(iGTF).name,'..') || d(iGTF).isdir || strstartcmp('._', d(iGTF).name) %do nothing else %check we have a match with listed filenames diff --git a/importprecision_dxldata.m b/importprecision_dxldata.m index acdda61..58033fd 100644 --- a/importprecision_dxldata.m +++ b/importprecision_dxldata.m @@ -278,7 +278,9 @@ Zero rovLAT by min(refLAT). Default=true % Save remaining potentially useful data pts_fieldnames = {'ptnumber', 'rovingx', 'rovingy', 'rovingz',... 'surfPtx', 'surfPty', 'surfPtz',... - 'rovLAT', 'refLAT', 'peak2peak', 'peakneg', 'endtime', 'CFEmean', 'CFEstddev'}; + 'rovLAT', 'refLAT', 'peak2peak', 'peakneg', 'endtime', 'CFEmean', 'CFEstddev' ... + 'utilized', 'displayed' ... + }; for iFieldname = 1:length(pts_fieldnames) data(i_file).(pts_fieldnames{iFieldname}) = [pts(:).(pts_fieldnames{iFieldname})]; end diff --git a/importprecision_openep.m b/importprecision_openep.m new file mode 100644 index 0000000..31331b6 --- /dev/null +++ b/importprecision_openep.m @@ -0,0 +1,271 @@ +function [userdata, matFileFullPath] = importprecision_openep(varargin) +% IMPORTPRECISION_OPENEP provides a data structure from multiple Precision files. +% Usage: +% userdata = importprecision_openep(userinput) +% userdata = importprecision_openep() +% [userdata, matFileFullPath] = ... +% Where: +% dirName is the directory with all of the files corresponding to a map +% userdata is a single data structure +% matFileFullPath is the path to the .mat file, if opened or saved +% +% IMPORTCARTO_MEM accepts the following parameter-value pairs +% 'refchannel' {''}|string +% The name of the channel to pick as the refence channel. Typically +% this is the pacing channel for the map. Specify a string such as +% 'CS9-CS10'. +% 'ecgchannel' {''}|string +% The name of the channel to pick as the ECG channel. Typically +% this is an informative ECG such as V1. Specify a string such as +% 'V1'. +% 'savefilename' {''}|string +% The full path to the location in which to save the output. +% +% Example of command line entry ... +% userdata = importprecision_openep(, ... +% 'refchannel', 'CS9-CS10', ... +% 'ecgchannel', 'V1') +% +% userdata structure ... +% .surface +% .triRep - TriRep object for the surface +% .isVertexAtRim - logical array indicating vertices at a 'rim' +% .act_bip - nVertices*2 array of activation and voltage data +% .uni_imp_frc - nVertices*3 array of uni voltage, impedance and contact force +% .electric +% .isPointLocationOnly - logical array +% .tags +% .names +% .egmX - location of point +% .egmSurfX - location of surface nearest point +% .barDirection - normal to surface at egmSurfX +% .egm - bipolar electrogram +% .egmUni - matrix of unipolar electrograms +% .egmUniX - location of unipolar points +% .egmRefNames - names of egmRef +% .egmRef - electrogram of reference +% .ecgNames - ecg names (or other channel names) +% .ecg - ecg +% .force +% .force - instantaneous force recording +% .axialAngle - axial angle +% .lateralAngle - lateral angle +% .time_force - time course of force [(:,:,1)=time, (:,:,2)=force] +% .time_axial - time course of axial angle [(:,:,1)=time, (:,:,2)=axial angle] +% .time_lateral - time course of lateral angle [(:,:,1)=time, (:,:,2)=lateral angle] + +% Author: Steven Williams (2025) +% SPDX-License-Identifier: Apache-2.0 +% +% --------------------------------------------------------------- +% testing +% --------------------------------------------------------------- + +% --------------------------------------------------------------- +% code +% --------------------------------------------------------------- + +%% Identify the folder containing the exported files +persistent saveDir homeDir +if isempty(saveDir) || ~ischar(saveDir) || ~isfolder(saveDir) + saveDir = local_homedirec(); +end +if isempty(homeDir) || ~isfolder(homeDir); homeDir = saveDir; end +if nargin >= 1 + userinput = varargin{1}; +else + dialog_title = 'Select the folder containing the exported Precision files'; + if ~ispc() + uiwait(msgbox(dialog_title,'modal')) + end + userinput = uigetdir(homeDir, dialog_title); + if userinput == 0 + return + else + homeDir = userinput; + end +end +if ~isfolder(userinput) + error('IMPORTPRECISION_OPENEP: Only specify a folder as user input') +else + studyDir = userinput; + homeDir = studyDir; +end + +%% Parse command line input +nStandardArgs = 1; % UPDATE VALUE +channelRef_cli = ''; +channelECG_cli = ''; +saveFileName_cli = ''; +if nargin > nStandardArgs + for i = nStandardArgs+1:2:nargin + switch varargin{i} + case 'refchannel' + channelRef_cli = varargin{i+1}; + case 'ecgchannel' + channelECG_cli = varargin{i+1}; + if ischar(channelECG_cli); channelECG_cli = {channelECG_cli}; end + case 'savefilename' + saveFileName_cli = varargin{i+1}; + otherwise + error('IMPORTCARTO_MEM: Unrecognized input.') + end + end +end + +%% Identify the relevant subfolders + +% These should be the BIP folder, the LAT folder and the UNI folder +latMapDir = local_findDirectory('OpenEP_LAT', studyDir); +bipMapDir = local_findDirectory('OpenEP_BIP', studyDir); +uniMapDir = local_findDirectory('OpenEP_UNI', studyDir); +geometryDir = latMapDir; % this is the same, by definition, as the latMapDir + +% And including the set of ALL the automap folders +allSubFolds = nameFolds(studyDir); +automapDirs = allSubFolds(strstartcmpi('OpenEP_AutoMap', allSubFolds)); + +%% Parse the geometry and surface mapping data +% By loading the LAT XML file to get the geometry +% By loading the LAT XML file to get the LAT map (I know, unnecessary +% duplication but this gives us future flexibility) +% By loading the bipolar XML file to get the bipolar map +% By loading the unipolar XML file to get the unipolar map +data_geometry = loadprecision_modelgroups(fullfile(latMapDir{:}, 'DxLandmarkGeo.xml')); +TRI = data_geometry.dxgeo.triangles; +X = data_geometry.dxgeo.vertices(:,1); +Y = data_geometry.dxgeo.vertices(:,2); +Z = data_geometry.dxgeo.vertices(:,3); +tr = TriRep(TRI, X, Y, Z); +t.X = tr.X; +t.Triangulation = tr.Triangulation; +normals = data_geometry.dxgeo.normals; + +data_latMap = loadprecision_modelgroups(fullfile(latMapDir{:}, 'DxLandmarkGeo.xml')); +data_bipolarVoltageMap = loadprecision_modelgroups(fullfile(bipMapDir{:}, 'DxLandmarkGeo.xml')); +data_unipolarVoltageMap = loadprecision_modelgroups(fullfile(uniMapDir{:}, 'DxLandmarkGeo.xml')); +act = data_latMap.dxgeo.act; +act(data_latMap.dxgeo.map_status==2) = NaN; + +bip = data_bipolarVoltageMap.dxgeo.bip; +bip(data_bipolarVoltageMap.dxgeo.map_status==2) = NaN; + +uni = data_unipolarVoltageMap.dxgeo.bip; +uni(data_unipolarVoltageMap.dxgeo.map_status==2) = NaN; + +imp = NaN(size(uni)); +frc = NaN(size(uni)); + +%% (Optional) Parse additional voltage maps - e.g. omnipolar +% By loading the XML file + +%% Parse electrogram data +% By loading the DXL files (note that these differ for unipolar and bipolar) +dxldataBip = importprecision_dxldata(bipMapDir); +dxldataUni = importprecision_dxldata(uniMapDir); + +%% Parse electrogram data +% By loading the automaps (not needed for Precision but might be needed for +% EnSiteX) + +%% Save all files in the OpenEP structure + +% General data +userdata = openep_createuserdata(); +userdata.systemName = 'precision'; +userdata.notes{1} = [date() ': Created']; +userdata.precisionFolder = studyDir; +userdata.electric.sampleFrequency = dxldataBip.sampleFreq; + +% Geometry +userdata.surface.triRep = t; +surfaceData = data_geometry.dxgeo.surface_of_origin; +userdata = setSurfaceProperty(userdata, 'name', 'surfaceOfOrigin', 'map', surfaceData, 'definedOn', 'elements'); +userdata.surface.normals = normals; + +% Surface maps, removing invalid data beyond interpolation distance +userdata.surface.act_bip = [act bip]; +userdata.surface.uni_imp_frc = [uni imp frc]; + +% Electric data +userdata.electric.electrodeNames_bip = dxldataBip.rovtrace_pts'; +userdata.electric.egmX = [dxldataBip.rovingx', dxldataBip.rovingy', dxldataBip.rovingz']; +userdata.electric.egmSurfX = [dxldataBip.surfPtx', dxldataBip.surfPty', dxldataBip.surfPtz']; +userdata.electric.egmRef = dxldataBip.reftrace'; +userdata.electric.egm = dxldataBip.rovtrace'; +userdata.electric.annotations.referenceAnnot = dxldataBip.refLAT'; +userdata.electric.annotations.mapAnnot = dxldataBip.rovLAT'; +userdata.electric.annotations.woi = 1 - userdata.electric.annotations.referenceAnnot; +userdata.electric.annotations.woi(:,2) = size(userdata.electric.egm,2) - userdata.electric.annotations.referenceAnnot; +userdata.electric.voltages.bipolar = dxldataBip.peak2peak'; +userdata.electric.include = dxldataBip.utilized'; +userdata.electric.names = strcat('P', strsplit(num2str(dxldataBip.ptnumber)))'; + +userdata.electric.electrodeNames_uni = dxldataUni.rovtrace_pts'; +userdata.electric.egmUniX = [dxldataUni.rovingx', dxldataUni.rovingy', dxldataUni.rovingz']; +userdata.electric.egmUniSurfX = [dxldataUni.surfPtx', dxldataUni.surfPty', dxldataUni.surfPtz']; +userdata.electric.egmUni = dxldataUni.rovtrace'; +userdata.electric.egmUni(:,:,2) = 0; % since we only get one unipole channel from Precision +userdata.electric.annotations.referenceAnnotUni = dxldataUni.refLAT'; +userdata.electric.annotations.mapAnnotUni = dxldataUni.rovLAT'; +userdata.electric.voltages.unipolar = dxldataUni.peak2peak'; + +% Temp - remote signalMaps which, if empty, prevents the file being loaded +% in EP Workbench +userdata.surface = rmfield(userdata.surface, 'signalMaps'); +userdata.electric.tags = cell(length(userdata.electric.names),1); + +% Encourage user to save the data +matFileFullPath = []; +if ~isempty(saveFileName_cli) + save(saveFileName_cli, 'userdata'); + matFileFullPath = saveFileName_cli; +else + defaultName = [dxldataBip.study '_' dxldataBip.mapId]; + defaultName(isspace(defaultName)) = '_'; + originalDir = cd(); + matFileFullPath = fullfile(saveDir, defaultName); %default + cd(saveDir); + [filename,saveDir] = uiputfile('*.mat', 'Save the userdata to disc for future rapid access?',defaultName); + cd(originalDir); + % We save as -v7 because it's faster to load in OpenEP-py than -v7.3, + % and the saved file is significantly smaller compared to -v6 files. + if filename ~= 0 + save([saveDir filename], 'userdata','-v7'); + matFileFullPath = fullfile(saveDir, filename); + end +end + +%% Local functions + function pathName = local_findDirectory(stub, studyDir) + allSubFolders = nameFolds(studyDir); + thisFolder = allSubFolders(strstartcmpi(stub, allSubFolders)); + % Check if more than one folder meets the critiera, and ask the user + % to choose + if numel(thisFolder)>1 + warning(['IMPORTPRECISION_OPENEP: More than one candidate folder selected for the export of ***' stub '*** data. Please choose one folder ...']) + [indx, tf] = listdlg('ListString', thisFolder ... + ,'ListSize', [480 300] ... + , 'name', ['Which is the correct ***' stub '*** folder?'] ... + , 'selectionmode', 'single' ... + ); + if ~tf + error('IMPORTPRECISION_OPENEP: Operation cancelled') + else + thisFolder = thisFolder{indx}; + end + end + pathName = fullfile(studyDir, thisFolder); + end + + function hd = local_homedirec() + %HOMEDIREC returns the user's home directory. + + if ispc + hd = [getenv('HOMEDRIVE') getenv('HOMEPATH')]; + else + hd = getenv('HOME'); + end + end + +end \ No newline at end of file diff --git a/incrementUnipoleName.m b/incrementUnipoleName.m new file mode 100644 index 0000000..93948b2 --- /dev/null +++ b/incrementUnipoleName.m @@ -0,0 +1,7 @@ +function uni2name = incrementUnipoleName(uni1name) +%increment the unipole name by 1 to get the second unipole (this assumes +%that the second unipole is always the 'first + 1' +pattern = '(?\S*\D)(?\d*)'; +result = regexpi(uni1name,pattern,'names'); +uni2name = [result.name, num2str(str2double(result.number)+1)]; +end \ No newline at end of file diff --git a/inspect_carto_zip.m b/inspect_carto_zip.m new file mode 100644 index 0000000..1582d98 --- /dev/null +++ b/inspect_carto_zip.m @@ -0,0 +1,46 @@ +function info = inspect_carto_zip(zipPath) +%INSPECT_CARTO_ZIP Read archive sizes from the ZIP central directory. + +zipPath = char(zipPath); +if ~isfile(zipPath) + error('inspect_carto_zip:MissingFile', ... + 'ZIP file does not exist: %s', zipPath); +end + +[~, ~, ext] = fileparts(zipPath); +if ~strcmpi(ext, '.zip') + error('inspect_carto_zip:UnsupportedFile', ... + 'Expected a ZIP file: %s', zipPath); +end + +fileInfo = dir(zipPath); +info = struct( ... + 'compressedBytes', double(fileInfo.bytes), ... + 'uncompressedBytes', 0, ... + 'fileCount', 0); + +zipFile = java.util.zip.ZipFile(java.io.File(zipPath)); +cleanupObj = onCleanup(@() zipFile.close()); +entries = zipFile.entries(); +unknownSize = false; + +while entries.hasMoreElements() + entry = entries.nextElement(); + if entry.isDirectory() + continue + end + + entrySize = double(entry.getSize()); + if entrySize < 0 + unknownSize = true; + else + info.uncompressedBytes = info.uncompressedBytes + entrySize; + end + info.fileCount = info.fileCount + 1; +end + +if unknownSize + error('inspect_carto_zip:UnknownEntrySize', ... + 'ZIP contains entries whose uncompressed size is unavailable: %s', zipPath); +end +end diff --git a/inspectensitex_export.m b/inspectensitex_export.m new file mode 100644 index 0000000..4d83ae5 --- /dev/null +++ b/inspectensitex_export.m @@ -0,0 +1,340 @@ +function manifest = inspectensitex_export(studyDir) +%INSPECTENSITEX_EXPORT Inspect EnSiteX exports without loading signal data. +% +% manifest = inspectensitex_export(studyDir) +% +% Folder and file names are treated as opaque. Semantic CSV headers are +% authoritative; recognized filenames are used only as a warned fallback. + +studyDir = char(studyDir); +assert(isfolder(studyDir), 'EnSiteX study folder not found: %s', studyDir); + +csvEntries = visibleFiles(dir(fullfile(studyDir, '**', '*.csv'))); +files = emptyFileInfo(); +ignoredFiles = {}; +for i = 1:numel(csvEntries) + filePath = fullfile(csvEntries(i).folder, csvEntries(i).name); + info = inspectCsvHeader(filePath); + if info.isDxl + files(end+1) = info; %#ok + else + ignoredFiles{end+1} = filePath; %#ok + end +end + +manifest = struct(); +manifest.studyDir = studyDir; +manifest.files = files; +manifest.ignoredFiles = ignoredFiles; +manifest.exports = groupExports(files); +end + +function exports = groupExports(files) +exports = emptyExport(); +if isempty(files) + return +end + +folders = unique({files.folder}, 'stable'); +for iFolder = 1:numel(folders) + inFolder = strcmp({files.folder}, folders{iFolder}); + folderFiles = files(inFolder); + mapNames = unique({folderFiles.mapName}, 'stable'); + mapNames = mapNames(~cellfun('isempty', mapNames)); + for iMap = 1:numel(mapNames) + inMap = strcmp({folderFiles.mapName}, mapNames{iMap}); + thisFiles = folderFiles(inMap); + export = buildExport(thisFiles, numel(exports) + 1); + exports(end+1) = export; %#ok + end +end +end + +function export = buildExport(files, index) +isMap = strcmp({files.kind}, 'map'); +isWave = strcmp({files.kind}, 'wave'); +mapFiles = files(isMap); +waveFiles = files(isWave); + +[mode, confidence, evidence, warnings, errors] = detectMode(mapFiles, waveFiles); +numPoints = unique([mapFiles.numPoints]); +numPoints = numPoints(isfinite(numPoints)); + +geometryFile = findGeometryFile(files(1).folder); +export = struct(); +export.id = sprintf('export_%d', index); +export.folder = files(1).folder; +export.mapName = files(1).mapName; +export.recordingMode = mode; +export.confidence = confidence; +export.evidence = evidence; +export.warnings = warnings; +export.errors = errors; +export.numPoints = numPoints; +export.geometryFile = geometryFile; +export.mapFiles = roleEntries(mapFiles); +export.waveFiles = roleEntries(waveFiles); +export.files = files; +end + +function [mode, confidence, evidence, warnings, errors] = detectMode(mapFiles, waveFiles) +mode = 'unknown'; +confidence = 'none'; +evidence = {}; +warnings = {}; +errors = {}; + +tokens = {mapFiles.modeToken}; +tokens = unique(tokens(~cellfun('isempty', tokens))); +if numel(tokens) > 1 + mode = 'conflict'; + errors{end+1} = ['Conflicting recording modes in Map type headers: ', ... + strjoin(tokens, ', ')]; + return +elseif isscalar(tokens) + mode = tokens{1}; + confidence = 'high'; + evidence{end+1} = ['Map type headers consistently identify ', mode, '.']; + return +end + +allColumns = {}; +for i = 1:numel(mapFiles) + allColumns = [allColumns mapFiles(i).columns]; %#ok +end +normalizedColumns = normalizeTokens(allColumns); +omniColumns = {'pp_vmax', 'pp_valong', 'pp_vacross', ... + 'uni_corner_elec', 'uni_along_elec', 'uni_across_elec'}; +if all(ismember(omniColumns, normalizedColumns)) + mode = 'omni'; + confidence = 'high'; + evidence{end+1} = 'Omnipolar voltage and corner/along/across columns are present.'; + return +end + +waveRoles = unique({waveFiles.role}); +if all(ismember({'uni_corner', 'uni_along', 'uni_across'}, waveRoles)) + mode = 'omni'; + confidence = 'medium'; + evidence{end+1} = 'Corner, along and across unipolar wave roles are present.'; + return +end + +filenameModes = unique([{mapFiles.filenameMode} {waveFiles.filenameMode}]); +filenameModes = filenameModes(~cellfun('isempty', filenameModes)); +if isscalar(filenameModes) + mode = filenameModes{1}; + confidence = 'low'; + evidence{end+1} = ['Legacy filenames identify ', mode, '.']; + warnings{end+1} = 'Recording mode was inferred from filenames because semantic header evidence was absent.'; +elseif numel(filenameModes) > 1 + errors{end+1} = ['Conflicting recording modes in legacy filenames: ', ... + strjoin(filenameModes, ', ')]; +end +end + +function entries = roleEntries(files) +entryTemplate = struct('role', '', 'path', '', 'source', ''); +entries = repmat(entryTemplate, numel(files), 1); +for i = 1:numel(files) + entries(i) = struct( ... + 'role', files(i).role, ... + 'path', files(i).path, ... + 'source', files(i).roleSource); +end +end + +function info = inspectCsvHeader(filePath) +[folder, name, ext] = fileparts(filePath); +info = emptyFileInfoScalar(); +info.path = filePath; +info.folder = folder; +info.name = [name, ext]; + +fid = fopen(filePath, 'r'); +if fid == -1 + info.error = 'Could not open file.'; + return +end +cleanupObj = onCleanup(@() fclose(fid)); + +lines = cell(1, 500); +nLines = 0; +for i = 1:500 + line = fgetl(fid); + if ~ischar(line) + break + end + nLines = nLines + 1; + lines{nLines} = line; + if nLines >= info.dataStartRow && isfinite(info.dataStartRow) + break + end + info = parseHeaderLine(info, line); +end +lines = lines(1:nLines); + +info.isDxl = any(strcmpi(info.dataElement, {'DxL', 'DXLData'})); +if ~info.isDxl + return +end + +if isfinite(info.dataStartRow) && numel(lines) >= info.dataStartRow + info.columns = splitCsvLine(lines{info.dataStartRow}); +end + +if ~isempty(info.mapType) && ~strcmpi(info.mapType, 'N/A') + info.kind = 'map'; + info.role = mapRole(info.mapType); + info.roleSource = 'header'; + info.modeToken = modeFromToken(info.mapType); +else + info.kind = 'wave'; + [info.role, info.roleSource] = waveRole(info.waveName, info.name); +end +info.filenameMode = modeFromFilename(info.name); +end + +function info = parseHeaderLine(info, line) +info.dataElement = firstToken(line, ... + 'Export Data Element\s*:\s*([^,\r\n]+)', info.dataElement); +info.mapName = firstToken(line, ... + 'Map name\s*:\s*,\s*([^,\r\n]+)', info.mapName); +info.mapType = firstToken(line, ... + 'Map type\s*:\s*,\s*([^,\r\n]+)', info.mapType); +info.waveName = firstToken(line, ... + 'Wave name\s*:\s*,\s*([^,\r\n]+)', info.waveName); +info.dataStartRow = firstNumber(line, ... + 'Data starts in row\s*,\s*(\d+)', info.dataStartRow); +info.numPoints = firstNumber(line, ... + '# mapping pts\s*:\s*,\s*(\d+)', info.numPoints); +info.numPoints = firstNumber(line, ... + '# freeze groups\s*:\s*,\s*(\d+)', info.numPoints); +end + +function role = mapRole(mapType) +token = regexprep(lower(strtrim(mapType)), '_(bi|uni|omni)$', ''); +role = ['map_', normalizeToken(token)]; +end + +function [role, source] = waveRole(waveName, filename) +if ~isempty(waveName) + role = normalizeToken(waveName); + source = 'header'; + return +end + +[~, baseName] = fileparts(filename); +baseName = regexprep(baseName, '^Wave_', '', 'ignorecase'); +role = normalizeToken(baseName); +source = 'filename'; +end + +function mode = modeFromToken(value) +tokens = regexp(lower(strtrim(value)), '_(bi|uni|omni)$', 'tokens', 'once'); +if isempty(tokens) + mode = ''; +else + mode = tokens{1}; +end +end + +function mode = modeFromFilename(filename) +[~, baseName] = fileparts(filename); +mode = modeFromToken(baseName); +end + +function geometryFile = findGeometryFile(folder) +geometryFile = ''; +candidates = { + fullfile(folder, 'Contact_Mapping_Model.xml') + fullfile(fileparts(folder), 'Contact_Mapping_Model.xml') +}; +for i = 1:numel(candidates) + if isfile(candidates{i}) + geometryFile = candidates{i}; + return + end +end +end + +function token = normalizeToken(value) +token = lower(strtrim(char(value))); +token = regexprep(token, '[^a-z0-9]+', '_'); +token = regexprep(token, '^_+|_+$', ''); +end + +function tokens = normalizeTokens(values) +tokens = cellfun(@normalizeToken, values, 'UniformOutput', false); +tokens = unique(tokens); +end + +function value = firstToken(line, pattern, currentValue) +value = currentValue; +tokens = regexp(line, pattern, 'tokens', 'once'); +if ~isempty(tokens) + value = strtrim(tokens{1}); +end +end + +function value = firstNumber(line, pattern, currentValue) +value = currentValue; +tokens = regexp(line, pattern, 'tokens', 'once'); +if ~isempty(tokens) + value = str2double(tokens{1}); +end +end + +function columns = splitCsvLine(line) +columns = regexp(line, ',', 'split'); +end + +function files = visibleFiles(files) +if isempty(files) + return +end +names = {files.name}; +files = files(~startsWith(names, '.') & ~startsWith(names, '._')); +end + +function files = emptyFileInfo() +files = repmat(emptyFileInfoScalar(), 0, 1); +end + +function info = emptyFileInfoScalar() +info = struct( ... + 'path', '', ... + 'folder', '', ... + 'name', '', ... + 'isDxl', false, ... + 'kind', '', ... + 'dataElement', '', ... + 'mapName', '', ... + 'mapType', 'N/A', ... + 'waveName', '', ... + 'dataStartRow', NaN, ... + 'numPoints', NaN, ... + 'columns', {{}}, ... + 'modeToken', '', ... + 'filenameMode', '', ... + 'role', '', ... + 'roleSource', '', ... + 'error', ''); +end + +function exports = emptyExport() +exports = struct( ... + 'id', {}, ... + 'folder', {}, ... + 'mapName', {}, ... + 'recordingMode', {}, ... + 'confidence', {}, ... + 'evidence', {}, ... + 'warnings', {}, ... + 'errors', {}, ... + 'numPoints', {}, ... + 'geometryFile', {}, ... + 'mapFiles', {}, ... + 'waveFiles', {}, ... + 'files', {}); +end diff --git a/loadensitex_dxldata.m b/loadensitex_dxldata.m index 5dfa63c..13f0e8c 100644 --- a/loadensitex_dxldata.m +++ b/loadensitex_dxldata.m @@ -1,4 +1,4 @@ -function [info, varnames, data] = loadensitex_dxldata(filename) +function [info, varnames, data] = loadensitex_dxldata(filename, varargin) % LOADPRECISION_DXLDATA loads the map stored in an EnSiteX DxL file. % Usage: % [info, points, egms] = loadprecision_dxldata(filename) @@ -15,13 +15,29 @@ % Info on Code Testing: % --------------------------------------------------------------- -% test code +% [info, varnames, data] = loadensitex_dxldata('/Contact_Mapping/Map_PP_bi.csv'); % --------------------------------------------------------------- % --------------------------------------------------------------- % code % --------------------------------------------------------------- + +% parse command line input +nStandardArgs = 1; +showProgress = true; +if nargin > nStandardArgs + for i = 1:2:nargin-nStandardArgs + switch lower(varargin{i}) + case 'showprogress' + showProgress = varargin{i+1}; + otherwise + error('LOADENSITEX_DXLDATA: Unrecognised input.'); + end + end +end + +disp(['LOADENSITEX_DXLDATA: Reading file: ' filename]); info = []; varnames = []; data = []; @@ -61,11 +77,11 @@ % READ THE HEADER % --------------- % The 'header' finishes at the end of the last line starting with "****," -[ind1, ~] = regexp(fData, '****','start','end'); -if isempty(ind1) +[~, ind2] = regexp(fData, '****','start','end'); +if isempty(ind2) error('End of header not found. Double check that maxBytes is large enough to cover header.') end -indEndofHeader = ind1(end); +indEndofHeader = ind2(end); header = fData(1:indEndofHeader); % Parse the header @@ -95,7 +111,7 @@ % Read the header line at info.dataStartRow fseek(fileID, 0, 'bof'); -for i = 1:info.dataStartRow-2 +for i = 1:info.dataStartRow-1 fgetl(fileID); end dataHeaderRowLine = fgetl(fileID); @@ -103,7 +119,14 @@ % Tidy up the heading data if strcmpi(dataHeaderRowLine(end), ',') - dataHeaders(end) = []; + headersBeforeTrailingComma = dataHeaders(1:end-1); + hasNumericSignalHeaders = isfield(info, 'sampleFreq') && ... + any(~isnan(str2double(headersBeforeTrailingComma))); + if hasNumericSignalHeaders || ~isfield(info, 'sampleFreq') + dataHeaders(end) = []; + else + dataHeaders{end} = '0'; + end end if strcmpi(dataHeaders(end), '...') dataHeaders(end) = []; @@ -128,11 +151,19 @@ varnames = dataHeaders; end +% we are already at the right line in the file as we just read the header line before the data numericColumnsToRead = tfNumHeaders; varColumnsToRead = ~tfNumHeaders; -% we are already at the right line in the file as we just read the header line before the data - -data = local_parsedata(fileID, varColumnsToRead, numericColumnsToRead, info.numPts, [thisFileName ext]); +if isfield(info, 'mapType') + if ~strcmpi(info.mapType, 'N/A') + parseMethod = 'internal'; % we are dealing with a map file + else + parseMethod = 'regexp'; % we are dealing with a wave file + end +else + parseMethod = 'regexp'; % faster for dealing with wave data +end +data = local_parsedata(fileID, varColumnsToRead, numericColumnsToRead, info.numPoints, [thisFileName ext], parseMethod, showProgress); end @@ -154,7 +185,7 @@ end end -function allOutput = local_parsedata(fileID, varColumnsToRead, numericColumnsToRead, nSamples, fname) +function allOutput = local_parsedata(fileID, varColumnsToRead, numericColumnsToRead, nSamples, fname, parseMethod, showProgress) % nSamples - the number of samples to read; which may be a number of % points or a number of freeze groups % columnsToRead - logical array indicating which columns will be read @@ -165,13 +196,15 @@ maxBytes = 10 * 1024 * 1024; % read in max 10MBytes at a time allNumericData = zeros(nSamples, nNumericColToRead, 'double'); -allVarData = cell(nSamples, nCol - nNumericColToRead); +allVarData = cell(nSamples, nCol - nNumericColToRead); %CHANGED HERE currentLine = 1; remainingBytes = filebytes2end(fileID); totalBytes = remainingBytes; remainingData = []; set(0,'DefaultTextInterpreter','none') -f = waitbar(0, ['Loading data from file: ' fname]); +if showProgress + f = waitbar(0, ['Loading data from file: ' fname]); +end while remainingBytes>0 % Read chunk of data bytesToRead = min([maxBytes, remainingBytes+1]); % The +1 ensures we read into the end of the file. @@ -195,60 +228,196 @@ % save the remaining data for the next time round remainingData = temp; - % split the text at commas - dataChunkCellArray = regexp(dataChunk', ',', 'split'); % deals with successive delimiters correctly in contrast to strsplit(dataChunk', ','); - - % remove any leading or trailing empty cells if needed - if isempty(dataChunkCellArray{1}) - dataChunkCellArray(1) = []; + switch parseMethod + % This section needs to output reshapedData and wholeLinesRead + % The internal method is robust and seems to work with most files + % but is quite slow. The regexp method is much faster but fails + % with some mapping files. + % + % We need to confirm but the regexp method MIGHT work fine with all + % wave files; in which case we will detault to using INTERNAL for + % mapping files and REGEXP for wave files. + case 'internal' + % Internal method - robust but very slow + dataChunkCellArray = parseCSVString(dataChunk); + reshapedData = dataChunkCellArray(:, 1:nCol); % remove extra columns + + numLinesRead = numel(reshapedData) / nCol; + wholeLinesRead = floor(numLinesRead); + + case 'regexp' + % split the text at commas + dataChunkCellArray = regexp(dataChunk', ',', 'split'); % deals with successive delimiters correctly in contrast to strsplit(dataChunk', ','); + + %remove any leading or trailing empty cells if needed + if isempty(dataChunkCellArray{1}) + dataChunkCellArray(1) = []; + end + if isempty(dataChunkCellArray{end}) + dataChunkCellArray(end) = []; + end + if strcmpi(dataChunkCellArray{end}(2:end), 'EOF') + dataChunkCellArray(end) = []; + end + + % % work out the valid cells + numLinesRead = numel(dataChunkCellArray) / nCol; + wholeLinesRead = floor(numLinesRead); + + if numLinesRead > wholeLinesRead + % there was overhanging data, so increment nCol + nCol = nCol + 1; + end + + % reshape the data + reshapedData = reshape(dataChunkCellArray(1:nCol*wholeLinesRead),[nCol, wholeLinesRead]); + reshapedData = reshapedData'; + + if numLinesRead > wholeLinesRead + % there was overhanging data, now is the time to remove it + reshapedData(:,end) = []; + % and decrement nCol + nCol = nCol-1; + end end - if isempty(dataChunkCellArray{end}) - dataChunkCellArray(end) = []; - end - if strcmpi(dataChunkCellArray{end}(2:end), 'EOF') - dataChunkCellArray(end) = []; - end - - % work out the valid cells - numLinesRead = numel(dataChunkCellArray) / nCol; - wholeLinesRead = floor(numLinesRead); - - % check if we need to insert extra cells - % reshape the data - reshapedData = reshape(dataChunkCellArray,[nCol, wholeLinesRead]); - reshapedData = reshapedData'; - - % Deal first with the numeric data ----- - - % only keep the columns we want for signal data + % Deal first with the numeric data - only keep the columns we want for signal data thisSignalData = reshapedData(:,numericColumnsToRead); - % equivalent to, but much faster than - %allData(currentLine:currentLine+wholeLinesRead-1,1:nColToRead) = str2double(thisEgmData); + % equivalent to, but much faster than, allData(currentLine:currentLine+wholeLinesRead-1,1:nColToRead) = str2double(thisEgmData); doubleValues = sscanf(sprintf(' %s',thisSignalData{:}),'%f',[1,Inf]); - doubleValueReshaped = reshape(doubleValues, size(thisSignalData)); + if numel(doubleValues) == numel(thisSignalData) + doubleValueReshaped = reshape(doubleValues, size(thisSignalData)); + else + doubleValueReshaped = str2double(thisSignalData); + end allNumericData(currentLine:currentLine+wholeLinesRead-1,1:nNumericColToRead) = doubleValueReshaped; - % Now deal with the variables data ----- - - thisVarData = reshapedData(:,~numericColumnsToRead); - allVarData(currentLine:currentLine+wholeLinesRead-1,1:nCol - nNumericColToRead) = thisVarData; + % Now deal with the variables data + thisVarData = reshapedData(:,varColumnsToRead); %opposite of numericColumnsToRead + allVarData(currentLine:currentLine+wholeLinesRead-1,1:(nCol) - nNumericColToRead) = thisVarData; % increment the current line index, waitbar and remaining bytes currentLine = currentLine+wholeLinesRead; - waitbar((totalBytes-remainingBytes)/totalBytes, f); + if showProgress + waitbar((totalBytes-remainingBytes)/totalBytes, f); + end remainingBytes = filebytes2end(fileID); end % destroy the waitbar -close(f) +if showProgress + close(f) +end % assign the output allOutput = allVarData; -widthOfAllOutput = size(allOutput,2); -for i = 1:size(allNumericData,1) - allOutput{i,widthOfAllOutput+1} = allNumericData(i,:); +if ~isempty(allNumericData) %check if we are dealing with a map or an electrogram file ... + widthOfAllOutput = size(allOutput,2); + for iD = 1:size(allNumericData,1) + allOutput{iD,widthOfAllOutput+1} = allNumericData(iD,:); + end end -end \ No newline at end of file + function C = parseCSVString(s) + % parseCSVString Parse CSV from a character vector into a cell array. + % C = parseCSVString(s) returns an MxN cell array of char, where each + % row is a CSV record and each column a field. Quoted fields and + % embedded commas/newlines are handled. Empty fields are preserved. + % + % Input: + % s - character vector (single string) containing the whole CSV text. + % + % Example: + % s = 'A,"B, with comma",,C\n"D with ""quote""",E,'; + % C = parseCSVString(s); + + if ~ischar(s) && ~isstring(s) + error('Input must be a character vector or string.'); + end + s = char(s); % ensure char vector + n = numel(s); + + rows = {}; % cell array of rows (each row is a cell vector) + curField = ''; % current field buffer (char) + curRow = {}; % current row (cell array) + inQuote = false; + i = 1; + + while i <= n + ch = s(i); + if ch == '"' % quote handling + if inQuote + % possible escaped quote: lookahead + if i < n && s(i+1) == '"' + curField(end+1) = '"'; % append one quote + i = i + 1; % skip the escaped quote + else + % closing quote + inQuote = false; + end + else + % starting quote (enter quoted mode) + inQuote = true; + end + i = i + 1; + continue; + end + + if ~inQuote + if ch == ',' % field separator + curRow{end+1} = curField; %#ok + curField = ''; + i = i + 1; + continue; + end + + % newline handling: support \r\n, \n, or \r + if ch == sprintf('\r') % CR + % check for CRLF + if i < n && s(i+1) == sprintf('\n') + i = i + 2; + else + i = i + 1; + end + % finish row + curRow{end+1} = curField; %#ok + rows{end+1,1} = curRow; %#ok + curRow = {}; curField = ''; + inQuote = false; + continue; + elseif ch == sprintf('\n') % LF + i = i + 1; + curRow{end+1} = curField; %#ok + rows{end+1,1} = curRow; %#ok + curRow = {}; curField = ''; + inQuote = false; + continue; + end + end + + % normal character (either inside quotes or plain text) + curField(end+1) = ch; + i = i + 1; + end + + % End of input: push remaining field/row + % If the input ended while inside a quoted field, we treat it as finished. + curRow{end+1} = curField; + rows{end+1,1} = curRow; + + % Convert rows (cell of cell) into a rectangular M-by-N cell array padded with '' + M = numel(rows); + maxCols = 0; + for r = 1:M + maxCols = max(maxCols, numel(rows{r})); + end + + C = repmat({''}, M, maxCols); + for r = 1:M + rowCells = rows{r}; + C(r,1:numel(rowCells)) = rowCells; + end + end + +end diff --git a/loadensitex_prechecks.m b/loadensitex_prechecks.m index fd3e7bc..6f4e061 100644 --- a/loadensitex_prechecks.m +++ b/loadensitex_prechecks.m @@ -5,6 +5,7 @@ % Modifications - % Phil Gemmell (2020): Refactored and updated % Steven Williams (2022): converted for EnsiteX +% Steven Williams (2025): incremented version number success = false; @@ -34,6 +35,13 @@ 'Export\s*File\s*Version\s*:\s*10\.0R',... 'once'); +if isempty(ind) + disp(['LOADENSITEX_PRECHECKS: First line is: ' firstLine]); + ind = regexp(firstLine,... + 'Export\s*File\s*Version\s*:\s*11',... + 'once'); +end + if isempty(ind) warning('LoadPrecision:InvalidFile',... 'LOADPRECISION_DXLDATA: Invalid File Revision Number'); diff --git a/loadprecision_modelgroups.m b/loadprecision_modelgroups.m index 24b31d6..f6fd018 100644 --- a/loadprecision_modelgroups.m +++ b/loadprecision_modelgroups.m @@ -45,6 +45,10 @@ end for i = 1:tree.DIFBody.Volumes.ATTRIBUTE.number + % pre-populate .act, .bip and .uni + dxgeo(i).act = []; + dxgeo(i).bip = []; + dxgeo(i).uni = []; if isfield(tree.DIFBody.Volumes.Volume(i), 'Vertices') dxgeo(i).vertices = str2num(tree.DIFBody.Volumes.Volume(i).Vertices.CONTENT); end @@ -64,7 +68,14 @@ dxgeo(i).surface_of_origin = str2num(tree.DIFBody.Volumes.Volume(i).Surface_of_origin.CONTENT); end if isfield(tree.DIFBody.Volumes.Volume(i), 'Map_data') - % identify the type of data that we have + % Identify the type of data that we have. LAT calculated from a + % bipolar map will be stored in .act; voltage calcualted from a + % bipolar map will be stored in .bip; voltage calculated from a + % unipolar map will be stored in .uni. Anything else will be stored + % in .mapData with a description given in .mapType. In a future + % version we may want to modify this logic so that all surface maps + % are stored as mapData/mapType, to align with the concept of + % surface properties in the OpenEP data format. stub = 'Data values at each vertex of DxL map'; iComment = []; for j=1:numel(dxgeo.comment) @@ -75,18 +86,32 @@ end end if isempty(iComment) - warning('OPENEP/LOADPRECISION_MODELGROUPS: Map data was found but no comment describing its type was identified'); + warning(['OPENEP/LOADPRECISION_MODELGROUPS: Map data was ...' ... + 'found but no comment describing its type was identified. ...' ... + 'The data will be stored in mapdata, and the description will be set to UNKNOWN']); + dxgeo(i).mapdata = str2num(tree.DIFBody.Volumes.Volume(i).Map_data.CONTENT); + dxgeo(i).maptype = 'UNKNWOWN'; else dataTypeString = dxgeo.comment{iComment}; - if contains(dataTypeString, 'P-P Voltage') + if contains(dataTypeString, 'P-P Voltage') && contains(dataTypeString, 'bi') dxgeo(i).bip = str2num(tree.DIFBody.Volumes.Volume(i).Map_data.CONTENT); - elseif contains(dataTypeString, 'LAT Isochronal') + elseif contains(dataTypeString, 'P-P Voltage') && contains(dataTypeString, 'uni') + dxgeo(i).uni = str2num(tree.DIFBody.Volumes.Volume(i).Map_data.CONTENT); + elseif contains(dataTypeString, 'LAT Isochronal') && contains(dataTypeString, 'bi') dxgeo(i).act = str2num(tree.DIFBody.Volumes.Volume(i).Map_data.CONTENT); - else - warning('OPENEP/LOADPRECISION_MODELGROUPS: Map data was found but its type was not identifiable as P-P Voltage or LAT Isochronal'); + elseif contains(dataTypeString, 'LAT Isochronal') + warning(['OPENEP/LOADPRECISION_MODELGROUPS: Map data was ...' ... + 'found but its type was not identifiable as act, ...' ... + 'bip or uni. The data will be stored in mapdata, and the description in maptype']); + dxgeo(i).mapdata = str2num(tree.DIFBody.Volumes.Volume(i).Map_data.CONTENT); + maptype = regexp(dataTypeString, 'map\s+(.*)$', 'tokens', 'once'); + dxgeo(i).maptype = maptype{1}; end end end + if isfield(tree.DIFBody.Volumes.Volume(i), 'Map_status') + dxgeo(i).map_status = str2num(tree.DIFBody.Volumes.Volume(i).Map_status.CONTENT); + end %get the labels if isfield(tree.DIFBody.Labels, 'Label') diff --git a/loadprecision_prechecks.m b/loadprecision_prechecks.m index bc1dda8..31350ff 100644 --- a/loadprecision_prechecks.m +++ b/loadprecision_prechecks.m @@ -46,8 +46,14 @@ ind6 = regexp(firstLine,... 'Export\s*File\s*Version\s*:\s*10\.0R',... 'once'); +ind7 = regexp(firstLine,... + 'St\.\s*Jude Medical\.\s*File Revision\s*:\s*5\.6',... + 'once'); +ind8 = regexp(firstLine,... + 'St\.\s*Jude Medical\.\s*File Revision\s*:\s*5\.7',... + 'once'); -if isempty(ind1) && isempty(ind2) && isempty(ind3) && isempty(ind4) && isempty(ind5) && isempty(ind6) +if isempty(ind1) && isempty(ind2) && isempty(ind3) && isempty(ind4) && isempty(ind5) && isempty(ind6) && isempty(ind7) && isempty(ind8) warning('LoadPrecision:InvalidFile',... 'LOADPRECISION_DXLDATA: Invalid File Revision Number'); return diff --git a/loadprecision_wavefile.m b/loadprecision_wavefile.m index 5f6cdfa..9f08df8 100644 --- a/loadprecision_wavefile.m +++ b/loadprecision_wavefile.m @@ -1,7 +1,7 @@ function info = loadprecision_wavefile(filename) % LOADPRECISION_WAVEFILE loads a Precision wavefile (egm data / locations) % Usage: -% info = loadprecision_egmdata(filename) +% info = loadprecision_wavefile(filename) % Where: % filename is the filename % info is the output @@ -140,7 +140,8 @@ if ~isempty(tokens); userComments = tokens{1}{1}; end % look for - "Export from Software Version: NAME" - tokens = regexp(line, 'Export from Software Version\s*:\s*(\w*)', 'tokens'); + % look for - "Exported from Software Version: NAME" + tokens = regexp(line, 'Export(?:ed) from Software Version\s*:\s*(\S*)', 'tokens'); if ~isempty(tokens); softwareVersion = tokens{1}{1}; end % look for - "Catheter[NUMBER](name, num electrodes): NAME,NUMBER" @@ -206,13 +207,14 @@ % Now we need to create the column headings according to the type of % file that we are reading. Also adjust sampleFreq if necessary - if strstartcmp('EPcathBIO_', dataElement) + dataElementType = translateDataExportElement(dataElement); + if strstartcmp('epcath_uni_', dataElementType) channels = usedChannels; channels.columnHeadings = cell(size(channels.channelNo)); for i = 1:numel(channels.channelNo) channels.columnHeadings{i} = ['c' num2str(channels.channelNo(i))]; end - elseif strstartcmp('EPcathBIObipol_', dataElement) || strstartcmp('EP_Catheter_Bipolar_', dataElement) + elseif strstartcmp('epcath_bip_', dataElementType) bipolChannels.columnHeadings = cell(size(bipolChannels.channelNo)); bipolChannels.cathName = cell(size(bipolChannels.channelNo)); for i = 1:numel(bipolChannels.channelNo) @@ -231,14 +233,14 @@ bipolChannels.columnHeadings{i} = [nameA '(' elecA '-' elecB ')_c' num2str(bipolChannels.channelNo(i))]; end channels = bipolChannels; - elseif strstartcmp('ECG_', dataElement) + elseif strstartcmp('ecg_', dataElementType) % create a 'catheter' for the 12 lead ECG catheters.name = 'ECG'; catheters.nElectrodes = 12; channels.cathName = repmat({'ECG'},12,1); channels.electrodeName = {'I','II','III','aVR','aVL','aVF','V1','V2','V3','V4','V5','V6'}'; channels.columnHeadings = {'I','II','III','aVR','aVL','aVF','V1','V2','V3','V4','V5','V6'}'; - elseif strstartcmp('Locations', dataElement) || strstartcmp('Electrode_Locations', dataElement) + elseif strstartcmp('locations', dataElementType) channels = usedChannels; channels.columnHeadings = cell(numel(channels.channelNo),3); for i = 1:numel(channels.channelNo) @@ -247,7 +249,7 @@ channels.columnHeadings{i,3} = ['c' num2str(channels.channelNo(i)) 'z']; end sampleFreq = 2034.5/20; - elseif strstartcmp('Respiration', dataElement) + elseif strstartcmp('respiration', dataElementType) catheters.name = 'Respiration'; catheters.nElectrodes = 2; channels.cathName = repmat({'Respiration'},2,1); @@ -403,9 +405,10 @@ % Check Export Data Element "Export Data Element : NAME" tokens = regexp(fData, 'Export Data Element\s*:\s*(\w*)', 'once', 'tokens'); - goodDataElements = {'EP_Catheter_Bipolar_Raw', 'EPcathBIO_COMPUTED', 'EPcathBIObipol_RAW', 'EPcathBIObipol_FILTERED', 'EPcathBIO_RAW', 'EPcathBIO_FILTERED', 'ECG_RAW', 'ECG_FILTERED', 'Respiration', 'Electrode_Locations', 'Locations' }; + goodDataElements = {'epcath_bip_raw', 'epcath_bip_filt', 'epcath_uni_raw', 'epcath_uni_filt', 'epcath_uni_comp', 'ecg_raw', 'ecg_filt', 'respiration', 'locations', 'displayedwaveforms' }; if ~isempty(tokens) dataElement = tokens{1}; + dataElement = translateDataExportElement(dataElement); tf = strcmp(dataElement, goodDataElements); if all(not(tf)) warning('LoadPrecision:InvalidFile','LOADPRECISION_WAVEFILE: Invalid Export Data Element'); @@ -422,9 +425,11 @@ ind4 = regexp(firstLine, 'Export\s*File\s*Version\s*:\s*5\.0R', 'once'); ind5 = regexp(firstLine, 'Export\s*File\s*Version\s*:\s*5\.1R', 'once'); ind6 = regexp(firstLine, 'St\.\s*Jude Medical\.\s*File Revision\s*:\s*5\.6', 'once'); - + ind7 = regexp(firstLine, 'Export\s*File\s*Version:\s*6\.3', 'once'); + ind8 = regexp(firstLine, 'Export\s*File\s*Version\s*:\s*5\.2', 'once'); + ind9 = regexp(firstLine, 'Export\s*File\s*Version\s*:\s*5\.3', 'once'); - if isempty(ind1) && isempty(ind2) && isempty(ind3) && isempty(ind4) && isempty(ind5) && isempty(ind6) + if isempty(ind1) && isempty(ind2) && isempty(ind3) && isempty(ind4) && isempty(ind5) && isempty(ind6) && isempty(ind7) && isempty(ind8) && isempty(ind9) warning('LoadPrecision:InvalidFile','LOADPRECISION_WAVEFILE: Invalid File Revision Number'); return end diff --git a/parse_header.m b/parse_header.m index 5d852d6..9d79a6c 100644 --- a/parse_header.m +++ b/parse_header.m @@ -40,6 +40,7 @@ iL = 0; startTime = NaN; endTime = NaN; +mapType = 'N/A'; while iL < (numel(indNL)-1) iL = iL + 1; line = header(indNL(iL):(indNL(iL+1)-2)); @@ -54,16 +55,20 @@ tokens = regexp(line, 'Map type\s*:\s*,\s*(\w*)', 'tokens'); if ~isempty(tokens) mapType = tokens{1}{1}; - else - mapType = 'N/A'; end - % look for - "Export File Version : TEXT" + % look for - "Export File Version : TEXTR" tokens = regexp(line, 'Export File Version\s*:\s*([a-zA-Z_.0-9]*)R', 'tokens'); if ~isempty(tokens) exportFileVersion = str2double(tokens{1}{1}); end + % look for - "Export File Version : TEXT" + tokens = regexp(line, 'Export File Version\s*:\s*([a-zA-Z_.0-9]*)', 'tokens'); + if ~isempty(tokens) + exportFileVersion = str2double(tokens{1}{1}); + end + % look for - "St. Jude Medical. File Revision : TEXT" tokens = regexp(line, 'St. Jude Medical. File Revision\s*:\s*([a-zA-Z_.0-9]*)', 'tokens'); if ~isempty(tokens) @@ -71,6 +76,11 @@ error('OPENEP/PARSE_HEADER: exportFileVersion has already been set'); else exportFileVersion = str2double(tokens{1}{1}); + + % *** TEMPORARY FIX - NEED TO UPDATE VERSION NUMBER PARSING *** + if isnan(exportFileVersion) + exportFileVersion = 5.6; + end end end diff --git a/prepare_carto_case.m b/prepare_carto_case.m new file mode 100644 index 0000000..719c7b5 --- /dev/null +++ b/prepare_carto_case.m @@ -0,0 +1,196 @@ +function [caseFolder, cleanupObj, info] = prepare_carto_case(cartoPath) +%PREPARE_CARTO_CASE Return an extracted CARTO folder ready for import. +% +% Folders are returned unchanged. ZIP archives are extracted to /dev/shm when +% possible, otherwise to tempdir. Keep the returned cleanup object alive for +% as long as the extracted files are needed. + +cartoPath = char(cartoPath); +cleanupObj = onCleanup(@() []); +info = emptyInfo(cartoPath); + +if isfolder(cartoPath) + caseFolder = cartoPath; + info.message = 'Using extracted CARTO folder.'; + return +end + +if ~isfile(cartoPath) + error('prepare_carto_case:MissingPath', ... + 'CARTO path does not exist: %s', cartoPath); +end + +[~, ~, ext] = fileparts(cartoPath); +if ~strcmpi(ext, '.zip') + error('prepare_carto_case:UnsupportedFile', ... + 'Expected a CARTO folder or ZIP file: %s', cartoPath); +end + +archiveInfo = inspect_carto_zip(cartoPath); +safetyBytes = max(0.15 * archiveInfo.uncompressedBytes, 2 * 1024^3); +requiredBytes = archiveInfo.uncompressedBytes + safetyBytes; +[extractionBase, availableBytes] = chooseExtractionBase(requiredBytes); +if isempty(extractionBase) + error('prepare_carto_case:InsufficientSpace', ... + ['Not enough temporary space to extract %s. Need %.2f GiB ', ... + '(%.2f GiB archive plus safety margin).'], cartoPath, ... + requiredBytes / 1024^3, archiveInfo.uncompressedBytes / 1024^3); +end + +destination = tempname(extractionBase); +mkdir(destination); +cleanupObj = onCleanup(@() removeFolder(destination)); + +extractStart = tic; +extractZipArchive(cartoPath, destination); +extractionSeconds = toc(extractStart); +caseFolder = findExtractedCartoFolder(destination); +if isempty(caseFolder) + error('prepare_carto_case:NoCartoFolder', ... + 'ZIP was extracted, but no CARTO study folder was found: %s', cartoPath); +end + +info.wasArchive = true; +info.extractionRoot = destination; +info.archiveCompressedBytes = archiveInfo.compressedBytes; +info.archiveUncompressedBytes = archiveInfo.uncompressedBytes; +info.archiveFileCount = archiveInfo.fileCount; +info.requiredBytes = requiredBytes; +info.availableBytesBeforeExtraction = availableBytes; +info.extractionSeconds = extractionSeconds; +info.message = sprintf('Extracted CARTO ZIP to %s.', destination); +end + +function extractZipArchive(zipPath, destination) +if isunix && isfile('/usr/bin/unzip') + extractionLog = fullfile(destination, 'openep_unzip.log'); + command = java.util.ArrayList(); + command.add('/usr/bin/unzip'); + command.add('-q'); + command.add(zipPath); + command.add('-d'); + command.add(destination); + processBuilder = java.lang.ProcessBuilder(command); + processBuilder.redirectErrorStream(true); + processBuilder.redirectOutput(java.io.File(extractionLog)); + process = processBuilder.start(); + status = process.waitFor(); + if status ~= 0 + details = ''; + if isfile(extractionLog) + details = strtrim(fileread(extractionLog)); + end + error('prepare_carto_case:ExtractionFailed', ... + 'unzip failed with status %d while extracting %s. %s', ... + status, zipPath, details); + end + if isfile(extractionLog) + delete(extractionLog); + end +else + unzip(zipPath, destination); +end +end + +function info = emptyInfo(sourcePath) +info = struct( ... + 'sourcePath', sourcePath, ... + 'wasArchive', false, ... + 'extractionRoot', '', ... + 'archiveCompressedBytes', 0, ... + 'archiveUncompressedBytes', 0, ... + 'archiveFileCount', 0, ... + 'requiredBytes', 0, ... + 'availableBytesBeforeExtraction', 0, ... + 'extractionSeconds', 0, ... + 'message', ''); +end + +function [extractionBase, availableBytes] = chooseExtractionBase(requiredBytes) +candidates = {}; +if isfolder('/dev/shm') + candidates{end+1} = '/dev/shm'; +end +candidates{end+1} = tempdir; + +extractionBase = ''; +availableBytes = 0; +for i = 1:numel(candidates) + candidate = candidates{i}; + candidateBytes = usableSpaceBytes(candidate); + hasMemory = true; + if strcmp(candidate, '/dev/shm') + memoryReserveBytes = 8 * 1024^3; + hasMemory = availableMemoryBytes() >= requiredBytes + memoryReserveBytes; + end + if candidateBytes >= requiredBytes && hasMemory + extractionBase = candidate; + availableBytes = candidateBytes; + return + end +end +end + +function bytes = availableMemoryBytes() +bytes = Inf; +if ~isfile('/proc/meminfo') + return +end + +text = fileread('/proc/meminfo'); +token = regexp(text, 'MemAvailable:\s+(\d+)\s+kB', 'tokens', 'once'); +if ~isempty(token) + bytes = str2double(token{1}) * 1024; +end +end + +function bytes = usableSpaceBytes(folderPath) +bytes = 0; +try + fileObj = java.io.File(folderPath); + bytes = double(fileObj.getUsableSpace()); +catch + [status, out] = system(sprintf('df -Pk "%s"', folderPath)); + if status == 0 + lines = regexp(strtrim(out), '\n', 'split'); + if numel(lines) >= 2 + parts = regexp(strtrim(lines{2}), '\s+', 'split'); + if numel(parts) >= 4 + bytes = str2double(parts{4}) * 1024; + end + end + end +end +end + +function caseFolder = findExtractedCartoFolder(rootFolder) +candidateFolders = {}; +meshFiles = dir(fullfile(rootFolder, '**', '*.mesh')); +for i = 1:numel(meshFiles) + folderPath = meshFiles(i).folder; + xmlFiles = dir(fullfile(folderPath, '*.xml')); + names = {xmlFiles.name}; + hasStudyXml = any(~startsWith(names, '.') & ... + ~contains(names, 'Point_Export') & ~contains(names, 'Points_Export')); + if hasStudyXml + candidateFolders{end+1} = folderPath; %#ok + end +end +candidateFolders = unique(candidateFolders, 'stable'); + +if isempty(candidateFolders) + caseFolder = ''; +elseif isscalar(candidateFolders) + caseFolder = candidateFolders{1}; +else + error('prepare_carto_case:AmbiguousArchive', ... + 'CARTO ZIP contains multiple study folders: %s', ... + strjoin(candidateFolders, ', ')); +end +end + +function removeFolder(folderPath) +if isfolder(folderPath) + rmdir(folderPath, 's'); +end +end diff --git a/private/nameFolds.m b/private/nameFolds.m new file mode 100644 index 0000000..dba11ac --- /dev/null +++ b/private/nameFolds.m @@ -0,0 +1,29 @@ +function listing = nameFolds(pathname) +% NAMEFOLDS lists all subfolders within pathname. +% Usage: +% listing = nameFolds(pathname) +% Where: +% a is the input +% b is the output +% +% NAMEFOLDS lists all subfolders within a directory by using the matlab +% command dir and removing anything that isn't a directory. +% +% Author: Steven Williams (2013) +% Modifications - +% +% Info on Code Testing: +% --------------------------------------------------------------- +% test code +% --------------------------------------------------------------- +% +% --------------------------------------------------------------- +% code +% --------------------------------------------------------------- + +d = dir(pathname); +isub = [d(:).isdir]; % returns logical vector +listing = {d(isub).name}'; +listing(ismember(listing,{'.','..'})) = []; + +end \ No newline at end of file diff --git a/private/triarea.m b/private/triarea.m index 340bfb3..601dd2d 100644 --- a/private/triarea.m +++ b/private/triarea.m @@ -14,6 +14,9 @@ % Author: Nick Linton (2010) % Modifications - 2013 capability for triangulation object added +% 2026 - handle a situations where poorly formed triangles with two +% co-incident vertices return imaginary numbers. If such simplices exist, +% their area is now returned as 0. switch nargin case 1 @@ -60,4 +63,9 @@ s2 = a2+b2+c2; % s2 = sum of squared lenths s4 = a2.*a2 + b2.*b2 + c2.*c2; % s4 = sum of fourth powered lengths -area = 0.25 * sqrt( s2.*s2 - 2*s4 ); +A = s2.*s2 - 2*s4; + +% Handle poorly formed triangles e.g. wtih co-incident vertices +A(A<0) = 0; + +area = 0.25 * sqrt(A); diff --git a/read_ecgfile_v4.m b/read_ecgfile_v4.m index 0fab068..40116ad 100644 --- a/read_ecgfile_v4.m +++ b/read_ecgfile_v4.m @@ -36,15 +36,34 @@ headerInfo.nSamples = 2500; filename = varargin{1}; + +% This section added to be able to remember if a manual gain has been set +% before in this session +manualgain = []; +if nargin==2 + manualgain = varargin{2}; +end + fid = fopen(filename, 'r', 'ieee-le', 'UTF-8'); % MUCH faster than fid = fopen(filename, 'r') if fid == (-1) error(['READ_ECGFILE: Could not read the file: "' filename '"']); end try line1 = fgetl(fid); + + % line 1 + line1(isspace(line1)) = []; + if ~startsWith(line1,'ECG_Export_4.0','IgnoreCase',true) && ~startsWith(line1,'ECG_Export_4.1','IgnoreCase',true) + error('READ_ECGFILE: The version number in the txt file is unexpected.') %#ok<*WNTAG> + end + line2 = fgetl(fid); line3 = fgetl(fid); - line4 = fgetl(fid); + + % if line + if startsWith(line1,'ECG_Export_4.0','IgnoreCase',true) + line4 = fgetl(fid); + end if nargout>=2 vData = fread(fid,'*char')'; end @@ -54,37 +73,52 @@ rethrow(err) end -% Check lines have expected information and retrieve it -% line 1 -line1(isspace(line1)) = []; -if ~startsWith(line1,'ECG_Export_4.0','IgnoreCase',true) - error('READ_ECGFILE: The version number in the txt file is unexpected.') %#ok<*WNTAG> -end +% Check lines have expected information and retrieve it (we have already +% checked line 1) + % line 2 line2(isspace(line2)) = []; if startsWith(line2,'rawecgtomv(gain)=0.003000','IgnoreCase',true) headerInfo.gain = 0.003; else - error('READ_ECGFILE: Unexpected statement about gain.') %#ok<*WNTAG> + if isempty(manualgain) + warning('READ_ECGFILE: Unexpected statement about gain.') + if isempty(manualgain) + str = input('Do you want to manually set the gain (enter = no; value = yes). This gain will be used for all future points unless the agin is set in the ECG file. Enter a value here (e.g. 0.003): '); + if isempty(str) + error('READ_ECGFILE: Unexpected statement about gain.') %#ok<*WNTAG> + else + headerInfo.gain = str; + manualgain = headerInfo.gain; + end + end + else + headerInfo.gain = manualgain; + end +end + +% line 3 (optional) +if startsWith(line1,'ECG_Export_4.0','IgnoreCase',true) + pattern = [ 'Unipolar Mapping Channel=(?\S*)\s*', ... + 'Bipolar Mapping Channel=(?\S*)\s*', ... + 'Reference Channel=(?\S*)\s*']; + result = regexpi(line3,pattern,'names'); + if ~isempty(result) + headerInfo.uniMapChannel = result.uni; + headerInfo.bipMapChannel = result.bip; + headerInfo.refChannel = result.ref; + + headerInfo.uniMapChannel2 = incrementUnipoleName(headerInfo.uniMapChannel); + + % choose the correct next line + nextLine = line4; + end +elseif startsWith(line1,'ECG_Export_4.1','IgnoreCase',true) + nextLine = line3; end -% line 3 -pattern = [ 'Unipolar Mapping Channel=(?\S*)\s*', ... - 'Bipolar Mapping Channel=(?\S*)\s*', ... - 'Reference Channel=(?\S*)\s*']; -result = regexpi(line3,pattern,'names'); -headerInfo.uniMapChannel = result.uni; -headerInfo.bipMapChannel = result.bip; -headerInfo.refChannel = result.ref; - -%increment the unipole name by 1 to get the second unipole (this assumes -%that the second unipole is always the 'first + 1' -pattern = '(?\S*\D)(?\d*)'; -result = regexpi(headerInfo.uniMapChannel,pattern,'names'); -headerInfo.uniMapChannel2 = [result.name, num2str(str2double(result.number)+1)]; - -% line 4 -[headerInfo.channelNamesFull, nomatch] = regexpi(line4,'([\w-]*\(\d*\))','match','split'); +% line 4 (or line 3) +[headerInfo.channelNamesFull, nomatch] = regexpi(nextLine,'([\w-]*\(\d*\))','match','split'); % check that nomatch strings are only white space characters test = regexp(nomatch,'\S*'); for i = 1:numel(test) diff --git a/read_electrodePositionsOnAnnotation.m b/read_electrodePositionsOnAnnotation.m index 1662f9d..288c5a0 100644 --- a/read_electrodePositionsOnAnnotation.m +++ b/read_electrodePositionsOnAnnotation.m @@ -56,7 +56,12 @@ if numel(names)==1 % maybe the user gave a Connector name for i = 1:numel(conData) - if matches(conData(i).connector,names{1},IgnoreCase=true) + if isMATLABReleaseOlderThan('R2022a') + hasMatches = matches(conData(i).connector,names{1},'IgnoreCase',true); + else + hasMatches = matches(conData(i).connector,names{1},IgnoreCase=true); + end + if hasMatches namesRead = conData(i).electrodeNames; electrodePositions = local_readallpositions(pointFileName, conData(i)); return %% return to user @@ -93,6 +98,39 @@ % get the Filenames that we will need connectorFilenames = local_getConnectorFilenames(pointFileName); +% check that we have the file we need +connectorName = conData(conIndex).connector; +idx = local_getIndexFirstMatch(connectorFilenames(:,1),connectorName); +if idx==0 + % the expected connector does not exist. this is most likely due to + % overlap in the naming convention for Navistar and MEC catheters, so + % remove the identified connector from conData and try to check again + conData(conIndex) = []; +end + +% now repeat the connector identification process - this is a repeat of +% lines 75-94 +conIndex = zeros(numel(names),1); +isBipolar = false(numel(names),1); +for iName = 1:numel(names) + for i = 1:numel(conData) + testUNI = regexpi(names{iName},[conData(i).unipolarNaming '\d']); + if ~isempty(testUNI) && testUNI(1)==1 + %it's a unipolar channel + %isBipolar(iName) = false; + conIndex(iName) = i; + break + end + testBI = regexpi(names{iName},[conData(i).bipolarNaming '\d']); + if ~isempty(testBI) && testBI(1)==1 + % it's a bipolar channel + isBipolar(iName) = true; + conIndex(iName) = i; + break + end + end +end + electrodePositions = zeros(numel(names),3); electrodePositionsAll = []; for iCon = 1:numel(conData) @@ -157,7 +195,7 @@ for iC = 1:nConnectors names = fieldnames(positions.Connector(iC).ATTRIBUTE); positionFile = positions.Connector(iC).ATTRIBUTE.(names{1}); - if contains(positionFile,'Eleclectrode_positions_OnAnnotation.txt', 'IgnoreCase',true) %then it is an Eleclectrode_Positions_OnAnnotation.txt file + if contains(positionFile,'Eleclectrode_positions_OnAnnotation', 'IgnoreCase',true) %then it is an Eleclectrode_Positions_OnAnnotation.txt file connectorFilenames{count,1} = names{1}; connectorFilenames{count,2} = fullfile(homeDir, positionFile); count = count+1; @@ -170,7 +208,8 @@ function idx = local_getIndexFirstMatch(nameList, name) idx = 0; for i = 1:numel(nameList) - if matches(nameList{i},name) + %if matches(nameList{i},name) + if strstartcmpi(name, nameList{i}) % so we can handle MEC_CONNECTOR and MEC idx = i; return end diff --git a/removeEmptyFields.m b/removeEmptyFields.m new file mode 100644 index 0000000..db25dd1 --- /dev/null +++ b/removeEmptyFields.m @@ -0,0 +1,60 @@ +function S = removeEmptyFields(S) +% REMOVEEMPTYFIELDS Remove empty fields from a structure +% +% Usage: +% S = removeEmptyFields(S) +% Where: +% S - the input/output structure +% +% MYFUNCTION accepts the following parameter-value pairs +% 'param1' {value1}|vallue2 +% +% MYFUNCTION Detailed description goes here +% +% Author: Steven Williams (2016) +% Modifications - +% +% Info on Code Testing: +% --------------------------------------------------------------- +% test code +% --------------------------------------------------------------- +% +% --------------------------------------------------------------- +% code +% --------------------------------------------------------------- + +% Only operate on structures +if ~isstruct(S) + return +end + +% First recurse into sub-structs +for k = 1:numel(S) + fields = fieldnames(S(k)); + + for i = 1:numel(fields) + fname = fields{i}; + value = S(k).(fname); + + if isstruct(value) + S(k).(fname) = removeEmptyFields(value); + end + end +end + +% Now determine which fields are empty across ALL elements +fields = fieldnames(S); +remove = false(size(fields)); + +for i = 1:numel(fields) + fname = fields{i}; + + % Field is removable only if empty in every element + remove(i) = all(arrayfun(@(x) isempty(x.(fname)), S)); +end + +% Remove fields in one operation (safe) +if any(remove) + S = rmfield(S, fields(remove)); +end +end \ No newline at end of file diff --git a/run_validation_tests.m b/run_validation_tests.m new file mode 100644 index 0000000..9da9d8f --- /dev/null +++ b/run_validation_tests.m @@ -0,0 +1,16 @@ +%% Run validation and importer regression tests + +clear; clc; + +repoRoot = fileparts(mfilename('fullpath')); +addpath(repoRoot); +addpath(fullfile(repoRoot, 'validation')); + +testFolders = { + fullfile(repoRoot, 'tests', 'validation') + fullfile(repoRoot, 'tests', 'import') +}; + +results = runtests(testFolders, 'IncludeSubfolders', true); +disp(table(results)) +assertSuccess(results) diff --git a/select_openep_dataset.m b/select_openep_dataset.m new file mode 100644 index 0000000..0ace76a --- /dev/null +++ b/select_openep_dataset.m @@ -0,0 +1,18 @@ +function [userdata, dataset] = select_openep_dataset(openepCase, selector) +%SELECT_OPENEP_DATASET Select one userdata dataset by ID or recording mode. + +assert(isstruct(openepCase) && isfield(openepCase, 'datasets') && ... + isstruct(openepCase.datasets), ... + 'SELECT_OPENEP_DATASET: Invalid OpenEP case container.'); + +selector = char(selector); +ids = {openepCase.datasets.id}; +modes = {openepCase.datasets.recordingMode}; +matches = strcmpi(ids, selector) | strcmpi(modes, selector); +if sum(matches) ~= 1 + error('SELECT_OPENEP_DATASET: Selector must identify exactly one dataset.'); +end + +dataset = openepCase.datasets(matches); +userdata = dataset.userdata; +end diff --git a/setMesh.m b/setMesh.m index 55bf7b6..47fcf9b 100644 --- a/setMesh.m +++ b/setMesh.m @@ -38,10 +38,10 @@ type = 'triangulation'; elseif isa(tNew, 'struct') type = 'struct'; - if ~isfield(userdata.surface.triRep, 'X') + if ~isfield(tNew, 'X') error('OPENEP/SETMESH: invalid input data. tNew must be one of: TriRep, triangulation or struct with fields .X and .Triangulation'); end - if ~isfield(userdata.surface.triRep, 'Triangulation') + if ~isfield(tNew, 'Triangulation') error('OPENEP/SETMESH: invalid intput data. tNew must be one of: TriRep, triangulation or struct with fields .X and .Triangulation'); end end diff --git a/tests/calculatePeak2PeakVoltageTest.m b/tests/calculatePeak2PeakVoltageTest.m new file mode 100644 index 0000000..299bc93 --- /dev/null +++ b/tests/calculatePeak2PeakVoltageTest.m @@ -0,0 +1,17 @@ +classdef calculatePeak2PeakVoltageTest < matlab.unittest.TestCase + methods (Test) + function calculatesVoltageWithoutOptionalToolboxes(testCase) + egms = [ + 0 1 5 2 0 + 0 4 4 4 0 + ]; + referenceAnnotations = [3; 3]; + windowsOfInterest = [-1 1; -1 1]; + + voltage = calculatePeak2PeakVoltage( ... + egms, referenceAnnotations, windowsOfInterest); + + testCase.verifyEqual(voltage, [4; 0]); + end + end +end diff --git a/tests/import/CartoFullCaseImportTest.m b/tests/import/CartoFullCaseImportTest.m new file mode 100644 index 0000000..950e3f8 --- /dev/null +++ b/tests/import/CartoFullCaseImportTest.m @@ -0,0 +1,141 @@ +classdef CartoFullCaseImportTest < matlab.unittest.TestCase + % Opt-in integration test for a complete CARTO archive. + + methods (TestClassSetup) + function addProjectPaths(~) + repoRoot = fileparts(fileparts(fileparts(mfilename('fullpath')))); + addpath(repoRoot); + addpath(fullfile(repoRoot, 'validation')); + end + end + + methods (Test) + function importsStudy1Map2LAIntoValidatedUserdata(testCase) + testCase.assumeTrue(runFullCartoTests(), ... + 'Set RUN_FULL_CARTO_IMPORT_TESTS=1 to run the full CARTO import.'); + + repoRoot = fileparts(fileparts(fileparts(mfilename('fullpath')))); + casePath = getenv('OPENEP_FULL_CARTO_CASE'); + if isempty(casePath) + casePath = fullfile(fileparts(repoRoot), 'full_cases', 'Carto', ... + 'ReCETT-AF', 'Study1-Williams-Edinburgh', ... + 'Export_PAF-02_28_2023-14-19-48.zip'); + end + testCase.assumeTrue(isfolder(casePath) || isfile(casePath), ... + 'Full CARTO Study1 test case was not found.'); + + mapName = environmentDefault('OPENEP_FULL_CARTO_MAP', '2-LA'); + refChannel = environmentDefault( ... + 'OPENEP_FULL_CARTO_REFCHANNEL', 'CS1-CS2'); + ecgChannel = environmentDefault( ... + 'OPENEP_FULL_CARTO_ECGCHANNEL', 'V1'); + outputFile = [tempname, '.mat']; + cleanupObj = onCleanup(@() deleteConversionFiles(outputFile)); + + result = convert_mapping_case(casePath, outputFile, ... + 'system', 'carto', ... + 'maptoread', mapName, ... + 'refchannel', refChannel, ... + 'ecgchannel', ecgChannel, ... + 'validationlevel', 'standard'); + + testCase.verifyTrue(result.success, result.error.message); + testCase.verifyTrue(result.outputPublished); + testCase.verifyTrue(isfile(outputFile)); + testCase.verifyTrue(isfile(result.statusFile)); + testCase.verifyTrue(isfile(result.logFile)); + testCase.verifyEqual(result.outputValidation.numFail, 0, ... + result.outputValidation.summary); + loaded = load(outputFile, 'userdata'); + userdata = loaded.userdata; + testCase.verifyTrue(isstruct(userdata.surface.triRep)); + verifyMesh(testCase, userdata.surface.triRep); + verifyElectricData(testCase, userdata.electric, mapName); + printTimings(result); + + if isfield(result.archive, 'wasArchive') && result.archive.wasArchive + extractionRoot = result.archive.extractionRoot; + testCase.verifyFalse(isfolder(extractionRoot)); + end + delete(cleanupObj); + end + end +end + +function tf = runFullCartoTests() +tf = any(strcmpi(getenv('RUN_FULL_CARTO_IMPORT_TESTS'), ... + {'1', 'true', 'yes'})); +end + +function value = environmentDefault(name, defaultValue) +value = getenv(name); +if isempty(value) + value = defaultValue; +end +end + +function verifyMesh(testCase, mesh) +testCase.verifyTrue(isfield(mesh, 'X')); +testCase.verifyTrue(isfield(mesh, 'Triangulation')); +testCase.verifyGreaterThan(size(mesh.X, 1), 0); +testCase.verifyEqual(size(mesh.X, 2), 3); +testCase.verifyGreaterThan(size(mesh.Triangulation, 1), 0); +testCase.verifyEqual(size(mesh.Triangulation, 2), 3); +testCase.verifyTrue(all(isfinite(mesh.X), 'all')); + +faces = mesh.Triangulation; +testCase.verifyTrue(all(isfinite(faces), 'all')); +testCase.verifyTrue(all(faces == round(faces), 'all')); +testCase.verifyGreaterThanOrEqual(min(faces, [], 'all'), 1); +testCase.verifyLessThanOrEqual(max(faces, [], 'all'), size(mesh.X, 1)); +end + +function verifyElectricData(testCase, electric, mapName) +nPoints = size(electric.egmX, 1); +if strcmp(mapName, '2-LA') + testCase.verifyEqual(nPoints, 711); +else + testCase.verifyGreaterThan(nPoints, 0); +end + +testCase.verifySize(electric.egmX, [nPoints, 3]); +testCase.verifyEqual(size(electric.egm, 1), nPoints); +testCase.verifyEqual(size(electric.egmUni, 1), nPoints); +testCase.verifyEqual(size(electric.egmUni, 3), 2); +testCase.verifySize(electric.egmUniX, [nPoints, 3, 2]); +testCase.verifyEqual(size(electric.egmRef, 1), nPoints); +testCase.verifyEqual(size(electric.ecg, 1), nPoints); +testCase.verifyEqual(numel(electric.names), nPoints); +testCase.verifyEqual(size(electric.annotations.mapAnnot, 1), nPoints); +testCase.verifyEqual(size(electric.voltages.bipolar, 1), nPoints); +testCase.verifyEqual(size(electric.voltages.unipolar, 1), nPoints); +end + +function printTimings(result) +timings = result.timings; +fprintf(['CARTO full-case timing: preparation %.1f s, import %.1f s, ', ... + 'validation %.1f s, save %.1f s, total %.1f s.\n'], ... + timings.preparationSeconds, timings.importSeconds, ... + timings.outputValidationSeconds, timings.saveSeconds, ... + timings.totalSeconds); +if isfield(result.archive, 'wasArchive') && result.archive.wasArchive + fprintf('CARTO archive: %d files, %.2f GiB uncompressed, root %s.\n', ... + result.archive.archiveFileCount, ... + result.archive.archiveUncompressedBytes / 1024^3, ... + result.archive.extractionRoot); +end +end + +function deleteConversionFiles(outputFile) +[folder, name] = fileparts(outputFile); +files = { + outputFile + fullfile(folder, [name, '.status.json']) + fullfile(folder, [name, '.log.txt']) + }; +for i = 1:numel(files) + if isfile(files{i}) + delete(files{i}); + end +end +end diff --git a/tests/import/EnsiteFullCaseImportTest.m b/tests/import/EnsiteFullCaseImportTest.m new file mode 100644 index 0000000..755be5c --- /dev/null +++ b/tests/import/EnsiteFullCaseImportTest.m @@ -0,0 +1,97 @@ +classdef EnsiteFullCaseImportTest < matlab.unittest.TestCase + % Opt-in integration test for a complete multi-folder EnSiteX case. + + methods (TestClassSetup) + function addProjectPaths(~) + repoRoot = fileparts(fileparts(fileparts(mfilename('fullpath')))); + addpath(repoRoot); + addpath(fullfile(repoRoot, 'validation')); + end + end + + methods (Test) + function importsAllModesIntoValidatedCaseContainer(testCase) + testCase.assumeTrue(runFullImporterTests(), ... + 'Set RUN_FULL_IMPORTER_SMOKE_TESTS=1 to run full imports.'); + + repoRoot = fileparts(fileparts(fileparts(mfilename('fullpath')))); + caseRoot = getenv('OPENEP_FULL_ENSITEX_CASE'); + if isempty(caseRoot) + caseRoot = fullfile(fileparts(repoRoot), 'full_cases', ... + 'EnsiteX', 'Brussels', 'Study3-Gharaviri-Brussels'); + end + testCase.assumeTrue(isfolder(caseRoot), ... + 'Full EnSiteX Study3 test case was not found.'); + + mapName = getenv('OPENEP_FULL_ENSITEX_MAP'); + if isempty(mapName) + mapName = 'VoXel SR 1 ENDO'; + end + outputFile = [tempname, '.mat']; + cleanupObj = onCleanup(@() deleteConversionFiles(outputFile)); + + result = convert_mapping_case(caseRoot, outputFile, ... + 'system', 'ensitex', ... + 'maptoread', mapName, ... + 'modes', {'bi', 'uni', 'omni'}, ... + 'validationlevel', 'standard'); + + testCase.verifyTrue(result.success, result.error.message); + testCase.verifyTrue(result.outputPublished); + testCase.verifyTrue(isfile(outputFile)); + testCase.verifyTrue(isfile(result.statusFile)); + testCase.verifyTrue(isfile(result.logFile)); + testCase.verifyEqual(result.outputValidation.numFail, 0, ... + result.outputValidation.summary); + loaded = load(outputFile, 'openepCase'); + openepCase = loaded.openepCase; + testCase.verifyEqual({openepCase.datasets.recordingMode}, ... + {'bi', 'uni', 'omni'}); + + expectedPoints = [4585, 3620, 7110]; + actualPoints = arrayfun(@(d) size(d.userdata.electric.egmX, 1), ... + openepCase.datasets); + testCase.verifyEqual(actualPoints(:)', expectedPoints); + + verifyEgmLayout(testCase, openepCase.datasets(1), 2); + verifyEgmLayout(testCase, openepCase.datasets(2), 1); + verifyEgmLayout(testCase, openepCase.datasets(3), 3); + delete(cleanupObj); + end + end +end + +function verifyEgmLayout(testCase, dataset, nComponents) +electric = dataset.userdata.electric; +nPoints = size(electric.egm, 1); +nSamples = size(electric.egm, 2); + +if nComponents == 1 + testCase.verifySize(electric.egmUni, [nPoints, nSamples]); + testCase.verifySize(electric.egmUniX, [nPoints, 3]); +else + testCase.verifySize(electric.egmUni, ... + [nPoints, nSamples, nComponents]); + testCase.verifySize(electric.egmUniX, [nPoints, 3, nComponents]); +end +testCase.verifySize(electric.electrodeNames_uni, [nPoints, nComponents]); +end + +function tf = runFullImporterTests() +tf = any(strcmpi(getenv('RUN_FULL_IMPORTER_SMOKE_TESTS'), ... + {'1', 'true', 'yes'})); +end + +function deleteConversionFiles(outputFile) +[folder, name] = fileparts(outputFile); +files = { + outputFile + fullfile(folder, [name, '.status.json']) + fullfile(folder, [name, '.log.txt']) + }; +for i = 1:numel(files) + if isfile(files{i}) + delete(files{i}); + end +end +end diff --git a/tests/import/InspectEnsiteXExportTest.m b/tests/import/InspectEnsiteXExportTest.m new file mode 100644 index 0000000..b4fc47c --- /dev/null +++ b/tests/import/InspectEnsiteXExportTest.m @@ -0,0 +1,146 @@ +classdef InspectEnsiteXExportTest < matlab.unittest.TestCase + properties + TempRoot + end + + methods (TestClassSetup) + function createTemporaryRoot(testCase) + repoRoot = fileparts(fileparts(fileparts(mfilename('fullpath')))); + addpath(repoRoot); + testCase.TempRoot = tempname; + mkdir(testCase.TempRoot); + end + end + + methods (TestClassTeardown) + function removeTemporaryRoot(testCase) + if isfolder(testCase.TempRoot) + rmdir(testCase.TempRoot, 's'); + end + end + end + + methods (Test) + function renamedFilesUseSemanticHeaders(testCase) + folder = testCase.newExportFolder('renamed'); + writeMapCsv(fullfile(folder, 'arbitrary-a.csv'), 'LAT_bi', commonColumns()); + writeWaveCsv(fullfile(folder, 'arbitrary-b.csv'), 'refs'); + writeWaveCsv(fullfile(folder, 'arbitrary-c.csv'), 'rov'); + + manifest = inspectensitex_export(testCase.TempRoot); + export = manifest.exports(1); + + testCase.verifyEqual(export.recordingMode, 'bi'); + testCase.verifyEqual(export.confidence, 'high'); + testCase.verifyEqual(sort({export.waveFiles.role}), {'refs', 'rov'}); + testCase.verifyTrue(all(strcmp({export.waveFiles.source}, 'header'))); + end + + function waveFilenameFallbackIsReported(testCase) + folder = testCase.newExportFolder('fallback'); + writeMapCsv(fullfile(folder, 'map.csv'), 'LAT_bi', commonColumns()); + writeWaveCsv(fullfile(folder, 'Wave_refs.csv'), ''); + + manifest = inspectensitex_export(folder); + + testCase.verifyEqual(manifest.exports.waveFiles.role, 'refs'); + testCase.verifyEqual(manifest.exports.waveFiles.source, 'filename'); + end + + function omnipolarSchemaWorksWithoutModeSuffix(testCase) + folder = testCase.newExportFolder('omni-schema'); + columns = [commonColumns(), { + 'pp_Vmax', 'pp_Valong', 'pp_Vacross', ... + 'Uni_Corner_Elec', 'Uni_Along_Elec', 'Uni_Across_Elec' + }]; + writeMapCsv(fullfile(folder, 'map.csv'), 'LAT', columns); + + manifest = inspectensitex_export(folder); + + testCase.verifyEqual(manifest.exports.recordingMode, 'omni'); + testCase.verifyEqual(manifest.exports.confidence, 'high'); + end + + function insufficientEvidenceRemainsUnknown(testCase) + folder = testCase.newExportFolder('unknown'); + writeMapCsv(fullfile(folder, 'map.csv'), 'LAT', commonColumns()); + writeWaveCsv(fullfile(folder, 'signal.csv'), 'rov'); + + manifest = inspectensitex_export(folder); + + testCase.verifyEqual(manifest.exports.recordingMode, 'unknown'); + end + + function conflictingMapHeadersAreReported(testCase) + folder = testCase.newExportFolder('conflict'); + writeMapCsv(fullfile(folder, 'map-one.csv'), 'LAT_bi', commonColumns()); + writeMapCsv(fullfile(folder, 'map-two.csv'), 'PP_uni', commonColumns()); + + manifest = inspectensitex_export(folder); + + testCase.verifyEqual(manifest.exports.recordingMode, 'conflict'); + testCase.verifyNotEmpty(manifest.exports.errors); + end + end + + methods (Access = private) + function folder = newExportFolder(testCase, name) + folder = fullfile(testCase.TempRoot, name, 'anything'); + mkdir(folder); + writeTextFile(fullfile(fileparts(folder), ... + 'Contact_Mapping_Model.xml'), ''); + end + end +end + +function columns = commonColumns() +columns = {'Rov trace', 'Electrodes', 'Freeze Grp #', '(Point #)', ... + 'roving x', 'roving y', 'roving z', 'LAT'}; +end + +function writeMapCsv(filePath, mapType, columns) +header = { + 'Export File Version: 11' + 'Export Data Element: DxL' + 'Map name:,Test Map' + ['Map type:,', mapType] + '# mapping pts:,2' +}; +writeDxlCsv(filePath, header, columns); +end + +function writeWaveCsv(filePath, waveName) +header = { + 'Export File Version: 11' + 'Export Data Element: DxL' + 'Map name:,Test Map' + ['Wave name:,', waveName] + '# freeze groups:,2' + 'Sample rate:,2000' +}; +writeDxlCsv(filePath, header, ... + {'Trace', 'Freeze Grp #', '(Point #)', 'startTime (abs)', ... + 'rovTime (wave samples)', '0'}); +end + +function writeDxlCsv(filePath, header, columns) +dataStartRow = numel(header) + 3; +fid = fopen(filePath, 'w'); +assert(fid ~= -1, 'Could not create test CSV: %s', filePath); +cleanupObj = onCleanup(@() fclose(fid)); +for i = 1:numel(header) + fprintf(fid, '%s\n', header{i}); +end +fprintf(fid, 'Data starts in row,%d\n', dataStartRow); +fprintf(fid, '*****\n'); +fprintf(fid, '%s\n', strjoin(columns, ',')); +delete(cleanupObj); +end + +function writeTextFile(filePath, text) +fid = fopen(filePath, 'w'); +assert(fid ~= -1, 'Could not create test file: %s', filePath); +cleanupObj = onCleanup(@() fclose(fid)); +fprintf(fid, '%s\n', text); +delete(cleanupObj); +end diff --git a/tests/import/OSheaEnsiteImporterRegressionTest.m b/tests/import/OSheaEnsiteImporterRegressionTest.m new file mode 100644 index 0000000..f0d3dec --- /dev/null +++ b/tests/import/OSheaEnsiteImporterRegressionTest.m @@ -0,0 +1,59 @@ +classdef OSheaEnsiteImporterRegressionTest < matlab.unittest.TestCase + % Regression tests for malformed waveform exports in O'Shea Study2. + + properties + TestDataRoot + end + + methods (TestClassSetup) + function locateTestData(testCase) + repoRoot = fileparts(fileparts(fileparts(mfilename('fullpath')))); + addpath(repoRoot); + + testDataRoot = getenv('OPENEP_TESTING_DATA'); + if isempty(testDataRoot) + candidate = fileparts(repoRoot); + if isfolder(fullfile(candidate, 'EnsiteXDXLFiles')) + testDataRoot = candidate; + end + end + testCase.TestDataRoot = testDataRoot; + end + end + + methods (Test) + function waveRefsUnlabeledSignalColumnLoads(testCase) + csvPath = fullfile(testCase.TestDataRoot, 'EnsiteXDXLFiles', ... + 'Study2-OShea-Birmingham', 'Wave_refs.csv'); + testCase.assumeTrue(isfile(csvPath), ... + 'O''Shea Wave_refs.csv test data not found.'); + + warningState = warning('off', 'all'); + cleanupWarnings = onCleanup(@() warning(warningState)); %#ok + [info, varnames, data] = loadensitex_dxldata( ... + csvPath, 'ShowProgress', false); + + testCase.verifyEqual(info.sampleFreq, 2000); + testCase.verifyEqual(size(data, 1), 1888); + testCase.verifyEqual(varnames{end}, 'signals'); + testCase.verifyEqual(numel(data{1, end}), 1); + end + + function waveRovMissingValuesPreserveSignalAlignment(testCase) + csvPath = fullfile(testCase.TestDataRoot, 'EnsiteXDXLFiles', ... + 'Study2-OShea-Birmingham', 'Wave_rov.csv'); + testCase.assumeTrue(isfile(csvPath), ... + 'O''Shea Wave_rov.csv test data not found.'); + + warningState = warning('off', 'all'); + cleanupWarnings = onCleanup(@() warning(warningState)); %#ok + [info, varnames, data] = loadensitex_dxldata( ... + csvPath, 'ShowProgress', false); + + testCase.verifyEqual(info.sampleFreq, 2000); + testCase.verifyEqual(size(data, 1), 5991); + testCase.verifyEqual(varnames{end}, 'signals'); + testCase.verifyEqual(numel(data{1, end}), 2001); + end + end +end diff --git a/tests/importCartoMemTest.m b/tests/import/importCartoMemTest.m similarity index 86% rename from tests/importCartoMemTest.m rename to tests/import/importCartoMemTest.m index 32af0a2..cae8edd 100644 --- a/tests/importCartoMemTest.m +++ b/tests/import/importCartoMemTest.m @@ -19,6 +19,11 @@ methods (TestClassSetup) function loadCartoStudy1Map2(testCase) + testingRoot = getenv("OPENEP_TESTING_DATA"); + studyFile = fullfile(testingRoot, ... + 'Carto/Export_Study-1-11_25_2021-15-01-32/Study 1 11_25_2021 15-01-32.xml'); + testCase.assumeTrue(strlength(testingRoot) > 0 && isfile(studyFile), ... + 'Set OPENEP_TESTING_DATA to run the legacy CARTO fixture tests.'); testCase.carto_study1_map2 = importCartoMemTest.loadCarto( ... 'Carto/Export_Study-1-11_25_2021-15-01-32/Study 1 11_25_2021 15-01-32.xml', ... '2-Map', ... @@ -38,7 +43,7 @@ function loadCartoStudy1Map2(testCase) 'maptoread', map, ... 'refchannel', refchannel, ... 'ecgchannel', ecgchannel, ... - 'savefilename', fullfile(getenv("OPENEP_TESTING_DATA"), 'tmp') ... + 'verbose', false ... ); end diff --git a/tests/import/loadensitex_dxldata_SmokeTest.m b/tests/import/loadensitex_dxldata_SmokeTest.m new file mode 100644 index 0000000..dfb1054 --- /dev/null +++ b/tests/import/loadensitex_dxldata_SmokeTest.m @@ -0,0 +1,173 @@ +classdef loadensitex_dxldata_SmokeTest < matlab.unittest.TestCase + % loadensitex_dxldata_SmokeTest + % + % Smoke test for loadensitex_dxldata. + % + % This test verifies that all EnsiteX DXL CSV files in the OpenEP test + % dataset can be parsed without throwing errors. The intent is not to + % validate data correctness, but to ensure that the importer is robust + % to a variety of real-world input files. + % + % Key behaviour: + % - Iterates over all subfolders in EnsiteXDXLFiles + % - Attempts to load every non-hidden *.csv file + % - Ignores warnings (errors only cause failures) + % - Treats empty dataset folders as failures + % - Optionally runs in parallel (requires Parallel Computing Toolbox) + % - Disables progress UI for headless/parallel execution + % - Reports a full pass/fail summary with timing information + % + + properties (Constant) + useParallel = true; % Set true to enable parallel execution + end + + methods (Test) + function runsOnAllDXLDatasets(testCase) + + % Start overall timer + tTotal = tic; + + % Locate test data + testDataRoot = getenv("OPENEP_TESTING_DATA"); + testCase.assumeNotEmpty(testDataRoot, ... + "OPENEP_TESTING_DATA environment variable not set."); + + rootDir = fullfile(testDataRoot, 'EnsiteXDXLFiles'); + testCase.assumeTrue(isfolder(rootDir), ... + "EnsiteXDXLFiles directory not found."); + + % Find dataset folders + datasets = dir(rootDir); + datasets = datasets([datasets.isdir]); + datasets = datasets(~ismember({datasets.name}, {'.','..'})); + testCase.assumeNotEmpty(datasets, ... + "No EnsiteX DXL datasets found."); + + % Collect CSV files and empty folders + allCSVFiles = {}; + emptyFolders = {}; + + for i = 1:numel(datasets) + dxlDir = fullfile(rootDir, datasets(i).name); + csvFiles = dir(fullfile(dxlDir, '*.csv')); + + % Ignore hidden / macOS metadata files + csvFiles = csvFiles(~startsWith({csvFiles.name}, '.') & ... + ~startsWith({csvFiles.name}, '._')); + + if isempty(csvFiles) + emptyFolders{end+1} = datasets(i).name; %#ok + else + for j = 1:numel(csvFiles) + allCSVFiles{end+1} = fullfile(datasets(i).name, csvFiles(j).name); %#ok + end + end + end + + numFiles = numel(allCSVFiles); + passedFiles = cell(1, numFiles); + failedFiles = cell(1, numFiles); + durations = zeros(1, numFiles); + + % ----------------------------------------------------------------- + % Load CSV files (parallel or sequential) + % ----------------------------------------------------------------- + if testCase.useParallel + parfor k = 1:numFiles + csvPath = fullfile(rootDir, allCSVFiles{k}); + + tStart_local = tic; + + try + loadensitex_dxldata(csvPath, 'ShowProgress', false); + durations(k) = toc(tStart_local); + passedFiles{k} = struct( ... + 'file', allCSVFiles{k}, ... + 'duration', durations(k)); + catch ME + durations(k) = toc(tStart_local); + failedFiles{k} = struct( ... + 'file', allCSVFiles{k}, ... + 'error', ME.message, ... + 'duration', durations(k)); + end + end + else + for k = 1:numFiles + csvPath = fullfile(rootDir, allCSVFiles{k}); + try + tStart_local = tic; + loadensitex_dxldata(csvPath, 'ShowProgress', false); + durations(k) = toc(tStart_local); + passedFiles{k} = struct( ... + 'file', allCSVFiles{k}, ... + 'duration', durations(k)); + catch ME + durations(k) = toc(tStart_local); + failedFiles{k} = struct( ... + 'file', allCSVFiles{k}, ... + 'error', ME.message, ... + 'duration', durations(k)); + end + end + end + + % Clean up results + passedFiles = passedFiles(~cellfun('isempty', passedFiles)); + failedFiles = failedFiles(~cellfun('isempty', failedFiles)); + + % Add empty-folder failures + for k = 1:numel(emptyFolders) + failedFiles{end+1} = struct( ... + 'file', emptyFolders{k}, ... + 'error', 'No CSV files found in folder (after filtering hidden files)', ... + 'duration', NaN); %#ok + end + + % ----------------------------------------------------------------- + % Summary output + % ----------------------------------------------------------------- + fprintf('\n================================================================================\n') + fprintf('\n=== loadensitex_dxldata Smoke Test Summary ===\n'); + + if ~isempty(passedFiles) + fprintf(' Passed files (%d):\n', numel(passedFiles)); + for k = 1:numel(passedFiles) + fprintf(' %s | Time: %.2f s\n', ... + passedFiles{k}.file, passedFiles{k}.duration); + end + else + fprintf(' No CSV files loaded successfully.\n'); + end + + if ~isempty(failedFiles) + fprintf(' Failed files (%d):\n', numel(failedFiles)); + for k = 1:numel(failedFiles) + if isnan(failedFiles{k}.duration) + durStr = 'N/A'; + else + durStr = sprintf('%.2f s', failedFiles{k}.duration); + end + fprintf(' %s | Error: %s | Time: %s\n', ... + failedFiles{k}.file, failedFiles{k}.error, durStr); + end + + totalTime = toc(tTotal); + fprintf('Total test runtime: %.2f seconds\n', totalTime); + fprintf('=== End of Summary ===\n\n'); + fprintf('\n================================================================================\n') + + testCase.assertFail( ... + "One or more CSV files or folders failed. See summary above."); + else + fprintf('=== All CSV files loaded successfully ===\n'); + totalTime = toc(tTotal); + fprintf('Total test runtime: %.2f seconds\n', totalTime); + fprintf('=== End of Summary ===\n\n'); + fprintf('\n================================================================================\n') + end + + end + end +end diff --git a/tests/validation/MappingCaseConversionTest.m b/tests/validation/MappingCaseConversionTest.m new file mode 100644 index 0000000..64fbcd1 --- /dev/null +++ b/tests/validation/MappingCaseConversionTest.m @@ -0,0 +1,79 @@ +classdef MappingCaseConversionTest < matlab.unittest.TestCase + % Tests for the headless integration-facing conversion contract. + + properties + TempRoot + end + + methods (TestClassSetup) + function addProjectPaths(testCase) + repoRoot = fileparts(fileparts(fileparts(mfilename('fullpath')))); + addpath(repoRoot); + addpath(fullfile(repoRoot, 'validation')); + testCase.TempRoot = tempname; + mkdir(testCase.TempRoot); + end + end + + methods (TestClassTeardown) + function removeTemporaryFolder(testCase) + if isfolder(testCase.TempRoot) + rmdir(testCase.TempRoot, 's'); + end + end + end + + methods (Test) + function failureWritesMachineReadableArtifacts(testCase) + outputFile = fullfile(testCase.TempRoot, 'failed.mat'); + + result = convert_mapping_case( ... + fullfile(testCase.TempRoot, 'missing.zip'), outputFile, ... + 'system', 'carto', ... + 'maptoread', '2-LA', ... + 'refchannel', 'CS1-CS2', ... + 'ecgchannel', 'V1'); + + testCase.verifyFalse(result.success); + testCase.verifyFalse(result.outputPublished); + testCase.verifyEqual(result.status, 'failure'); + testCase.verifyFalse(isfile(outputFile)); + testCase.verifyTrue(isfile(result.statusFile)); + testCase.verifyTrue(isfile(result.logFile)); + testCase.verifyFalse(isfile(result.progressFile)); + + savedStatus = jsondecode(fileread(result.statusFile)); + testCase.verifyFalse(savedStatus.success); + testCase.verifyFalse(savedStatus.outputPublished); + testCase.verifyEqual(savedStatus.status, 'failure'); + testCase.verifyEqual(savedStatus.error.identifier, ... + 'prepare_carto_case:MissingPath'); + testCase.verifySubstring(fileread(result.logFile), ... + 'OpenEP conversion status: FAILURE'); + end + + function throwOnFailureWritesArtifactsBeforeError(testCase) + outputFile = fullfile(testCase.TempRoot, 'thrown.mat'); + statusFile = fullfile(testCase.TempRoot, 'thrown.json'); + logFile = fullfile(testCase.TempRoot, 'thrown.log'); + progressFile = fullfile(testCase.TempRoot, 'thrown.progress.json'); + + call = @() convert_mapping_case( ... + fullfile(testCase.TempRoot, 'missing.zip'), outputFile, ... + 'system', 'carto', ... + 'maptoread', '2-LA', ... + 'refchannel', 'CS1-CS2', ... + 'ecgchannel', 'V1', ... + 'statusfilename', statusFile, ... + 'logfilename', logFile, ... + 'progressfilename', progressFile, ... + 'throwonfailure', true); + + testCase.verifyError(call, 'prepare_carto_case:MissingPath'); + testCase.verifyTrue(isfile(statusFile)); + testCase.verifyTrue(isfile(logFile)); + testCase.verifyFalse(isfile(progressFile)); + testCase.verifyFalse(isfile(outputFile)); + end + end +end diff --git a/tests/validation/MappingInputValidationTest.m b/tests/validation/MappingInputValidationTest.m new file mode 100644 index 0000000..80b31cc --- /dev/null +++ b/tests/validation/MappingInputValidationTest.m @@ -0,0 +1,118 @@ +classdef MappingInputValidationTest < matlab.unittest.TestCase + % Unit tests for mapping-input and imported-userdata validation. + + properties + RepoRoot + TempRoot + end + + methods (TestClassSetup) + function setupPathsAndTemporaryFolder(testCase) + testCase.RepoRoot = fileparts(fileparts(fileparts(mfilename('fullpath')))); + addpath(testCase.RepoRoot); + addpath(fullfile(testCase.RepoRoot, 'validation')); + testCase.TempRoot = tempname; + mkdir(testCase.TempRoot); + end + end + + methods (TestClassTeardown) + function removeTemporaryFolder(testCase) + if isfolder(testCase.TempRoot) + rmdir(testCase.TempRoot, 's'); + end + end + end + + methods (Test) + function openepUserdataStructurePasses(testCase) + userdata = createMinimalOpenepUserdata(); + + report = validate_mapping_input(userdata, 'openep_userdata'); + + testCase.verifyEqual(report.numFail, 0, report.summary); + testCase.verifyTrue(hasCheck(report, 'openep.mesh.readable')); + testCase.verifyTrue(hasCheck(report, 'openep.numeric.lat.finite')); + testCase.verifyTrue(hasCheck(report, 'openep.numeric.voltage.nonnegative')); + end + + function missingSurfaceFails(testCase) + userdata = struct('electric', struct()); + + report = validate_mapping_input(userdata, 'openep_userdata'); + + testCase.verifyEqual(report.status, 'fail', report.summary); + testCase.verifyTrue(hasCheck(report, 'openep.userdata.surface.missing')); + end + + function discoversCartoAndEnsiteCases(testCase) + caseRoot = fullfile(testCase.TempRoot, 'full_cases'); + cartoFolder = fullfile(caseRoot, 'Carto', 'Study1'); + ensiteFolder = fullfile(caseRoot, 'EnsiteX', 'Study1', 'Export_bi'); + mkdir(cartoFolder); + mkdir(fullfile(ensiteFolder, 'Contact_Mapping')); + + writeTextFile(fullfile(cartoFolder, 'study.xml'), ''); + writeTextFile(fullfile(cartoFolder, 'map.mesh'), 'mesh'); + writeTextFile(fullfile(ensiteFolder, 'Contact_Mapping_Model.xml'), ''); + writeTextFile(fullfile(ensiteFolder, 'Contact_Mapping', 'Map_LAT_bi.csv'), ... + sprintf('Export File Version: 11\nMap name:,Test Map\n')); + + manifest = discover_full_cases(caseRoot); + + testCase.verifyEqual(numel(manifest), 2); + testCase.verifyTrue(any(strcmp({manifest.caseType}, 'carto'))); + testCase.verifyTrue(any(strcmp({manifest.caseType}, 'ensitex'))); + ensiteCase = manifest(strcmp({manifest.caseType}, 'ensitex')); + testCase.verifyEqual(ensiteCase.candidateMaps, {'Test Map'}); + testCase.verifyEqual(ensiteCase.egmTypes, {'bi'}); + end + + function preparesAndCleansCartoZip(testCase) + sourceFolder = fullfile(testCase.TempRoot, 'carto_zip_source'); + mkdir(sourceFolder); + writeTextFile(fullfile(sourceFolder, 'study.xml'), ''); + writeTextFile(fullfile(sourceFolder, 'map.mesh'), 'mesh'); + zipFile = fullfile(testCase.TempRoot, 'carto_case.zip'); + zip(zipFile, sourceFolder); + + [caseFolder, cleanupObj, info] = prepare_carto_case(zipFile); + + testCase.verifyTrue(isfolder(caseFolder)); + testCase.verifyTrue(info.wasArchive); + testCase.verifyTrue(isfile(fullfile(caseFolder, 'map.mesh'))); + testCase.verifyGreaterThan(info.archiveCompressedBytes, 0); + testCase.verifyGreaterThan(info.archiveUncompressedBytes, 0); + testCase.verifyEqual(info.archiveFileCount, 2); + testCase.verifyGreaterThan(info.requiredBytes, ... + info.archiveUncompressedBytes); + extractionRoot = info.extractionRoot; + delete(cleanupObj); + testCase.verifyFalse(isfolder(extractionRoot)); + end + end +end + +function userdata = createMinimalOpenepUserdata() +userdata = struct(); +userdata.surface = struct(); +userdata.surface.triRep = struct(); +userdata.surface.triRep.X = [0 0 0; 1 0 0; 0 1 0; 0 0 1]; +userdata.surface.triRep.Triangulation = [1 2 3; 1 3 4]; +userdata.surface.act_bip = [0 1.1; 5 0.9; 10 1.4; 15 0.8]; +userdata.electric = struct(); +userdata.electric.egmX = [0.1 0.1 0; 0.8 0.1 0; 0.2 0.7 0]; +userdata.electric.egmSurfX = userdata.electric.egmX; +userdata.electric.voltages = struct('bipolar', [1.1; 0.9; 1.4]); +end + +function writeTextFile(filePath, text) +fid = fopen(filePath, 'w'); +assert(fid ~= -1, 'Could not create test file: %s', filePath); +cleanupObj = onCleanup(@() fclose(fid)); +fprintf(fid, '%s', text); +end + +function tf = hasCheck(report, id) +tf = any(strcmp({report.checks.id}, id)); +end diff --git a/tests/validation/OpenepCaseContainerTest.m b/tests/validation/OpenepCaseContainerTest.m new file mode 100644 index 0000000..fab5949 --- /dev/null +++ b/tests/validation/OpenepCaseContainerTest.m @@ -0,0 +1,124 @@ +classdef OpenepCaseContainerTest < matlab.unittest.TestCase + methods (TestClassSetup) + function addProjectPaths(~) + repoRoot = fileparts(fileparts(fileparts(mfilename('fullpath')))); + addpath(repoRoot); + addpath(fullfile(repoRoot, 'validation')); + end + end + + methods (Test) + function validatesAndSelectsDatasets(testCase) + openepCase = createCase({'bi', 'omni'}); + + report = validate_mapping_input(openepCase, 'openep_case'); + [userdata, dataset] = select_openep_dataset(openepCase, 'omni'); + + testCase.verifyEqual(report.numFail, 0, report.summary); + testCase.verifyEqual(dataset.recordingMode, 'omni'); + testCase.verifyEqual(userdata.electric.egmX, ... + openepCase.datasets(2).userdata.electric.egmX); + end + + function duplicateModesFailValidation(testCase) + openepCase = createCase({'bi', 'bi'}); + + report = validate_mapping_input(openepCase, 'openep_case'); + + testCase.verifyGreaterThan(report.numFail, 0); + testCase.verifyTrue(any(strcmp({report.checks.id}, ... + 'openep.case.modes.invalid'))); + end + + function validatesSavedCaseMatFile(testCase) + openepCase = createCase({'uni'}); + matFile = [tempname, '.mat']; + cleanupObj = onCleanup(@() deleteIfPresent(matFile)); + save(matFile, 'openepCase', '-v7.3'); + + report = validate_mapping_input(matFile, 'openep_mat'); + + testCase.verifyEqual(report.numFail, 0, report.summary); + testCase.verifyTrue(any(strcmp({report.checks.id}, ... + 'openep_mat.case'))); + delete(cleanupObj); + end + + function invalidBipolarEgmLayoutFailsValidation(testCase) + openepCase = createCase({'bi'}); + openepCase.datasets.userdata.electric.egmUni = zeros(2, 5, 3); + + report = validate_mapping_input(openepCase, 'openep_case'); + + testCase.verifyTrue(any(strcmp({report.checks.id}, ... + 'openep.case.bi.egm_layout') & ... + strcmp({report.checks.level}, 'fail'))); + end + + function invalidOmnipolarEgmLayoutFailsValidation(testCase) + openepCase = createCase({'omni'}); + openepCase.datasets.userdata.electric.egmUniX = zeros(2, 3, 2); + + report = validate_mapping_input(openepCase, 'openep_case'); + + testCase.verifyTrue(any(strcmp({report.checks.id}, ... + 'openep.case.omni.egm_layout') & ... + strcmp({report.checks.level}, 'fail'))); + end + end +end + +function openepCase = createCase(modes) +datasets = repmat(struct( ... + 'id', '', ... + 'recordingMode', '', ... + 'mapName', 'Test Map', ... + 'sourceFolder', '/test', ... + 'detection', struct(), ... + 'userdata', struct()), numel(modes), 1); +for i = 1:numel(modes) + datasets(i).id = sprintf('%s_%d', modes{i}, i); + datasets(i).recordingMode = modes{i}; + datasets(i).userdata = createMinimalUserdata(modes{i}); +end + +openepCase = struct(); +openepCase.schemaName = 'OpenEP multi-dataset case'; +openepCase.schemaVersion = '1.0'; +openepCase.source = struct('system', 'ensitex', 'rootFolder', '/test'); +openepCase.mapName = 'Test Map'; +openepCase.datasets = datasets; +end + +function userdata = createMinimalUserdata(mode) +userdata = struct(); +userdata.surface = struct(); +userdata.surface.triRep = struct(); +userdata.surface.triRep.X = [0 0 0; 1 0 0; 0 1 0; 0 0 1]; +userdata.surface.triRep.Triangulation = [1 2 3; 1 3 4]; +userdata.surface.act_bip = [0 1; 1 1; 2 1; 3 1]; +userdata.electric = struct(); +userdata.electric.egmX = [0.1 0.1 0; 0.8 0.1 0]; +userdata.electric.egmSurfX = userdata.electric.egmX; +userdata.electric.egm = zeros(2, 5); +if strcmp(mode, 'bi') + userdata.electric.egmUni = zeros(2, 5, 2); + userdata.electric.egmUniX = zeros(2, 3, 2); + userdata.electric.electrodeNames_uni = cell(2, 2); +elseif strcmp(mode, 'omni') + userdata.electric.egmUni = zeros(2, 5, 3); + userdata.electric.egmUniX = zeros(2, 3, 3); + userdata.electric.electrodeNames_uni = cell(2, 3); +else + userdata.electric.egmUni = userdata.electric.egm; + userdata.electric.egmUniX = userdata.electric.egmX; + userdata.electric.electrodeNames_uni = cell(2, 1); +end +userdata.electric.voltages = struct('bipolar', [1; 1]); +end + +function deleteIfPresent(filePath) +if isfile(filePath) + delete(filePath); +end +end diff --git a/translateDataExportElement.m b/translateDataExportElement.m new file mode 100644 index 0000000..08280c3 --- /dev/null +++ b/translateDataExportElement.m @@ -0,0 +1,48 @@ +function newText = translateDataExportElement(oldText) +% TRANSLATEDATAEXPORTELEMENT standardises the description of Ensite files. +% The naming of files has changed, this causes problems. All filenames are +% mapped where possible. +% Usage: +% newText = translateDataExportElement(oldText) +% allDataExportTypes = translateDataExportElement() +% +% translator = { +% {'standardNameA', 'precisionName1A' , 'precisionName2A' , etc} ; +% {'standardNameB', 'precisionName1B' , 'precisionName2B' , etc} ; +% etc}; +% allTranslations is a list of all {'standardNameA', 'standardNameB' , etc} + + newText = ''; + + translator = { ... + {'epcath_bip_raw' , 'EP_Catheter_Bipolar_Raw' , 'EPcathBIObipol_RAW' , 'EP_Catheter_Bipolar_Waveforms_Raw'};... + {'epcath_bip_filt' , 'EP_Catheter_Bipolar_Filtered' , 'EPcathBIObipol_FILTERED' , 'EP_Catheter_Bipolar_Waveforms_Filtered'};... + {'epcath_uni_raw' , 'EP_Catheter_Unipolar_Raw' , 'EPcathBIO_RAW' , 'EP_Catheter_Unipolar_Waveforms_Raw'};... + {'epcath_uni_filt' , 'EP_Catheter_Unipolar_Filtered' , 'EPcathBIO_FILTERED' , 'EP_Catheter_Unipolar_Waveforms_Filtered'};... + {'epcath_uni_comp' , 'EPcathBIO_COMPUTED'};... + {'ecg_raw' , 'ECG_RAW' , 'ECG_Waveforms_Raw'}; ... + {'ecg_filt' , 'ECG_FILTERED' , 'ECG_Waveforms_Filtered'}; ... + % {'displayedwaveforms', 'DisplayedWaveforms'}; ... code is not yet written to parse the DisplayedWaveforms files + {'respiration' , 'Respiration'}; ... + {'locations' , 'Electrode_Locations' , 'Locations'}; ... + {'channels' , 'Channels'}; ... + {'dxldata' , '?????'}; ... + {'modelgroups' , 'modelgroups' , 'Model_Groups'}; ... + }; + + if nargin == 0 + newText = cell(numel(translator),1); + for i = 1:numel(translator) + newText{i} = translator{i}{1}; + end + return + end + + for iTr=1:numel(translator) + if any(contains(oldText,translator{iTr},'IgnoreCase',true)) + newText = translator{iTr}{1}; + break + end + end + +end \ No newline at end of file diff --git a/validation/discover_full_cases.m b/validation/discover_full_cases.m new file mode 100644 index 0000000..f9a0665 --- /dev/null +++ b/validation/discover_full_cases.m @@ -0,0 +1,171 @@ +function manifest = discover_full_cases(caseRoot) +%DISCOVER_FULL_CASES Find local CARTO and EnSite full-case exports. +% +% manifest = discover_full_cases('./full_cases') +% +% The returned struct array is intentionally compact so tests and notebooks +% can choose representative cases without repeatedly scanning large folders. + +if nargin < 1 || isempty(caseRoot) + caseRoot = fullfile(pwd, 'full_cases'); +end +caseRoot = char(caseRoot); + +manifest = emptyManifest(); +if ~isfolder(caseRoot) + return +end + +manifest = [manifest discoverCartoCases(caseRoot)]; +manifest = [manifest discoverEnsiteCases(caseRoot)]; +end + +function cases = discoverCartoCases(caseRoot) +cases = emptyManifest(); +cartoRoot = fullfile(caseRoot, 'Carto'); +if ~isfolder(cartoRoot) + return +end + +zipFiles = visibleFiles(dir(fullfile(cartoRoot, '**', '*.zip'))); +for i = 1:numel(zipFiles) + zipPath = fullfile(zipFiles(i).folder, zipFiles(i).name); + archiveInfo = inspect_carto_zip(zipPath); + cases(end+1) = makeCase('carto', zipPath, true, zipPath, zipFiles(i).bytes, ... + {}, {}, {}, 'CARTO ZIP export', archiveInfo); %#ok +end + +meshFiles = visibleFiles(dir(fullfile(cartoRoot, '**', '*.mesh'))); +folders = unique({meshFiles.folder}, 'stable'); +for i = 1:numel(folders) + folderPath = folders{i}; + xmlFiles = visibleFiles(dir(fullfile(folderPath, '*.xml'))); + names = {xmlFiles.name}; + hasStudyXml = any(~contains(names, 'Point_Export') & ~contains(names, 'Points_Export')); + if hasStudyXml + cases(end+1) = makeCase('carto', folderPath, false, '', folderSizeBytes(folderPath), ... + inferCartoMapNames(folderPath), {}, {}, 'Extracted CARTO export', struct()); %#ok + end +end +end + +function cases = discoverEnsiteCases(caseRoot) +cases = emptyManifest(); +ensiteRoot = fullfile(caseRoot, 'EnsiteX'); +if ~isfolder(ensiteRoot) + return +end + +modelFiles = visibleFiles(dir(fullfile(ensiteRoot, '**', 'Contact_Mapping_Model.xml'))); +for i = 1:numel(modelFiles) + exportFolder = modelFiles(i).folder; + [candidateMaps, candidateMapFiles, egmTypes] = inferEnsiteMaps(exportFolder); + cases(end+1) = makeCase('ensitex', exportFolder, false, '', ... + folderSizeBytes(exportFolder), candidateMaps, candidateMapFiles, egmTypes, ... + 'Extracted EnSiteX export', struct()); %#ok +end +end + +function names = inferCartoMapNames(folderPath) +meshFiles = visibleFiles(dir(fullfile(folderPath, '*.mesh'))); +names = cell(1, numel(meshFiles)); +for i = 1:numel(meshFiles) + [~, names{i}] = fileparts(meshFiles(i).name); +end +names = unique(names, 'stable'); +end + +function [mapNames, mapFiles, egmTypes] = inferEnsiteMaps(exportFolder) +mapFilesInfo = visibleFiles(dir(fullfile(exportFolder, '**', 'Map_LAT_*.csv'))); +mapNames = {}; +mapFiles = {}; +egmTypes = {}; +for i = 1:numel(mapFilesInfo) + filePath = fullfile(mapFilesInfo(i).folder, mapFilesInfo(i).name); + mapName = readEnsiteHeaderToken(filePath, 'Map name\s*:\s*,\s*([^,\r\n]+)'); + if isempty(mapName) + [~, mapName] = fileparts(filePath); + end + mapNames{end+1} = strrep(strtrim(mapName), sprintf('\t'), ' '); %#ok + mapFiles{end+1} = filePath; %#ok + + [~, baseName] = fileparts(filePath); + tokens = regexp(baseName, 'Map_LAT_(.+)$', 'tokens', 'once'); + if ~isempty(tokens) + egmTypes{end+1} = tokens{1}; %#ok + end +end +mapNames = unique(mapNames, 'stable'); +egmTypes = unique(egmTypes, 'stable'); +end + +function value = readEnsiteHeaderToken(filePath, pattern) +value = ''; +fid = fopen(filePath, 'r'); +if fid == -1 + return +end +cleanupObj = onCleanup(@() fclose(fid)); + +for i = 1:120 + line = fgetl(fid); + if ~ischar(line) + break + end + tokens = regexp(line, pattern, 'tokens', 'once'); + if ~isempty(tokens) + value = tokens{1}; + return + end +end +end + +function bytes = folderSizeBytes(folderPath) +files = dir(fullfile(folderPath, '**', '*')); +files = files(~[files.isdir]); +files = visibleFiles(files); +if isempty(files) + bytes = 0; +else + bytes = sum([files.bytes]); +end +end + +function files = visibleFiles(files) +if isempty(files) + return +end +names = {files.name}; +files = files(~startsWith(names, '.') & ~startsWith(names, '._')); +end + +function s = makeCase(caseType, path, isArchive, archivePath, sizeBytes, candidateMaps, candidateMapFiles, egmTypes, notes, archiveInfo) +[~, name, ext] = fileparts(path); +s = struct(); +s.caseType = caseType; +s.name = [name, ext]; +s.path = path; +s.isArchive = isArchive; +s.archivePath = archivePath; +s.sizeBytes = sizeBytes; +if isempty(fieldnames(archiveInfo)) + s.archiveCompressedBytes = 0; + s.archiveUncompressedBytes = 0; + s.archiveFileCount = 0; +else + s.archiveCompressedBytes = archiveInfo.compressedBytes; + s.archiveUncompressedBytes = archiveInfo.uncompressedBytes; + s.archiveFileCount = archiveInfo.fileCount; +end +s.candidateMaps = candidateMaps; +s.candidateMapFiles = candidateMapFiles; +s.egmTypes = egmTypes; +s.notes = notes; +end + +function s = emptyManifest() +s = struct('caseType', {}, 'name', {}, 'path', {}, 'isArchive', {}, ... + 'archivePath', {}, 'sizeBytes', {}, 'archiveCompressedBytes', {}, ... + 'archiveUncompressedBytes', {}, 'archiveFileCount', {}, 'candidateMaps', {}, ... + 'candidateMapFiles', {}, 'egmTypes', {}, 'notes', {}); +end diff --git a/validation/prepare_carto_case_for_test.m b/validation/prepare_carto_case_for_test.m new file mode 100644 index 0000000..1d48674 --- /dev/null +++ b/validation/prepare_carto_case_for_test.m @@ -0,0 +1,5 @@ +function [caseFolder, cleanupObj, info] = prepare_carto_case_for_test(cartoPath) +%PREPARE_CARTO_CASE_FOR_TEST Compatibility wrapper for validation tests. + +[caseFolder, cleanupObj, info] = prepare_carto_case(cartoPath); +end diff --git a/validation/validate_mapping_input.m b/validation/validate_mapping_input.m new file mode 100644 index 0000000..01c8ed6 --- /dev/null +++ b/validation/validate_mapping_input.m @@ -0,0 +1,1251 @@ +function report = validate_mapping_input(inputPath, workflowMode, varargin) +%VALIDATE_MAPPING_INPUT Lightweight validation for CARTO and EnSite inputs. +% +% report = validate_mapping_input(inputPath, workflowMode) +% +% V1 scope: +% - Stage 1: file presence and workflow compatibility. +% - Stage 2: EnSite DXL/CARTO header and required-column checks. +% - Stage 3: simple cross-file consistency checks. +% - Stage 4: numeric sanity checks for coordinates and scalar fields. +% - Stage 5: OpenEP userdata structure checks after import. + +p = inputParser; +addRequired(p, 'inputPath', @(x) ischar(x) || isstring(x) || isstruct(x)); +addRequired(p, 'workflowMode', @(x) ischar(x) || isstring(x)); +addParameter(p, 'mapToRead', '', @(x) ischar(x) || isstring(x)); +addParameter(p, 'refChannel', '', @(x) ischar(x) || isstring(x)); +addParameter(p, 'egmType', 'bi', @(x) ischar(x) || isstring(x)); +addParameter(p, 'validationLevel', 'standard', ... + @(x) ischar(x) || isstring(x)); +addParameter(p, 'maxWaveFiles', 6, ... + @(x) isnumeric(x) && isscalar(x) && isfinite(x) && x >= 1 && x == floor(x)); +parse(p, inputPath, workflowMode, varargin{:}); + +workflowMode = lower(char(workflowMode)); +opts = p.Results; +opts.validationLevel = lower(char(opts.validationLevel)); +validLevels = {'quick', 'standard', 'full'}; + +checks = emptyCheck(); +report = struct(); +if isstruct(inputPath) + if strcmp(workflowMode, 'openep_case') + report.inputPath = ''; + else + report.inputPath = ''; + end +else + inputPath = char(inputPath); + report.inputPath = inputPath; +end +report.workflowMode = workflowMode; +report.validationLevel = opts.validationLevel; +report.maxWaveFiles = opts.maxWaveFiles; + +if ~any(strcmp(opts.validationLevel, validLevels)) + checks = addCheck(checks, 'fail', 1, 'validation.level.invalid', ... + sprintf('Unknown validationLevel: %s. Use quick, standard, or full.', opts.validationLevel), report.inputPath); + report.checks = checks; + report = finalizeReport(report); + return +elseif isstruct(inputPath) && ~any(strcmp(workflowMode, ... + {'openep_userdata', 'openep_case'})) + checks = addCheck(checks, 'fail', 1, 'workflow.input_type_invalid', ... + 'Struct input requires workflowMode openep_userdata or openep_case.', report.inputPath); + report.checks = checks; + report = finalizeReport(report); + return +elseif ~isstruct(inputPath) && ~(isfolder(inputPath) || isfile(inputPath)) + checks = addCheck(checks, 'fail', 1, 'path.missing', ... + sprintf('Input path does not exist: %s', inputPath), inputPath); + report.checks = checks; + report = finalizeReport(report); + return +end + +switch workflowMode + case 'openep_userdata' + checks = validateOpenepUserdata(inputPath, checks, report.inputPath); + case 'openep_case' + checks = validateOpenepCase(inputPath, checks, report.inputPath); + case 'openep_mat' + checks = validateOpenepMat(inputPath, checks); + case 'carto_openep' + checks = validateCartoFolder(inputPath, checks, opts); + case 'ensitex_openep' + checks = validateEnsiteFolder(inputPath, checks, opts, true); + case 'ensitex_dxl' + checks = validateEnsiteFolder(inputPath, checks, opts, false); + otherwise + checks = addCheck(checks, 'fail', 1, 'workflow.unknown', ... + sprintf('Unknown workflow mode: %s', workflowMode), inputPath); +end + +report.checks = checks; +report = finalizeReport(report); +end + +function checks = validateOpenepMat(inputPath, checks) +if ~isfile(inputPath) + checks = addCheck(checks, 'fail', 1, 'openep_mat.not_file', ... + 'OpenEP MAT workflow expects a .mat file.', inputPath); + return +end + +[~, ~, ext] = fileparts(inputPath); +if ~strcmpi(ext, '.mat') + checks = addCheck(checks, 'fail', 1, 'openep_mat.extension', ... + 'OpenEP MAT workflow expects a .mat file.', inputPath); + return +end + +try + contents = whos('-file', inputPath); + names = {contents.name}; + hasUserdata = any(strcmp(names, 'userdata')); + hasOpenepCase = any(strcmp(names, 'openepCase')); + if hasUserdata + checks = addCheck(checks, 'pass', 1, 'openep_mat.userdata', ... + 'MAT file contains userdata.', inputPath); + loaded = load(inputPath, 'userdata'); + checks = validateOpenepUserdata(loaded.userdata, checks, inputPath); + elseif hasOpenepCase + checks = addCheck(checks, 'pass', 1, 'openep_mat.case', ... + 'MAT file contains openepCase.', inputPath); + loaded = load(inputPath, 'openepCase'); + checks = validateOpenepCase(loaded.openepCase, checks, inputPath); + else + checks = addCheck(checks, 'fail', 1, 'openep_mat.no_userdata', ... + 'MAT file does not contain userdata or openepCase.', inputPath); + end +catch ME + checks = addCheck(checks, 'fail', 1, 'openep_mat.unreadable', ... + ['Could not inspect MAT file: ', ME.message], inputPath); +end +end + +function checks = validateOpenepCase(openepCase, checks, sourceLabel) +if ~isstruct(openepCase) || ~isscalar(openepCase) + checks = addCheck(checks, 'fail', 5, 'openep.case.not_struct', ... + 'openepCase must be a scalar struct.', sourceLabel); + return +end + +requiredFields = {'schemaName', 'schemaVersion', 'source', 'mapName', 'datasets'}; +missing = requiredFields(~isfield(openepCase, requiredFields)); +if ~isempty(missing) + checks = addCheck(checks, 'fail', 5, 'openep.case.fields.missing', ... + ['Missing openepCase fields: ', strjoin(missing, ', ')], sourceLabel); + return +end + +if ~isstruct(openepCase.datasets) || isempty(openepCase.datasets) + checks = addCheck(checks, 'fail', 5, 'openep.case.datasets.invalid', ... + 'openepCase.datasets must be a non-empty struct array.', sourceLabel); + return +end + +datasets = openepCase.datasets; +datasetFields = {'id', 'recordingMode', 'mapName', 'sourceFolder', ... + 'detection', 'userdata'}; +missing = datasetFields(~isfield(datasets, datasetFields)); +if ~isempty(missing) + checks = addCheck(checks, 'fail', 5, ... + 'openep.case.dataset_fields.missing', ... + ['Missing dataset fields: ', strjoin(missing, ', ')], sourceLabel); + return +end + +ids = {datasets.id}; +modes = {datasets.recordingMode}; +if any(cellfun('isempty', ids)) || numel(unique(ids)) ~= numel(ids) + checks = addCheck(checks, 'fail', 5, 'openep.case.ids.invalid', ... + 'Dataset IDs must be non-empty and unique.', sourceLabel); +else + checks = addCheck(checks, 'pass', 5, 'openep.case.ids.valid', ... + sprintf('Found %d unique dataset ID(s).', numel(ids)), sourceLabel); +end + +validModes = {'bi', 'uni', 'omni'}; +if any(~ismember(modes, validModes)) || numel(unique(modes)) ~= numel(modes) + checks = addCheck(checks, 'fail', 5, 'openep.case.modes.invalid', ... + 'Recording modes must be unique bi, uni or omni values.', sourceLabel); +else + checks = addCheck(checks, 'pass', 5, 'openep.case.modes.valid', ... + ['Recording modes: ', strjoin(modes, ', ')], sourceLabel); +end + +for i = 1:numel(datasets) + datasetLabel = sprintf('%s [%s]', sourceLabel, datasets(i).id); + checks = validateOpenepUserdata(datasets(i).userdata, checks, datasetLabel); + checks = validateRecordingModeEgmLayout(datasets(i).userdata, ... + datasets(i).recordingMode, checks, datasetLabel); +end +end + +function checks = validateRecordingModeEgmLayout(userdata, mode, checks, sourceLabel) +if ~ismember(mode, {'bi', 'omni'}) + return +end + +nComponents = 2; +if strcmp(mode, 'omni') + nComponents = 3; +end + +if ~isstruct(userdata) || ~isfield(userdata, 'electric') || ... + ~isstruct(userdata.electric) + return +end + +electric = userdata.electric; +requiredFields = {'egmX', 'egm', 'egmUni', 'egmUniX', 'electrodeNames_uni'}; +missing = requiredFields(~isfield(electric, requiredFields)); +checkId = ['openep.case.', mode, '.egm_layout']; +if ~isempty(missing) + checks = addCheck(checks, 'fail', 5, checkId, ... + ['Missing electric fields: ', strjoin(missing, ', ')], sourceLabel); + return +end + +if ~isnumeric(electric.egm) || ~ismatrix(electric.egm) + checks = addCheck(checks, 'fail', 5, checkId, ... + 'userdata.electric.egm must be a numeric numPoints-by-numSamples matrix.', ... + sourceLabel); + return +end + +nPoints = size(electric.egmX, 1); +nSamples = size(electric.egm, 2); +if size(electric.egm, 1) ~= nPoints + checks = addCheck(checks, 'fail', 5, checkId, ... + sprintf(['userdata.electric.egm has %d row(s), but egmX has ', ... + '%d mapping point(s).'], size(electric.egm, 1), nPoints), sourceLabel); + return +end + +expectedEgmUniSize = [nPoints, nSamples, nComponents]; +expectedCoordinateSize = [nPoints, 3, nComponents]; +expectedNameSize = [nPoints, nComponents]; + +if ~isequal(size(electric.egmUni), expectedEgmUniSize) || ... + ~isequal(size(electric.egmUniX), expectedCoordinateSize) || ... + ~isequal(size(electric.electrodeNames_uni), expectedNameSize) + message = sprintf(['Expected egmUni %s, egmUniX %s and ', ... + 'electrodeNames_uni %s; found %s, %s and %s.'], ... + mat2str(expectedEgmUniSize), mat2str(expectedCoordinateSize), ... + mat2str(expectedNameSize), mat2str(size(electric.egmUni)), ... + mat2str(size(electric.egmUniX)), ... + mat2str(size(electric.electrodeNames_uni))); + checks = addCheck(checks, 'fail', 5, checkId, message, sourceLabel); +else + checks = addCheck(checks, 'pass', 5, checkId, ... + sprintf('%s EGM layout is numPoints-by-numSamples-by-%d.', ... + upper(mode), nComponents), sourceLabel); +end +end + +function checks = validateOpenepUserdata(userdata, checks, sourceLabel) +if ~isstruct(userdata) + checks = addCheck(checks, 'fail', 5, 'openep.userdata.not_struct', ... + 'OpenEP userdata is expected to be a struct.', sourceLabel); + return +end + +checks = addCheck(checks, 'pass', 5, 'openep.userdata.struct', ... + 'OpenEP userdata is a struct.', sourceLabel); + +if ~isfield(userdata, 'surface') || ~isstruct(userdata.surface) + checks = addCheck(checks, 'fail', 5, 'openep.userdata.surface.missing', ... + 'userdata.surface is missing or is not a struct.', sourceLabel); + return +else + checks = addCheck(checks, 'pass', 5, 'openep.userdata.surface.present', ... + 'userdata.surface is present.', sourceLabel); +end + +if ~isfield(userdata, 'electric') || ~isstruct(userdata.electric) + checks = addCheck(checks, 'warning', 5, 'openep.userdata.electric.missing', ... + 'userdata.electric is missing or is not a struct; point-level checks will be limited.', sourceLabel); +else + checks = addCheck(checks, 'pass', 5, 'openep.userdata.electric.present', ... + 'userdata.electric is present.', sourceLabel); +end + +[vertices, faces, meshError] = extractOpenepMesh(userdata); +if isempty(meshError) + checks = addCheck(checks, 'pass', 5, 'openep.mesh.readable', ... + sprintf('Mesh was read: %d vertices, %d faces.', size(vertices, 1), size(faces, 1)), sourceLabel); + checks = validateCoordinateMatrix(checks, vertices, 'openep.numeric.mesh_coordinates', ... + 'Mesh vertex coordinates', sourceLabel, true); + checks = validateMeshFaces(checks, faces, size(vertices, 1), sourceLabel); +else + checks = addCheck(checks, 'fail', 5, 'openep.mesh.unreadable', meshError, sourceLabel); +end + +nPoints = NaN; +if exist('getNumPts', 'file') == 2 + try + nPoints = getNumPts(userdata); + checks = addCheck(checks, 'pass', 5, 'openep.userdata.num_points', ... + sprintf('OpenEP reports %d mapping point(s).', nPoints), sourceLabel); + catch ME + checks = addCheck(checks, 'warning', 5, 'openep.userdata.num_points_unavailable', ... + ['Could not call getNumPts: ', ME.message], sourceLabel); + end +elseif isfield(userdata, 'electric') && isstruct(userdata.electric) && isfield(userdata.electric, 'egmX') + nPoints = size(userdata.electric.egmX, 1); + checks = addCheck(checks, 'info', 5, 'openep.userdata.num_points_fallback', ... + sprintf('Estimated %d mapping point(s) from userdata.electric.egmX.', nPoints), sourceLabel); +else + checks = addCheck(checks, 'info', 5, 'openep.userdata.num_points_unavailable', ... + 'Mapping point count could not be estimated.', sourceLabel); +end + +if isfield(userdata, 'electric') && isstruct(userdata.electric) + if isfield(userdata.electric, 'egmX') + checks = validateCoordinateMatrix(checks, userdata.electric.egmX, ... + 'openep.numeric.egm_coordinates', 'Mapping point coordinates', sourceLabel, false); + checks = validatePointCount(checks, userdata.electric.egmX, nPoints, ... + 'openep.userdata.egm_coordinates.rows', 'userdata.electric.egmX', sourceLabel); + else + checks = addCheck(checks, 'warning', 5, 'openep.userdata.egm_coordinates.missing', ... + 'userdata.electric.egmX is missing.', sourceLabel); + end + + if isfield(userdata.electric, 'egmSurfX') + checks = validateCoordinateMatrix(checks, userdata.electric.egmSurfX, ... + 'openep.numeric.surface_mapping_coordinates', 'Surface-projected mapping point coordinates', sourceLabel, false); + checks = validatePointCount(checks, userdata.electric.egmSurfX, nPoints, ... + 'openep.userdata.egm_surface_coordinates.rows', 'userdata.electric.egmSurfX', sourceLabel); + end + + if isfield(userdata.electric, 'voltages') && isstruct(userdata.electric.voltages) && ... + isfield(userdata.electric.voltages, 'bipolar') + bipolar = userdata.electric.voltages.bipolar; + checks = validateNumericVector(checks, bipolar, 'openep.numeric.voltage.finite', ... + 'Point bipolar voltage', sourceLabel, false); + checks = validateNonnegativeVector(checks, bipolar, 'openep.numeric.voltage.nonnegative', ... + 'Point bipolar voltage', sourceLabel); + checks = validatePointCount(checks, bipolar, nPoints, ... + 'openep.userdata.voltage.rows', 'userdata.electric.voltages.bipolar', sourceLabel); + end +end + +if isfield(userdata.surface, 'act_bip') + actBip = userdata.surface.act_bip; + if ~isnumeric(actBip) || size(actBip, 2) < 2 + checks = addCheck(checks, 'warning', 5, 'openep.userdata.surface_act_bip.invalid', ... + 'userdata.surface.act_bip should be numeric with at least LAT and bipolar-voltage columns.', sourceLabel); + else + checks = validateNumericVector(checks, actBip(:, 1), 'openep.numeric.lat.finite', ... + 'Surface LAT', sourceLabel, false); + checks = validateNumericVector(checks, actBip(:, 2), 'openep.numeric.surface_voltage.finite', ... + 'Surface bipolar voltage', sourceLabel, false); + checks = validateNonnegativeVector(checks, actBip(:, 2), 'openep.numeric.surface_voltage.nonnegative', ... + 'Surface bipolar voltage', sourceLabel); + if ~isempty(vertices) + checks = validatePointCount(checks, actBip, size(vertices, 1), ... + 'openep.userdata.surface_act_bip.rows', 'userdata.surface.act_bip', sourceLabel); + end + end +else + checks = addCheck(checks, 'warning', 5, 'openep.userdata.surface_act_bip.missing', ... + 'userdata.surface.act_bip is missing; LAT/voltage surface plotting may be limited.', sourceLabel); +end +end + +function checks = validateCartoFolder(inputPath, checks, opts) +if isfile(inputPath) + [~, ~, ext] = fileparts(inputPath); + if strcmpi(ext, '.zip') + fileInfo = dir(inputPath); + checks = addCheck(checks, 'pass', 1, 'carto.archive.detected', ... + sprintf('Found CARTO ZIP export (%.2f GB).', fileInfo.bytes / 1e9), inputPath); + checks = addCheck(checks, 'warning', 1, 'carto.archive.requires_extraction', ... + 'CARTO ZIP export must be extracted or handled by batch conversion before content validation.', inputPath); + else + checks = addCheck(checks, 'fail', 1, 'carto.not_folder', ... + 'CARTO workflow expects an extracted folder or ZIP export.', inputPath); + end + return +elseif ~isfolder(inputPath) + checks = addCheck(checks, 'fail', 1, 'carto.not_folder', ... + 'CARTO workflow expects a folder.', inputPath); + return +end + +xmlFiles = visibleFiles(dir(fullfile(inputPath, '*.xml'))); +studyXml = xmlFiles(~contains({xmlFiles.name}, 'Point_Export') & ... + ~contains({xmlFiles.name}, 'Points_Export')); +if isempty(studyXml) + checks = addCheck(checks, 'fail', 1, 'carto.study_xml.missing', ... + 'No CARTO study XML found at the export root.', inputPath); +else + checks = addCheck(checks, 'pass', 1, 'carto.study_xml.present', ... + sprintf('Found CARTO study XML: %s', studyXml(1).name), ... + fullfile(studyXml(1).folder, studyXml(1).name)); +end + +meshFiles = visibleFiles(dir(fullfile(inputPath, '*.mesh'))); +if isempty(meshFiles) + checks = addCheck(checks, 'fail', 1, 'carto.mesh.missing', ... + 'No CARTO mesh file found.', inputPath); +else + checks = addCheck(checks, 'pass', 1, 'carto.mesh.present', ... + sprintf('Found %d mesh file(s).', numel(meshFiles)), inputPath); +end + +pointFiles = visibleFiles(dir(fullfile(inputPath, '*Point_Export.xml'))); +if isempty(pointFiles) + checks = addCheck(checks, 'fail', 1, 'carto.points.missing', ... + 'No CARTO point export XML files found.', inputPath); +else + checks = addCheck(checks, 'pass', 1, 'carto.points.present', ... + sprintf('Found %d point export XML file(s).', numel(pointFiles)), inputPath); +end + +ecgFiles = visibleFiles(dir(fullfile(inputPath, '*ECG_Export*.txt'))); +if isempty(ecgFiles) + checks = addCheck(checks, 'warning', 1, 'carto.ecg.missing', ... + 'No ECG export files found. ECG/reference validation may be limited.', inputPath); +else + checks = addCheck(checks, 'pass', 1, 'carto.ecg.present', ... + sprintf('Found %d ECG export file(s).', numel(ecgFiles)), inputPath); +end + +mapToRead = char(opts.mapToRead); +if ~isempty(mapToRead) + mapFound = any(contains({meshFiles.name}, mapToRead)); + if ~mapFound && ~isempty(studyXml) + studyText = safeFileRead(fullfile(studyXml(1).folder, studyXml(1).name)); + mapFound = contains(studyText, mapToRead); + end + + if mapFound + checks = addCheck(checks, 'pass', 2, 'carto.map.present', ... + sprintf('Selected map was found: %s', mapToRead), inputPath); + else + checks = addCheck(checks, 'warning', 2, 'carto.map.not_found', ... + sprintf('Selected map was not found by name: %s', mapToRead), inputPath); + end +end + +refChannel = char(opts.refChannel); +if ~isempty(refChannel) && ~isempty(ecgFiles) + refFound = anyFileContains(ecgFiles, refChannel, 10); + if refFound + checks = addCheck(checks, 'pass', 2, 'carto.ref_channel.present', ... + sprintf('Reference channel found in ECG exports: %s', refChannel), inputPath); + else + checks = addCheck(checks, 'warning', 2, 'carto.ref_channel.not_found', ... + sprintf('Reference channel was not found in first ECG exports: %s', refChannel), inputPath); + end +end +end + +function checks = validateEnsiteFolder(inputPath, checks, opts, requireModelXml) +validationLevel = lower(char(opts.validationLevel)); +countRows = ~strcmp(validationLevel, 'quick'); +runNumericChecks = ~strcmp(validationLevel, 'quick'); +unreadableLevel = 'fail'; +if strcmp(validationLevel, 'quick') + unreadableLevel = 'warning'; +end + +checks = addCheck(checks, 'info', 1, 'validation.level', ... + sprintf('Validation level is %s.', validationLevel), inputPath); + +if ~isfolder(inputPath) + checks = addCheck(checks, 'fail', 1, 'ensite.not_folder', ... + 'EnSite workflow expects a folder.', inputPath); + return +end + +modelFiles = visibleFiles(dir(fullfile(inputPath, '**', 'Contact_Mapping_Model.xml'))); +if requireModelXml + if isempty(modelFiles) + checks = addCheck(checks, 'fail', 1, 'ensite.model_xml.missing', ... + 'Contact_Mapping_Model.xml was not found. Full EnSite mesh import is not possible.', inputPath); + else + checks = addCheck(checks, 'pass', 1, 'ensite.model_xml.present', ... + 'Contact_Mapping_Model.xml was found.', fullfile(modelFiles(1).folder, modelFiles(1).name)); + end +else + if isempty(modelFiles) + checks = addCheck(checks, 'info', 1, 'ensite.model_xml.not_required', ... + 'DXL-only folder: mesh import is not expected without Contact_Mapping_Model.xml.', inputPath); + else + checks = addCheck(checks, 'info', 1, 'ensite.model_xml.present_in_dxl_mode', ... + 'Contact_Mapping_Model.xml is present; full EnSite workflow may also be possible.', ... + fullfile(modelFiles(1).folder, modelFiles(1).name)); + end +end + +csvFiles = visibleFiles(dir(fullfile(inputPath, '**', '*.csv'))); +if isempty(csvFiles) + checks = addCheck(checks, 'fail', 1, 'ensite.csv.missing', ... + 'No EnSite DXL CSV files found.', inputPath); + return +end + +latMapFiles = csvFiles(startsWith({csvFiles.name}, 'Map_LAT_', 'IgnoreCase', true)); +voltageMapFiles = csvFiles(startsWith({csvFiles.name}, 'Map_PP_', 'IgnoreCase', true)); +mapFiles = uniqueFiles([latMapFiles(:); voltageMapFiles(:)]); +if isempty(latMapFiles) + checks = addCheck(checks, 'fail', 1, 'ensite.map_lat.missing', ... + 'No Map_LAT_*.csv file found.', inputPath); +else + checks = addCheck(checks, 'pass', 1, 'ensite.map_lat.present', ... + sprintf('Found %d LAT map CSV file(s).', numel(latMapFiles)), inputPath); +end +if ~isempty(voltageMapFiles) + checks = addCheck(checks, 'info', 1, 'ensite.map_pp.present', ... + sprintf('Found %d voltage map CSV file(s).', numel(voltageMapFiles)), inputPath); +end + +allWaveFiles = csvFiles(startsWith({csvFiles.name}, 'Wave_', 'IgnoreCase', true)); +waveRov = csvFiles(strcmpi({csvFiles.name}, 'Wave_rov.csv')); +waveRefs = csvFiles(strcmpi({csvFiles.name}, 'Wave_refs.csv')); +if isempty(waveRov) + checks = addCheck(checks, 'warning', 1, 'ensite.wave_rov.missing', ... + 'Wave_rov.csv was not found. Waveform validation/import may be incomplete.', inputPath); +end +if isempty(waveRefs) + checks = addCheck(checks, 'warning', 1, 'ensite.wave_refs.missing', ... + 'Wave_refs.csv was not found. Reference waveform validation/import may be incomplete.', inputPath); +end + +egmType = lower(char(opts.egmType)); +checks = validateEgmTypeFiles(csvFiles, checks, egmType, inputPath); + +mapInfo = []; +if ~isempty(mapFiles) + for i = 1:numel(mapFiles) + filePath = fullfile(mapFiles(i).folder, mapFiles(i).name); + info = inspectDxlCsv(filePath, countRows); + checks = validateDxlHeader(info, checks, unreadableLevel); + checks = validateMapColumns(info, checks, runNumericChecks); + if isempty(mapInfo) && startsWith(info.name, 'Map_LAT_', 'IgnoreCase', true) + mapInfo = info; + end + end +end + +waveInfo = struct([]); +switch validationLevel + case 'quick' + waveFiles = selectCoreFirstWaveFiles(allWaveFiles, opts.maxWaveFiles); + checks = addCheck(checks, 'info', 1, 'ensite.quick.wave_sample', ... + sprintf(['Quick validation selected %d of %d Wave_*.csv file(s) ', ... + 'using core-first deterministic sampling.'], ... + numel(waveFiles), numel(allWaveFiles)), inputPath); + case 'full' + waveFiles = allWaveFiles(:); + checks = addCheck(checks, 'info', 1, 'ensite.full.wave_files', ... + sprintf('Full validation selected all %d Wave_*.csv file(s).', numel(waveFiles)), inputPath); + otherwise + waveFiles = uniqueFiles([waveRefs(:); waveRov(:)]); +end + +for i = 1:numel(waveFiles) + filePath = fullfile(waveFiles(i).folder, waveFiles(i).name); + info = inspectDxlCsv(filePath, countRows); + checks = validateDxlHeader(info, checks, unreadableLevel); + checks = validateWaveColumns(info, checks); + waveInfo = [waveInfo info]; %#ok +end + +if ~isempty(mapInfo) && ~isempty(waveInfo) + checks = validateEnsiteConsistency(mapInfo, waveInfo, checks); +end +end + +function checks = validateEgmTypeFiles(csvFiles, checks, egmType, inputPath) +names = {csvFiles.name}; +switch egmType + case 'bi' + expected = {'wave_rov.csv', 'wave_refs.csv'}; + case 'omni' + expected = {'wave_rov.csv', 'wave_refs.csv'}; + case 'uni' + expected = {'wave_rov.csv', 'wave_refs.csv'}; + otherwise + checks = addCheck(checks, 'warning', 1, 'ensite.egmtype.unknown', ... + sprintf('Unknown egmType: %s', egmType), inputPath); + return +end + +for i = 1:numel(expected) + if any(strcmpi(names, expected{i})) + checks = addCheck(checks, 'pass', 1, ['ensite.egmtype.', expected{i}], ... + sprintf('Required %s file is present for egmType=%s.', expected{i}, egmType), inputPath); + else + checks = addCheck(checks, 'warning', 1, ['ensite.egmtype.', expected{i}, '.missing'], ... + sprintf('Expected %s for egmType=%s was not found.', expected{i}, egmType), inputPath); + end +end +end + +function checks = validateDxlHeader(info, checks, unreadableLevel) +if nargin < 3 + unreadableLevel = 'fail'; +end +if ~isempty(info.error) + checks = addCheck(checks, unreadableLevel, 2, 'ensite.csv.unreadable', info.error, info.file); + return +end + +if isempty(info.exportFileVersion) + checks = addCheck(checks, 'fail', 2, 'ensite.header.version_missing', ... + 'Export File Version was not found.', info.file); +elseif any(strcmp(info.exportFileVersion, {'10.0R', '10', '11'})) || startsWith(info.exportFileVersion, '11') + checks = addCheck(checks, 'pass', 2, 'ensite.header.version_supported', ... + sprintf('Supported export file version: %s', info.exportFileVersion), info.file); +else + checks = addCheck(checks, 'warning', 2, 'ensite.header.version_unknown', ... + sprintf('Unexpected export file version: %s', info.exportFileVersion), info.file); +end + +if isempty(info.dataElement) + checks = addCheck(checks, 'fail', 2, 'ensite.header.data_element_missing', ... + 'Export Data Element was not found.', info.file); +elseif any(strcmpi(info.dataElement, {'DxL', 'DXLData'})) + checks = addCheck(checks, 'pass', 2, 'ensite.header.data_element_valid', ... + sprintf('Export Data Element is valid: %s', info.dataElement), info.file); +else + checks = addCheck(checks, 'fail', 2, 'ensite.header.data_element_invalid', ... + sprintf('Invalid Export Data Element: %s', info.dataElement), info.file); +end + +if isnan(info.dataStartRow) + checks = addCheck(checks, 'fail', 2, 'ensite.header.data_start_missing', ... + 'Data starts in row was not found.', info.file); +else + checks = addCheck(checks, 'pass', 2, 'ensite.header.data_start_present', ... + sprintf('Data starts in row %d.', info.dataStartRow), info.file); +end +end + +function checks = validateMapColumns(info, checks, runNumericChecks) +if nargin < 3 + runNumericChecks = true; +end +if isempty(info.columns) || ~startsWith(info.name, 'Map_', 'IgnoreCase', true) + return +end + +required = {'(Point #)', 'Freeze Grp #', 'surface x', 'surface y', 'surface z', ... + 'roving x', 'roving y', 'roving z'}; +if contains(lower(info.name), 'lat') + required{end+1} = 'LAT'; +end + +checks = requireColumns(checks, info, required, 'ensite.map_columns'); + +if ~isnan(info.numMappingPts) && ~isnan(info.dataRows) + if info.numMappingPts == info.dataRows + checks = addCheck(checks, 'pass', 3, 'ensite.map_rows.match_header', ... + sprintf('Map row count matches header: %d.', info.dataRows), info.file); + else + checks = addCheck(checks, 'warning', 3, 'ensite.map_rows.mismatch_header', ... + sprintf('Map rows (%d) do not match header mapping points (%d).', ... + info.dataRows, info.numMappingPts), info.file); + end +end + +if runNumericChecks + checks = validateEnsiteMapNumeric(info, checks); +else + checks = addCheck(checks, 'info', 4, 'ensite.numeric.skipped_quick', ... + 'Numeric map checks skipped in quick validation level.', info.file); +end +end + +function checks = validateWaveColumns(info, checks) +if isempty(info.columns) || ~startsWith(info.name, 'Wave_', 'IgnoreCase', true) + return +end + +required = {'Trace', 'Freeze Grp #', '(Point #)', 'startTime (abs)', 'rovTime (wave samples)'}; +checks = requireColumns(checks, info, required, 'ensite.wave_columns'); + +hasNumericSignalHeader = any(~isnan(str2double(info.columns))); +hasUnlabeledSignal = ~isempty(info.columns) && isempty(strtrim(info.columns{end})); +if hasNumericSignalHeader + checks = addCheck(checks, 'pass', 2, 'ensite.wave_signal.numbered', ... + 'Wave file has numbered signal columns.', info.file); +elseif hasUnlabeledSignal + checks = addCheck(checks, 'warning', 2, 'ensite.wave_signal.unlabeled', ... + 'Wave file has an unlabeled signal column; importer should store it as signals.', info.file); +else + checks = addCheck(checks, 'warning', 2, 'ensite.wave_signal.missing', ... + 'No numeric or unlabeled signal column was detected.', info.file); +end + +if isnan(info.sampleFreq) + checks = addCheck(checks, 'fail', 2, 'ensite.wave_sample_rate.missing', ... + 'Sample rate was not found for wave file.', info.file); +elseif info.sampleFreq > 0 + checks = addCheck(checks, 'pass', 2, 'ensite.wave_sample_rate.valid', ... + sprintf('Sample rate is %.3f Hz.', info.sampleFreq), info.file); +else + checks = addCheck(checks, 'fail', 2, 'ensite.wave_sample_rate.invalid', ... + sprintf('Sample rate is invalid: %.3f.', info.sampleFreq), info.file); +end +end + +function checks = validateEnsiteConsistency(mapInfo, waveInfo, checks) +for i = 1:numel(waveInfo) + info = waveInfo(i); + if strcmpi(info.name, 'Wave_rov.csv') && ~isnan(mapInfo.numMappingPts) && ~isnan(info.dataRows) + if abs(info.dataRows - mapInfo.numMappingPts) <= 1 + checks = addCheck(checks, 'pass', 3, 'ensite.wave_rov.rows_match_map', ... + 'Wave_rov row count is consistent with map points.', info.file); + else + checks = addCheck(checks, 'warning', 3, 'ensite.wave_rov.rows_mismatch_map', ... + sprintf('Wave_rov rows (%d) differ from map points (%d).', ... + info.dataRows, mapInfo.numMappingPts), info.file); + end + end +end + +sampleFreqs = [waveInfo.sampleFreq]; +sampleFreqs = sampleFreqs(~isnan(sampleFreqs)); +if numel(unique(sampleFreqs)) <= 1 && ~isempty(sampleFreqs) + checks = addCheck(checks, 'pass', 3, 'ensite.wave_sample_rate.consistent', ... + 'Wave files have consistent sample frequency.', mapInfo.file); +elseif numel(sampleFreqs) > 1 + checks = addCheck(checks, 'warning', 3, 'ensite.wave_sample_rate.inconsistent', ... + 'Wave files have inconsistent sample frequency.', mapInfo.file); +end +end + +function checks = validateEnsiteMapNumeric(info, checks) +if isempty(info.columns) || ~startsWith(info.name, 'Map_', 'IgnoreCase', true) + return +end + +[surfaceX, surfaceFound] = readNumericColumns(info, {'surface x', 'surface y', 'surface z'}); +if all(surfaceFound) + checks = validateCoordinateColumns(checks, surfaceX, 'ensite.numeric.surface_coordinates', ... + 'Surface coordinates', info.file); +end + +[rovingX, rovingFound] = readNumericColumns(info, {'roving x', 'roving y', 'roving z'}); +if all(rovingFound) + checks = validateCoordinateColumns(checks, rovingX, 'ensite.numeric.roving_coordinates', ... + 'Roving coordinates', info.file); +end + +if hasColumn(info.columns, 'LAT') + [lat, ~] = readNumericColumns(info, {'LAT'}); + checks = validateNumericVector(checks, lat, 'ensite.numeric.lat.finite', ... + 'LAT', info.file, false); +end + +voltageColumns = {'P-P', 'peak2peak', 'pp_Valong', 'unipoleMaxPP'}; +for i = 1:numel(voltageColumns) + if hasColumn(info.columns, voltageColumns{i}) + [voltage, ~] = readNumericColumns(info, voltageColumns(i)); + checks = validateNumericVector(checks, voltage, 'ensite.numeric.voltage.finite', ... + ['Voltage column ', voltageColumns{i}], info.file, false); + checks = validateNonnegativeVector(checks, voltage, 'ensite.numeric.voltage.nonnegative', ... + ['Voltage column ', voltageColumns{i}], info.file); + break + end +end +end + +function checks = validateCoordinateColumns(checks, values, idPrefix, label, file) +if isempty(values) + checks = addCheck(checks, 'warning', 4, [idPrefix, '.empty'], ... + [label, ' could not be read.'], file); + return +end + +finiteRows = all(isfinite(values), 2); +if all(finiteRows) + checks = addCheck(checks, 'pass', 4, [idPrefix, '.finite'], ... + sprintf('%s are finite for %d row(s).', label, size(values, 1)), file); +elseif any(finiteRows) + checks = addCheck(checks, 'warning', 4, [idPrefix, '.finite'], ... + sprintf('%s contain non-finite values in %d of %d row(s).', ... + label, sum(~finiteRows), size(values, 1)), file); +else + checks = addCheck(checks, 'fail', 4, [idPrefix, '.finite'], ... + [label, ' do not contain any fully finite coordinate rows.'], file); +end + +if any(finiteRows) + if all(abs(values(finiteRows, :)) < 1e-12, 'all') + checks = addCheck(checks, 'warning', 4, [idPrefix, '.all_zero'], ... + [label, ' are all zero; plotting may need roving coordinates or mesh data.'], file); + else + checks = addCheck(checks, 'pass', 4, [idPrefix, '.not_all_zero'], ... + [label, ' are not all zero.'], file); + end +end +end + +function [values, found] = readNumericColumns(info, columnNames) +columnNames = cellstr(columnNames); +found = false(1, numel(columnNames)); +indices = NaN(1, numel(columnNames)); +for i = 1:numel(columnNames) + indices(i) = columnIndex(info.columns, columnNames{i}); + found(i) = ~isnan(indices(i)); +end + +values = NaN(0, numel(columnNames)); +if any(~found) || isnan(info.dataStartRow) + return +end + +fid = openTextFile(info.file); +if fid == -1 + return +end +cleanupObj = onCleanup(@() fclose(fid)); + +for i = 1:info.dataStartRow + if ~ischar(fgetl(fid)) + return + end +end + +row = 0; +while true + line = fgetl(fid); + if ~ischar(line) + break + end + if isempty(strtrim(line)) || strcmpi(strtrim(line), 'EOF') + continue + end + parts = splitCsvLine(line); + row = row + 1; + values(row, :) = NaN; + for j = 1:numel(columnNames) + idx = indices(j); + if idx <= numel(parts) + values(row, j) = str2double(strtrim(parts{idx})); + end + end +end +end + +function idx = columnIndex(columns, columnName) +idx = find(strcmpi(strtrim(columns), columnName), 1); +if isempty(idx) + idx = NaN; +end +end + +function checks = validateCoordinateMatrix(checks, values, idPrefix, label, file, required) +level = 'warning'; +if required + level = 'fail'; +end + +if ~isnumeric(values) || ~ismatrix(values) || size(values, 2) ~= 3 + checks = addCheck(checks, level, 4, [idPrefix, '.shape'], ... + sprintf('%s should be a numeric N-by-3 array.', label), file); + return +end + +checks = addCheck(checks, 'pass', 4, [idPrefix, '.shape'], ... + sprintf('%s have shape %d-by-3.', label, size(values, 1)), file); +checks = validateCoordinateColumns(checks, double(values), idPrefix, label, file); +end + +function checks = validateNumericVector(checks, values, checkId, label, file, required) +level = 'warning'; +if required + level = 'fail'; +end + +if ~isnumeric(values) || isempty(values) + checks = addCheck(checks, level, 4, checkId, ... + sprintf('%s should be numeric and non-empty.', label), file); + return +end + +values = double(values(:)); +finiteMask = isfinite(values); +if all(finiteMask) + checks = addCheck(checks, 'pass', 4, checkId, ... + sprintf('%s values are finite (%d value(s)).', label, numel(values)), file); +elseif any(finiteMask) + checks = addCheck(checks, 'warning', 4, checkId, ... + sprintf('%s has %d non-finite value(s) out of %d.', ... + label, sum(~finiteMask), numel(values)), file); +else + checks = addCheck(checks, level, 4, checkId, ... + sprintf('%s does not contain any finite values.', label), file); +end +end + +function checks = validateNonnegativeVector(checks, values, checkId, label, file) +if ~isnumeric(values) || isempty(values) + checks = addCheck(checks, 'warning', 4, checkId, ... + sprintf('%s cannot be checked for sign because it is not numeric or is empty.', label), file); + return +end + +values = double(values(:)); +finiteValues = values(isfinite(values)); +if isempty(finiteValues) + checks = addCheck(checks, 'warning', 4, checkId, ... + sprintf('%s has no finite values for sign check.', label), file); +elseif any(finiteValues < 0) + checks = addCheck(checks, 'warning', 4, checkId, ... + sprintf('%s contains %d negative finite value(s).', label, sum(finiteValues < 0)), file); +else + checks = addCheck(checks, 'pass', 4, checkId, ... + sprintf('%s values are non-negative where finite.', label), file); +end +end + +function checks = validatePointCount(checks, values, expectedRows, checkId, label, file) +if isnan(expectedRows) || ~isnumeric(values) + return +end + +actualRows = size(values, 1); +if actualRows == expectedRows + checks = addCheck(checks, 'pass', 5, checkId, ... + sprintf('%s row count matches expected count: %d.', label, expectedRows), file); +else + checks = addCheck(checks, 'warning', 5, checkId, ... + sprintf('%s has %d row(s), expected %d.', label, actualRows, expectedRows), file); +end +end + +function checks = validateMeshFaces(checks, faces, nVertices, file) +if ~isnumeric(faces) || ~ismatrix(faces) || size(faces, 2) ~= 3 || isempty(faces) + checks = addCheck(checks, 'fail', 5, 'openep.mesh.faces.shape', ... + 'Mesh faces should be a non-empty numeric N-by-3 array.', file); + return +end + +checks = addCheck(checks, 'pass', 5, 'openep.mesh.faces.shape', ... + sprintf('Mesh faces have shape %d-by-3.', size(faces, 1)), file); + +faces = double(faces); +validFaces = all(isfinite(faces), 2) & all(faces == round(faces), 2) & ... + all(faces >= 1, 2) & all(faces <= nVertices, 2); +if all(validFaces) + checks = addCheck(checks, 'pass', 5, 'openep.mesh.faces.indices', ... + 'Mesh face indices are finite integer vertex indices.', file); +elseif any(validFaces) + checks = addCheck(checks, 'warning', 5, 'openep.mesh.faces.indices', ... + sprintf('Mesh has %d invalid face row(s) out of %d.', ... + sum(~validFaces), size(faces, 1)), file); +else + checks = addCheck(checks, 'fail', 5, 'openep.mesh.faces.indices', ... + 'No mesh faces contain valid vertex indices.', file); +end +end + +function [vertices, faces, meshError] = extractOpenepMesh(userdata) +vertices = []; +faces = []; +meshError = ''; + +if exist('getMesh', 'file') == 2 + try + mesh = getMesh(userdata, 'type', 'struct'); + vertices = double(mesh.X); + faces = double(mesh.Triangulation); + return + catch ME + meshError = ['getMesh could not read userdata.surface.triRep: ', ME.message]; + end +end + +if ~isfield(userdata, 'surface') || ~isstruct(userdata.surface) || ~isfield(userdata.surface, 'triRep') + meshError = 'userdata.surface.triRep is missing.'; + return +end + +triRep = userdata.surface.triRep; +try + if isstruct(triRep) && isfield(triRep, 'X') && isfield(triRep, 'Triangulation') + vertices = double(triRep.X); + faces = double(triRep.Triangulation); + meshError = ''; + elseif isa(triRep, 'triangulation') + vertices = double(triRep.Points); + faces = double(triRep.ConnectivityList); + meshError = ''; + elseif isa(triRep, 'TriRep') + vertices = double(triRep.X); + faces = double(triRep.Triangulation); + meshError = ''; + elseif isempty(meshError) + meshError = 'userdata.surface.triRep is not a supported mesh representation.'; + end +catch ME + meshError = ['Could not read userdata.surface.triRep: ', ME.message]; +end +end + +function filesOut = selectCoreFirstWaveFiles(filesIn, maxFiles) +filesIn = uniqueFiles(filesIn); +if numel(filesIn) <= maxFiles + filesOut = filesIn; + return +end + +names = {filesIn.name}; +isCore = strcmpi(names, 'Wave_refs.csv') | strcmpi(names, 'Wave_rov.csv'); +coreFiles = filesIn(isCore); +otherFiles = filesIn(~isCore); + +if numel(coreFiles) >= maxFiles + filesOut = coreFiles(1:maxFiles); + return +end + +remaining = maxFiles - numel(coreFiles); +otherFiles = sampleFilesEvenly(otherFiles, remaining); +filesOut = uniqueFiles([coreFiles(:); otherFiles(:)]); +end + +function filesOut = sampleFilesEvenly(filesIn, maxFiles) +filesIn = uniqueFiles(filesIn); +if isempty(filesIn) || maxFiles <= 0 + filesOut = filesIn([]); + return +end +if numel(filesIn) <= maxFiles + filesOut = filesIn; + return +end + +indices = unique(round(linspace(1, numel(filesIn), maxFiles))); +filesOut = filesIn(indices); +end + +function filesOut = uniqueFiles(filesIn) +filesOut = filesIn; +if isempty(filesIn) + return +end +paths = arrayfun(@(f) fullfile(f.folder, f.name), filesIn, 'UniformOutput', false); +[~, keep] = unique(paths, 'stable'); +filesOut = filesIn(keep); +end + +function checks = requireColumns(checks, info, requiredColumns, idPrefix) +missing = {}; +for i = 1:numel(requiredColumns) + if ~hasColumn(info.columns, requiredColumns{i}) + missing{end+1} = requiredColumns{i}; %#ok + end +end + +if isempty(missing) + checks = addCheck(checks, 'pass', 2, [idPrefix, '.present'], ... + 'Required columns are present.', info.file); +else + checks = addCheck(checks, 'fail', 2, [idPrefix, '.missing'], ... + ['Missing required columns: ', strjoin(missing, ', ')], info.file); +end +end + +function info = inspectDxlCsv(filePath, countRows) +if nargin < 2 + countRows = true; +end +[~, name, ext] = fileparts(filePath); +info = struct('file', filePath, 'name', [name, ext], 'error', '', ... + 'exportFileVersion', '', 'dataElement', '', 'mapName', '', 'mapType', '', ... + 'dataStartRow', NaN, 'numMappingPts', NaN, 'numFreezeGroups', NaN, ... + 'sampleFreq', NaN, 'waveformSamples', NaN, 'columns', {{}}, 'dataRows', NaN); + +fid = openTextFile(filePath); +if fid == -1 + info.error = ['Could not open CSV file: ', filePath]; + return +end +cleanupObj = onCleanup(@() fclose(fid)); + +lines = {}; +for i = 1:500 + line = fgetl(fid); + if ~ischar(line) + break + end + lines{end+1} = line; %#ok + if startsWith(line, '*****') && ~isnan(info.dataStartRow) && numel(lines) >= info.dataStartRow + break + end +end + +for i = 1:numel(lines) + line = lines{i}; + info.exportFileVersion = firstToken(line, 'Export File Version\s*:\s*([^,\r\n]+)', info.exportFileVersion); + info.dataElement = firstToken(line, 'Export Data Element\s*:\s*([^,\r\n]+)', info.dataElement); + info.mapName = firstToken(line, 'Map name\s*:\s*,\s*([^,\r\n]+)', info.mapName); + info.mapType = firstToken(line, 'Map type\s*:\s*,\s*([^,\r\n]+)', info.mapType); + info.dataStartRow = firstNumber(line, 'Data starts in row\s*,\s*(\d+)', info.dataStartRow); + info.numMappingPts = firstNumber(line, '# mapping pts\s*:\s*,\s*(\d+)', info.numMappingPts); + info.numFreezeGroups = firstNumber(line, '# freeze groups\s*:\s*,\s*(\d+)', info.numFreezeGroups); + info.sampleFreq = firstNumber(line, 'Sample rate\s*:\s*,\s*([0-9.]+)', info.sampleFreq); + info.waveformSamples = firstNumber(line, 'Waveform samples exported\s*:\s*,\s*(\d+)', info.waveformSamples); +end + +if ~isnan(info.dataStartRow) && numel(lines) >= info.dataStartRow + info.columns = splitCsvLine(lines{info.dataStartRow}); +elseif ~isnan(info.dataStartRow) + info.columns = readSpecificLine(filePath, info.dataStartRow); +end + +if countRows && ~isnan(info.dataStartRow) + info.dataRows = countDataRows(filePath, info.dataStartRow); +end +end + +function value = firstToken(line, pattern, currentValue) +value = currentValue; +tokens = regexp(line, pattern, 'tokens', 'once'); +if ~isempty(tokens) + value = strtrim(tokens{1}); +end +end + +function value = firstNumber(line, pattern, currentValue) +value = currentValue; +tokens = regexp(line, pattern, 'tokens', 'once'); +if ~isempty(tokens) + value = str2double(tokens{1}); +end +end + +function columns = readSpecificLine(filePath, lineNumber) +columns = {}; +fid = openTextFile(filePath); +if fid == -1 + return +end +cleanupObj = onCleanup(@() fclose(fid)); +line = ''; +for i = 1:lineNumber + line = fgetl(fid); + if ~ischar(line) + return + end +end +columns = splitCsvLine(line); +end + +function nRows = countDataRows(filePath, dataStartRow) +nRows = 0; +fid = openTextFile(filePath); +if fid == -1 + nRows = NaN; + return +end +cleanupObj = onCleanup(@() fclose(fid)); + +for i = 1:dataStartRow + if ~ischar(fgetl(fid)) + return + end +end + +while true + line = fgetl(fid); + if ~ischar(line) + break + end + line = strtrim(line); + if isempty(line) || strcmpi(line, 'EOF') + continue + end + nRows = nRows + 1; +end +end + +function fid = openTextFile(filePath) +fid = -1; +for attempt = 1:3 + fid = fopen(filePath, 'r'); + if fid ~= -1 + return + end + if attempt < 3 + pause(0.25 * attempt); + end +end +end + +function columns = splitCsvLine(line) +columns = regexp(line, ',', 'split'); +end + +function tf = hasColumn(columns, columnName) +tf = any(strcmpi(strtrim(columns), columnName)); +end + +function files = visibleFiles(files) +if isempty(files) + return +end +names = {files.name}; +files = files(~startsWith(names, '.') & ~startsWith(names, '._')); +end + +function text = safeFileRead(filePath) +try + text = fileread(filePath); +catch + text = ''; +end +end + +function tf = anyFileContains(files, pattern, maxFiles) +tf = false; +for i = 1:min(numel(files), maxFiles) + text = safeFileRead(fullfile(files(i).folder, files(i).name)); + if contains(text, pattern) + tf = true; + return + end +end +end + +function checks = emptyCheck() +checks = struct('level', {}, 'stage', {}, 'id', {}, 'message', {}, 'file', {}); +end + +function checks = addCheck(checks, level, stage, id, message, file) +checks(end+1) = struct( ... + 'level', char(level), ... + 'stage', stage, ... + 'id', char(id), ... + 'message', char(message), ... + 'file', char(file)); +end + +function report = finalizeReport(report) +levels = {report.checks.level}; +if any(strcmp(levels, 'fail')) + status = 'fail'; +elseif any(strcmp(levels, 'warning')) + status = 'warning'; +else + status = 'pass'; +end + +report.status = status; +report.numFail = sum(strcmp(levels, 'fail')); +report.numWarning = sum(strcmp(levels, 'warning')); +report.numPass = sum(strcmp(levels, 'pass')); +report.numInfo = sum(strcmp(levels, 'info')); +report.summary = sprintf('%s: %d fail, %d warning, %d pass, %d info', ... + upper(status), report.numFail, report.numWarning, report.numPass, report.numInfo); +end