diff --git a/analysis/matlab/raw/raw_success.csv b/analysis/matlab/raw/raw_success.csv new file mode 100644 index 0000000..d0708ce --- /dev/null +++ b/analysis/matlab/raw/raw_success.csv @@ -0,0 +1,35 @@ +Data,Success + +Group,—,—,Electrode-Box-A,Electrode-Box-A2,Electrode-Box-A2,Electrode-Box-A2,Electrode-Box-B2,Electrode-Box-B2,Electrode-Box-B2,Electrode-Box-B2,Left-Electrode-Cathodal,Naive,Naive,Naive,Right-Electrode +# Days Reach,Flan-1,Vu-vuong,Khoai-tay-1,Banh-mi-2,Egg-tart-2,Root-beer-2,Banh-mi-1,Egg-tart-1,Flan-2,Root-beer-1,OM-1,Khoai-lang-2,Khoai-tay-2,OM-2,Khoai-lang-1 +-4,,,,,,,,,,,,,,,0 +-3,,,,,,,,,,,,,,,2 +-2,,,1,,,,,,,,,,,0, +-1,,,3,2,,,,,,,,,,5,0 +0,,18,14,25,9,22,13,7,,11,0,0,,1,3 +1,,35,22,22,2,31,35,16,,18,1,0,21,11,18 +2,,61,6,22,31,49,47,23,,40,3,10,6,19,27 +3,,34,28,29,44,31,65,63,,55,1,11,0,29,46 +4,,61,60,27,54,60,70,69,,75,11,9,28,73,65 +5,,37,75,43,84,84,102,83,,64,5,34,31,53,76 +6,,49,81,74,85,,90,71,,104,9,21,62,73,79 +7,,65,78,70,79,,109,79,,98,,23,87,80,81 +8,,67,91,65,76,,104,98,,81,,64,105,91,98 +9,,73,99,79,88,,119,89,,89,,75,75,90,101 +10,,72,94,93,81,,121,96,,105,,63,101,95,103 +11,,89,110,,78,,121,96,,,,59,102,60,113 +12,,97,105,,96,,120,101,,,,51,95,58,117 +13,,93,106,,84,,135,103,,,,73,77,,114 +14,,75,91,,,,,97,,,,82,91,,119 +15,,68,,,,,,,,,,70,,,111 +16,,96,,,,,,,,,,76,,,74 +17,,68,,,,,,,,,,76,,,109 +18,,81,,,,,,,,,,63,,,88 +19,,66,,,,,,,,,,48,,,98 +20,,84,,,,,,,,,,65,,,118 +21,,66,,,,,,,,,,75,,,109 +22,,,,,,,,,,,,98,,,106 +23,,,,,,,,,,,,88,,, +24,,,,,,,,,,,,94,,, +25,,,,,,,,,,,,56,,, +26,,,,,,,,,,,,75,,, \ No newline at end of file diff --git a/analysis/matlab/raw/raw_total.csv b/analysis/matlab/raw/raw_total.csv new file mode 100644 index 0000000..561f855 --- /dev/null +++ b/analysis/matlab/raw/raw_total.csv @@ -0,0 +1,35 @@ +Data,Total attempts + +Group,—,—,Electrode-Box-A,Electrode-Box-A2,Electrode-Box-A2,Electrode-Box-A2,Electrode-Box-B2,Electrode-Box-B2,Electrode-Box-B2,Electrode-Box-B2,Left-Electrode-Cathodal,Naive,Naive,Naive,Right-Electrode +# Days Reach,Flan-1,Vu-vuong,Khoai-tay-1,Banh-mi-2,Egg-tart-2,Root-beer-2,Banh-mi-1,Egg-tart-1,Flan-2,Root-beer-1,OM-1,Khoai-lang-2,Khoai-tay-2,OM-2,Khoai-lang-1 +-4,,,,,,,,,,,,,,,0 +-3,,,,,,,,,20,,,,,,7 +-2,,,18,,,,,,,,,,,0, +-1,,,24,42,,,,,,,,,,18,0 +0,,61,62,97,32,74,84,56,,85,0,0,,7,69 +1,,91,83,101,38,87,86,78,,76,10,0,68,33,97 +2,,127,79,119,93,134,110,103,,105,5,47,79,62,84 +3,,146,97,118,101,89,140,120,,134,2,52,83,117,121 +4,,141,134,136,131,140,127,132,,136,44,56,87,126,143 +5,,151,138,146,139,147,142,136,,133,23,95,125,135,141 +6,,131,137,146,145,,131,142,,139,32,72,144,138,146 +7,,149,147,148,143,,148,138,,148,,99,141,131,153 +8,,141,132,130,131,,137,142,,145,,136,152,141,144 +9,,140,146,151,149,,150,139,,156,,131,148,135,149 +10,,131,143,152,151,,158,143,,158,,134,149,142,150 +11,,158,156,,152,,148,148,,,,139,154,133,154 +12,,154,143,,155,,149,156,,,,129,144,142,148 +13,,145,153,,155,,154,152,,,,143,149,,146 +14,,147,152,,,,,152,,,,136,153,,132 +15,,142,,,,,,,,,,145,,,156 +16,,162,,,,,,,,,,135,,,156 +17,,144,,,,,,,,,,150,,,139 +18,,134,,,,,,,,,,122,,,138 +19,,144,,,,,,,,,,116,,,140 +20,,127,,,,,,,,,,134,,,153 +21,,133,,,,,,,,,,131,,,146 +22,,,,,,,,,,,,146,,,141 +23,,,,,,,,,,,,139,,, +24,,,,,,,,,,,,148,,, +25,,,,,,,,,,,,102,,, +26,,,,,,,,,,,,143,,, \ No newline at end of file diff --git a/analysis/matlab/tdcs_config.m b/analysis/matlab/tdcs_config.m new file mode 100644 index 0000000..92b88f9 --- /dev/null +++ b/analysis/matlab/tdcs_config.m @@ -0,0 +1,57 @@ +function cfg = tdcs_config() +%TDCS_CONFIG Curated group map and merge maps for the tDCS reaching study. +% CFG = TDCS_CONFIG() returns a struct with: +% .groups containers.Map, subject name (char) -> curated group name +% (char), for the 12 curated animals only. All other +% animals present in the raw export are not in this map +% and must be dropped by callers. +% .ref reference group used to code the GLME group factor, +% 'Electrode-Box-B2'. +% .mergeA2 containers.Map collapsing the two UNKNOWN conditions +% toward the weaker anchor: Electrode-Box-A -> +% Electrode-Box-A2, Right-Electrode -> Electrode-Box-B2. +% .mergeB2 containers.Map collapsing both UNKNOWN conditions onto +% the stronger anchor: Electrode-Box-A -> Electrode-Box-B2, +% Right-Electrode -> Electrode-Box-B2. +% .anchorLow 'Electrode-Box-A2', the weaker of the two known anchors. +% .anchorHigh 'Electrode-Box-B2', the stronger known anchor (== ref). +% .unknowns cell array of the two ungrouped conditions that get +% classified against the two anchors: {'Electrode-Box-A', +% 'Right-Electrode'}. +% +% See docs/superpowers/plans/2026-07-20-matlab-tdcs-analysis.md (Global +% Constraints) for the source of these values. + +% Curated 12-animal group map (by animal name). +subjectGroup = { + 'Vu-vuong', 'Naive' + 'Khoai-lang-2', 'Naive' + 'Khoai-tay-2', 'Naive' + 'OM-2', 'Naive' + 'Khoai-tay-1', 'Electrode-Box-A' + 'Banh-mi-2', 'Electrode-Box-A2' + 'Egg-tart-2', 'Electrode-Box-A2' + 'Root-beer-2', 'Electrode-Box-A2' + 'Banh-mi-1', 'Electrode-Box-B2' + 'Egg-tart-1', 'Electrode-Box-B2' + 'Root-beer-1', 'Electrode-Box-B2' + 'Khoai-lang-1', 'Right-Electrode' + }; + +cfg = struct(); +cfg.groups = containers.Map(subjectGroup(:, 1), subjectGroup(:, 2)); + +cfg.ref = 'Electrode-Box-B2'; +cfg.anchorLow = 'Electrode-Box-A2'; +cfg.anchorHigh = 'Electrode-Box-B2'; +cfg.unknowns = {'Electrode-Box-A', 'Right-Electrode'}; + +cfg.mergeA2 = containers.Map( ... + {'Electrode-Box-A', 'Right-Electrode'}, ... + {'Electrode-Box-A2', 'Electrode-Box-B2'}); + +cfg.mergeB2 = containers.Map( ... + {'Electrode-Box-A', 'Right-Electrode'}, ... + {'Electrode-Box-B2', 'Electrode-Box-B2'}); + +end diff --git a/analysis/matlab/tdcs_load_data.m b/analysis/matlab/tdcs_load_data.m new file mode 100644 index 0000000..cbc5a33 --- /dev/null +++ b/analysis/matlab/tdcs_load_data.m @@ -0,0 +1,117 @@ +function T = tdcs_load_data() +%TDCS_LOAD_DATA Load, join, and curate the raw tDCS reaching matrices. +% T = TDCS_LOAD_DATA() reads analysis/matlab/raw/raw_success.csv and +% raw_total.csv (app-exported matrix format: metric header line, blank +% line, file "Group" line, "# Days Reach" subject-header line, then one +% row per day with one value per subject column), reshapes each to long +% form, joins them on (subject, day), applies the curated 12-animal +% group map from tdcs_config (the file's own Group row is ignored), +% and restricts to day >= 0. +% +% Returns a table with variables: +% subject (string) animal name +% group (categorical) curated group (Naive, Electrode-Box-A, +% Electrode-Box-A2, Electrode-Box-B2, +% Right-Electrode) +% day (double) "# Days Reach" value, >= 0 +% success (double) successful reaches in the session +% total (double) total attempts in the session +% +% Only the 12 curated animals (per tdcs_config().groups) are retained; +% any other animal present in the raw export is dropped. + +thisDir = fileparts(mfilename('fullpath')); +successFile = fullfile(thisDir, 'raw', 'raw_success.csv'); +totalFile = fullfile(thisDir, 'raw', 'raw_total.csv'); + +Tsuccess = localReadMatrix(successFile, 'success'); +Ttotal = localReadMatrix(totalFile, 'total'); + +% Left join keyed on (subject, day): every non-blank Success cell becomes +% a row; Total is looked up for the same (subject, day). Per the plan's +% Global Constraints, Total is fully populated wherever Success is, so +% this should never introduce a missing total — but the join is a left +% join (not an inner join) specifically so that an unmatched Success row +% would still be kept (with total = NaN) rather than silently dropped. +T = outerjoin(Tsuccess, Ttotal, 'Keys', {'subject', 'day'}, ... + 'MergeKeys', true, 'Type', 'left'); + +% Apply the curated map: keep only the 12 curated animals, and assign +% group from the map (overriding the file's own Group row). +cfg = tdcs_config(); +subjectsCell = cellstr(T.subject); +keep = isKey(cfg.groups, subjectsCell); +T = T(keep, :); +subjectsCell = subjectsCell(keep); + +groupVals = cell(height(T), 1); +for i = 1:height(T) + groupVals{i} = cfg.groups(subjectsCell{i}); +end +T.group = categorical(groupVals); + +% Restrict to day >= 0 (analysis window). +T = T(T.day >= 0, :); + +% Final variable order and a deterministic row order. +T = T(:, {'subject', 'group', 'day', 'success', 'total'}); +T = sortrows(T, {'subject', 'day'}); + +end + +function Tlong = localReadMatrix(filePath, valueName) +%LOCALREADMATRIX Parse one app-exported day-by-subject matrix to long form. +% Line 1 = "Data," (skipped), line 2 blank (skipped), line 3 = +% "Group,<...>" (skipped -- grouping comes from the curated map, not +% this row), line 4 = "# Days Reach," (columns 2:end +% are the subject names), lines 5+ = "," with +% blank cells meaning "no session that day for that subject". + +lines = readlines(filePath); +% Defensive: drop any wholly-blank trailing line (e.g. if the file ends +% with a newline). Real data rows always start with a day number and are +% never zero-length. +while numel(lines) > 0 && strlength(strip(lines(end))) == 0 + lines(end) = []; +end + +headerParts = strsplit(lines(4), ',', 'CollapseDelimiters', false); +subjects = string(headerParts(2:end)); +nSubjects = numel(subjects); + +dataLines = lines(5:end); +dataLines = dataLines(strlength(strip(dataLines)) > 0); +nRows = numel(dataLines); + +day = nan(nRows * nSubjects, 1); +subjectCol = strings(nRows * nSubjects, 1); +value = nan(nRows * nSubjects, 1); + +idx = 0; +for r = 1:nRows + parts = strsplit(dataLines(r), ',', 'CollapseDelimiters', false); + dayVal = str2double(parts(1)); + for c = 1:nSubjects + col = c + 1; + if col <= numel(parts) + cellStr = strtrim(parts(col)); + else + cellStr = ""; + end + if strlength(cellStr) == 0 + continue + end + idx = idx + 1; + day(idx) = dayVal; + subjectCol(idx) = subjects(c); + value(idx) = str2double(cellStr); + end +end + +day = day(1:idx); +subjectCol = subjectCol(1:idx); +value = value(1:idx); + +Tlong = table(subjectCol, day, value, 'VariableNames', {'subject', 'day', valueName}); + +end diff --git a/analysis/matlab/tests/tLoadData.m b/analysis/matlab/tests/tLoadData.m new file mode 100644 index 0000000..0cdeaea --- /dev/null +++ b/analysis/matlab/tests/tLoadData.m @@ -0,0 +1,72 @@ +classdef tLoadData < matlab.unittest.TestCase + %TLOADDATA Tests for tdcs_load_data / tdcs_config (Task 1). + % + % Authored per TDD: written before tdcs_load_data.m existed, so it is + % expected to fail until the loader is implemented. As of this + % commit MATLAB is not licensed in this environment, so this suite + % has been authored and reviewed by hand but has NOT been executed. + % Expected values (row count, group counts, load-check cells) were + % independently verified against analysis/matlab/raw/*.csv by a + % throwaway Python script (see task-1-report.md), not by running + % this test. + + properties (Constant) + % Non-blank (subject, day) success cells for the 12 curated + % animals with day >= 0, counted directly from raw_success.csv. + ExpectedRowCount = 185 + end + + methods (Test) + function testTwelveCuratedSubjects(testCase) + T = tdcs_load_data(); + testCase.verifyEqual(numel(unique(T.subject)), 12); + end + + function testRowCountMatchesNonBlankDayGeq0Cells(testCase) + T = tdcs_load_data(); + testCase.verifyEqual(height(T), tLoadData.ExpectedRowCount); + end + + function testGroupSubjectCounts(testCase) + T = tdcs_load_data(); + subjGroup = unique(T(:, {'subject', 'group'})); + counts = countcats(subjGroup.group); + catNames = categories(subjGroup.group); + + expected = containers.Map( ... + {'Naive', 'Electrode-Box-A2', 'Electrode-Box-B2', ... + 'Electrode-Box-A', 'Right-Electrode'}, ... + {4, 3, 3, 1, 1}); + + testCase.verifyEqual(numel(catNames), expected.Count); + for i = 1:numel(catNames) + name = catNames{i}; + testCase.verifyTrue(isKey(expected, name), ... + sprintf('Unexpected group "%s" in loaded data.', name)); + testCase.verifyEqual(counts(i), expected(name), ... + sprintf('Subject count mismatch for group "%s".', name)); + end + end + + function testVuVuongDayZeroLoadCheck(testCase) + T = tdcs_load_data(); + row = T(T.subject == "Vu-vuong" & T.day == 0, {'success', 'total'}); + testCase.verifyEqual(height(row), 1); + testCase.verifyEqual(row.success(1), 18); + testCase.verifyEqual(row.total(1), 61); + end + + function testBanhMi1DayZeroLoadCheck(testCase) + T = tdcs_load_data(); + row = T(T.subject == "Banh-mi-1" & T.day == 0, {'success', 'total'}); + testCase.verifyEqual(height(row), 1); + testCase.verifyEqual(row.success(1), 13); + testCase.verifyEqual(row.total(1), 84); + end + + function testNoNegativeDayRows(testCase) + T = tdcs_load_data(); + testCase.verifyTrue(all(T.day >= 0)); + end + end +end