classdef tModels < matlab.unittest.TestCase %TMODELS Tests for tdcs_models (Task 3: GLMM + per-animal + classify). % % Authored per TDD: written before tdcs_models.m existed, so it is % expected to fail until the model-fitting function is implemented. % MATLAB R2025b + Statistics and Machine Learning Toolbox IS licensed % in this environment (unlike Tasks 1-2), so this suite is actually % run: RED before tdcs_models.m existed, GREEN after. % % Per-animal golden values (Box-B2 vs Box-A2, pure stats: per-subject % mean success for "count", per-subject pooled sum(success)/sum(total) % for "rate"; Welch two-sided TTEST2, one-sided 'right' RANKSUM) were % independently reproduced with a throwaway Python script against % analysis/tdcs_reach_data.csv (equal_var=False t-test, % mannwhitneyu(..., alternative='greater')), confirming the plan's % Task 3 golden-value table to better than 1e-3 in every cell (see % task-3-report.md). They must match to 3 decimals (AbsTol 0.005) -- % do not loosen this tolerance. % % GLME-derived checks (H2 odds/incidence-rate ratios, classification) % are checked loosely (direction/magnitude), since GLMM (subject % random intercept) is not numerically identical to the Python's GEE % (population-average, robust SEs) -- see the plan's Global % Constraints and analysis/METHODS.md. properties (TestParameter) perAnimalCase = struct( ... 'unmerged_full', struct( ... 'scenario', 'unmerged_full', 'nB', 3, 'nA', 3, ... 'rateWelchP', 0.072, 'rateMWUp', 0.050, ... 'countWelchP', 0.055, 'countMWUp', 0.050), ... 'mergeA2_full', struct( ... 'scenario', 'mergeA2_full', 'nB', 4, 'nA', 4, ... 'rateWelchP', 0.056, 'rateMWUp', 0.029, ... 'countWelchP', 0.035, 'countMWUp', 0.029), ... 'mergeB2_full', struct( ... 'scenario', 'mergeB2_full', 'nB', 5, 'nA', 3, ... 'rateWelchP', 0.044, 'rateMWUp', 0.018, ... 'countWelchP', 0.020, 'countMWUp', 0.018), ... 'unmerged_d0_10', struct( ... 'scenario', 'unmerged_d0_10', 'nB', 3, 'nA', 3, ... 'rateWelchP', 0.070, 'rateMWUp', 0.050, ... 'countWelchP', 0.041, 'countMWUp', 0.050), ... 'mergeA2_d0_10', struct( ... 'scenario', 'mergeA2_d0_10', 'nB', 4, 'nA', 4, ... 'rateWelchP', 0.069, 'rateMWUp', 0.057, ... 'countWelchP', 0.023, 'countMWUp', 0.014), ... 'mergeB2_d0_10', struct( ... 'scenario', 'mergeB2_d0_10', 'nB', 5, 'nA', 3, ... 'rateWelchP', 0.094, 'rateMWUp', 0.071, ... 'countWelchP', 0.023, 'countMWUp', 0.018) ... ) end methods (Test) function testPerAnimalGoldenValues(testCase, perAnimalCase) cfg = tdcs_config(); [S, meta] = tdcs_scenario_data(perAnimalCase.scenario); R = tdcs_models(S, meta, cfg); testCase.verifyEqual(R.perAnimal.nB, perAnimalCase.nB); testCase.verifyEqual(R.perAnimal.nA, perAnimalCase.nA); testCase.verifyEqual(R.perAnimal.rateWelchP, ... perAnimalCase.rateWelchP, 'AbsTol', 0.005); testCase.verifyEqual(R.perAnimal.rateMWUp, ... perAnimalCase.rateMWUp, 'AbsTol', 0.005); testCase.verifyEqual(R.perAnimal.countWelchP, ... perAnimalCase.countWelchP, 'AbsTol', 0.005); testCase.verifyEqual(R.perAnimal.countMWUp, ... perAnimalCase.countMWUp, 'AbsTol', 0.005); end function testGLMEObjectsReturned(testCase) cfg = tdcs_config(); [S, meta] = tdcs_scenario_data('mergeB2_full'); R = tdcs_models(S, meta, cfg); testCase.verifyClass(R.countGLME, 'GeneralizedLinearMixedModel'); testCase.verifyClass(R.rateGLME, 'GeneralizedLinearMixedModel'); testCase.verifyClass(R.learnGLME, 'GeneralizedLinearMixedModel'); end function testH2SanityMergeB2Full(testCase) cfg = tdcs_config(); [S, meta] = tdcs_scenario_data('mergeB2_full'); R = tdcs_models(S, meta, cfg); testCase.verifyGreaterThan(R.h2.rateOR, 1.3); testCase.verifyLessThan(R.h2.rateOR, 1.7); testCase.verifyLessThan(R.h2.rateOneSidedP, 0.05); testCase.verifyGreaterThan(R.h2.countIRR, 1); end function testClassifyPresentForUnmergedOnly(testCase) cfg = tdcs_config(); [S, meta] = tdcs_scenario_data('unmerged_full'); R = tdcs_models(S, meta, cfg); testCase.verifyTrue(meta.isUnmerged); for i = 1:numel(cfg.unknowns) fieldName = matlab.lang.makeValidName(cfg.unknowns{i}); testCase.verifyTrue(isfield(R.classify, fieldName), ... sprintf('R.classify missing entry for unknown "%s".', ... cfg.unknowns{i})); entry = R.classify.(fieldName); testCase.verifyTrue(isfield(entry, 'count')); testCase.verifyTrue(isfield(entry, 'rate')); testCase.verifyTrue(isfield(entry.count, 'label')); testCase.verifyTrue(isfield(entry.count.vsA2, 'ratio')); testCase.verifyTrue(isfield(entry.count.vsA2, 'p')); testCase.verifyTrue(isfield(entry.count.vsB2, 'ratio')); testCase.verifyTrue(isfield(entry.count.vsB2, 'p')); end [Sm, metam] = tdcs_scenario_data('mergeB2_full'); Rm = tdcs_models(Sm, metam, cfg); testCase.verifyFalse(metam.isUnmerged); testCase.verifyEmpty(fieldnames(Rm.classify)); end end end