From e398237c769afa40805d971945de63da84f1d3cb Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 20 Jul 2026 07:39:12 -0400 Subject: [PATCH] matlab(tdcs): scenario derivation + derived data export --- analysis/matlab/tdcs_scenario_data.m | 111 +++++++++++++++++++++ analysis/matlab/tests/tScenarioData.m | 135 ++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 analysis/matlab/tdcs_scenario_data.m create mode 100644 analysis/matlab/tests/tScenarioData.m diff --git a/analysis/matlab/tdcs_scenario_data.m b/analysis/matlab/tdcs_scenario_data.m new file mode 100644 index 0000000..29065df --- /dev/null +++ b/analysis/matlab/tdcs_scenario_data.m @@ -0,0 +1,111 @@ +function [S, meta] = tdcs_scenario_data(scenario) +%TDCS_SCENARIO_DATA Build one merge/window scenario from the curated tDCS data. +% [S, META] = TDCS_SCENARIO_DATA(SCENARIO) loads the curated 12-animal +% table via TDCS_LOAD_DATA, applies the requested group-merge map from +% TDCS_CONFIG, restricts to day <= maxDay per the scenario's window +% suffix, adds mean-centered day covariates DAY_C / DAY_C2, and recodes +% GROUP as a categorical with CFG.REF ('Electrode-Box-B2') as the FIRST +% (reference) category via REORDERCATS. S is also written to +% analysis/matlab/derived/.csv via WRITETABLE (the derived/ +% folder is created if it does not already exist). +% +% SCENARIO must be one of: +% unmerged_full, unmerged_d0_10, mergeA2_full, mergeA2_d0_10, +% mergeB2_full, mergeB2_d0_10 +% +% The scenario name is split into: +% - a merge key: 'unmerged' | 'mergeA2' | 'mergeB2', selecting which +% containers.Map from TDCS_CONFIG (if any) to apply to relabel +% GROUP. 'unmerged' applies no relabeling (identity) -- it is not +% backed by a config map at all, since only the two "unknown" +% conditions (Electrode-Box-A, Right-Electrode) are ever merge +% targets, and the unmerged scenario reports them unmerged/as-is. +% - a day window: '_full' -> maxDay = 26, '_d0_10' -> maxDay = 10. +% +% META is a struct with fields: +% .mergeKey 'unmerged' | 'mergeA2' | 'mergeB2' +% .maxDay 26 or 10 +% .isUnmerged true only when mergeKey == 'unmerged' + +[mergeKey, maxDay] = localParseScenario(scenario); + +cfg = tdcs_config(); +T = tdcs_load_data(); + +switch mergeKey + case 'unmerged' + % Identity: no relabeling. An empty map means isKey(...) is + % always false below, so every group passes through unchanged. + mergeMap = containers.Map('KeyType', 'char', 'ValueType', 'char'); + case 'mergeA2' + mergeMap = cfg.mergeA2; + case 'mergeB2' + mergeMap = cfg.mergeB2; + otherwise + error('tdcs_scenario_data:badScenario', ... + 'Unrecognized scenario "%s".', scenario); +end + +% Relabel GROUP per the merge map. Groups that are not a key in the map +% (e.g. Naive, Electrode-Box-A2, Electrode-Box-B2 under mergeA2/mergeB2, +% or every group under the empty 'unmerged' map) are left unchanged -- +% only the two "unknown" conditions are ever merge-map keys. +groupCell = cellstr(T.group); +for i = 1:numel(groupCell) + if isKey(mergeMap, groupCell{i}) + groupCell{i} = mergeMap(groupCell{i}); + end +end + +% Day window (applied after relabeling, before centering, per the plan). +dayMask = T.day <= maxDay; +S = T(dayMask, :); +groupCell = groupCell(dayMask); + +% Recode GROUP as categorical with cfg.ref first (the GLME reference +% level). Only categories actually present after merge+window survive. +S.group = categorical(groupCell); +allCats = categories(S.group); +otherCats = allCats(~strcmp(allCats, cfg.ref)); +S.group = reordercats(S.group, [{cfg.ref}; otherCats]); + +% Mean-centered day covariates, computed on the post-window data. +S.day_c = S.day - mean(S.day); +S.day_c2 = S.day_c .^ 2; + +% Persist the derived scenario table. +thisDir = fileparts(mfilename('fullpath')); +derivedDir = fullfile(thisDir, 'derived'); +if ~exist(derivedDir, 'dir') + mkdir(derivedDir); +end +outFile = fullfile(derivedDir, [char(scenario) '.csv']); +writetable(S, outFile); + +meta = struct(); +meta.mergeKey = mergeKey; +meta.maxDay = maxDay; +meta.isUnmerged = strcmp(mergeKey, 'unmerged'); + +end + +function [mergeKey, maxDay] = localParseScenario(scenario) +%LOCALPARSESCENARIO Split "_full" / "_d0_10" apart. +scenario = char(scenario); +if endsWith(scenario, '_full') + maxDay = 26; + mergeKey = scenario(1:end - numel('_full')); +elseif endsWith(scenario, '_d0_10') + maxDay = 10; + mergeKey = scenario(1:end - numel('_d0_10')); +else + error('tdcs_scenario_data:badScenario', ... + 'Scenario "%s" does not end in "_full" or "_d0_10".', scenario); +end + +if ~ismember(mergeKey, {'unmerged', 'mergeA2', 'mergeB2'}) + error('tdcs_scenario_data:badScenario', ... + 'Scenario "%s" has unrecognized merge key "%s".', scenario, mergeKey); +end + +end diff --git a/analysis/matlab/tests/tScenarioData.m b/analysis/matlab/tests/tScenarioData.m new file mode 100644 index 0000000..0bceec3 --- /dev/null +++ b/analysis/matlab/tests/tScenarioData.m @@ -0,0 +1,135 @@ +classdef tScenarioData < matlab.unittest.TestCase + %TSCENARIODATA Tests for tdcs_scenario_data (Task 2). + % + % Authored per TDD: written before tdcs_scenario_data.m existed, so + % it is expected to fail until the function 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 per-scenario subject-per-group counts were + % hand-derived from the Task 1 curated counts (Naive=4, + % Electrode-Box-A2=3, Electrode-Box-B2=3, Electrode-Box-A=1, + % Right-Electrode=1) plus each scenario's merge map, and cross- + % checked against task-2-brief.md's table (see task-2-report.md). + + properties (TestParameter) + scenarioCase = struct( ... + 'unmerged_full', struct( ... + 'scenario', 'unmerged_full', ... + 'maxDay', 26, ... + 'counts', containers.Map( ... + {'Electrode-Box-A', 'Electrode-Box-A2', ... + 'Electrode-Box-B2', 'Naive', 'Right-Electrode'}, ... + {1, 3, 3, 4, 1})), ... + 'unmerged_d0_10', struct( ... + 'scenario', 'unmerged_d0_10', ... + 'maxDay', 10, ... + 'counts', containers.Map( ... + {'Electrode-Box-A', 'Electrode-Box-A2', ... + 'Electrode-Box-B2', 'Naive', 'Right-Electrode'}, ... + {1, 3, 3, 4, 1})), ... + 'mergeA2_full', struct( ... + 'scenario', 'mergeA2_full', ... + 'maxDay', 26, ... + 'counts', containers.Map( ... + {'Electrode-Box-A2', 'Electrode-Box-B2', 'Naive'}, ... + {4, 4, 4})), ... + 'mergeA2_d0_10', struct( ... + 'scenario', 'mergeA2_d0_10', ... + 'maxDay', 10, ... + 'counts', containers.Map( ... + {'Electrode-Box-A2', 'Electrode-Box-B2', 'Naive'}, ... + {4, 4, 4})), ... + 'mergeB2_full', struct( ... + 'scenario', 'mergeB2_full', ... + 'maxDay', 26, ... + 'counts', containers.Map( ... + {'Electrode-Box-A2', 'Electrode-Box-B2', 'Naive'}, ... + {3, 5, 4})), ... + 'mergeB2_d0_10', struct( ... + 'scenario', 'mergeB2_d0_10', ... + 'maxDay', 10, ... + 'counts', containers.Map( ... + {'Electrode-Box-A2', 'Electrode-Box-B2', 'Naive'}, ... + {3, 5, 4})) ... + ) + end + + methods (Test) + function testGroupCountsWindowAndReference(testCase, scenarioCase) + [S, meta] = tdcs_scenario_data(scenarioCase.scenario); + + % Day window. + testCase.verifyEqual(meta.maxDay, scenarioCase.maxDay); + testCase.verifyLessThanOrEqual(max(S.day), scenarioCase.maxDay); + + % meta fields derived from the scenario name. + expectedMergeKey = regexprep(scenarioCase.scenario, '_full$|_d0_10$', ''); + testCase.verifyEqual(meta.mergeKey, expectedMergeKey); + testCase.verifyEqual(meta.isUnmerged, strcmp(expectedMergeKey, 'unmerged')); + + % Reference (first) category of S.group is Electrode-Box-B2. + allCats = categories(S.group); + testCase.verifyEqual(allCats{1}, 'Electrode-Box-B2'); + + % Per-group subject counts. + actualCounts = tScenarioData.localSubjectCountsByGroup(S); + expectedCounts = scenarioCase.counts; + + testCase.verifyEqual(actualCounts.Count, expectedCounts.Count); + actualNames = keys(actualCounts); + for i = 1:numel(actualNames) + name = actualNames{i}; + testCase.verifyTrue(isKey(expectedCounts, name), ... + sprintf('Unexpected group "%s" in scenario "%s".', ... + name, scenarioCase.scenario)); + testCase.verifyEqual(actualCounts(name), expectedCounts(name), ... + sprintf('Subject count mismatch for group "%s" in scenario "%s".', ... + name, scenarioCase.scenario)); + end + end + + function testMergeB2FullCsvIsWrittenAndReloads(testCase) + [S, ~] = tdcs_scenario_data('mergeB2_full'); + + thisDir = fileparts(mfilename('fullpath')); + matlabDir = fileparts(thisDir); + csvFile = fullfile(matlabDir, 'derived', 'mergeB2_full.csv'); + + testCase.verifyTrue(isfile(csvFile), ... + 'derived/mergeB2_full.csv was not written.'); + + Sreload = readtable(csvFile); + Sreload.group = categorical(Sreload.group); + + countsOrig = tScenarioData.localSubjectCountsByGroup(S); + countsReload = tScenarioData.localSubjectCountsByGroup(Sreload); + + testCase.verifyEqual(countsReload.Count, countsOrig.Count); + namesOrig = keys(countsOrig); + for i = 1:numel(namesOrig) + name = namesOrig{i}; + testCase.verifyTrue(isKey(countsReload, name), ... + sprintf('Group "%s" missing after CSV reload.', name)); + testCase.verifyEqual(countsReload(name), countsOrig(name), ... + sprintf('Subject count mismatch for group "%s" after CSV reload.', name)); + end + end + end + + methods (Static) + function m = localSubjectCountsByGroup(T) + %LOCALSUBJECTCOUNTSBYGROUP containers.Map group name -> #unique subjects. + subjGroup = unique(T(:, {'subject', 'group'})); + group = subjGroup.group; + if ~iscategorical(group) + group = categorical(group); + end + counts = countcats(group); + catNames = categories(group); + m = containers.Map('KeyType', 'char', 'ValueType', 'double'); + for i = 1:numel(catNames) + m(catNames{i}) = counts(i); + end + end + end +end