matlab(tdcs): scenario derivation + derived data export
This commit is contained in:
@@ -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/<SCENARIO>.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 "<mergeKey>_full" / "<mergeKey>_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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user