Coverage for tasks/ifs.py: 39%

137 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/ifs.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

10 

11 This file is part of CamCOPS. 

12 

13 CamCOPS is free software: you can redistribute it and/or modify 

14 it under the terms of the GNU General Public License as published by 

15 the Free Software Foundation, either version 3 of the License, or 

16 (at your option) any later version. 

17 

18 CamCOPS is distributed in the hope that it will be useful, 

19 but WITHOUT ANY WARRANTY; without even the implied warranty of 

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

23 You should have received a copy of the GNU General Public License 

24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

25 

26=============================================================================== 

27 

28""" 

29 

30from typing import Any, Dict, List, Tuple, Type 

31 

32from sqlalchemy.ext.declarative import DeclarativeMeta 

33from sqlalchemy.sql.sqltypes import Boolean, Float, Integer 

34 

35from camcops_server.cc_modules.cc_constants import ( 

36 CssClass, 

37 DATA_COLLECTION_UNLESS_UPGRADED_DIV, 

38 INVALID_VALUE, 

39) 

40from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

41from camcops_server.cc_modules.cc_html import ( 

42 answer, 

43 get_correct_incorrect_none, 

44 td, 

45 tr, 

46 tr_qa, 

47) 

48from camcops_server.cc_modules.cc_request import CamcopsRequest 

49from camcops_server.cc_modules.cc_sqla_coltypes import ( 

50 BIT_CHECKER, 

51 CamcopsColumn, 

52 ZERO_TO_ONE_CHECKER, 

53 ZERO_TO_TWO_CHECKER, 

54 ZERO_TO_THREE_CHECKER, 

55) 

56from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

57from camcops_server.cc_modules.cc_task import ( 

58 Task, 

59 TaskHasClinicianMixin, 

60 TaskHasPatientMixin, 

61) 

62from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

63 

64 

65# ============================================================================= 

66# IFS 

67# ============================================================================= 

68 

69 

70class IfsMetaclass(DeclarativeMeta): 

71 # noinspection PyInitNewSignature 

72 def __init__( 

73 cls: Type["Ifs"], 

74 name: str, 

75 bases: Tuple[Type, ...], 

76 classdict: Dict[str, Any], 

77 ) -> None: 

78 for seqlen in cls.Q4_DIGIT_LENGTHS: 

79 fname1 = f"q4_len{seqlen}_1" 

80 fname2 = f"q4_len{seqlen}_2" 

81 setattr( 

82 cls, 

83 fname1, 

84 CamcopsColumn( 

85 fname1, 

86 Boolean, 

87 permitted_value_checker=BIT_CHECKER, 

88 comment=f"Q4. Digits backward, length {seqlen}, trial 1", 

89 ), 

90 ) 

91 setattr( 

92 cls, 

93 fname2, 

94 CamcopsColumn( 

95 fname2, 

96 Boolean, 

97 permitted_value_checker=BIT_CHECKER, 

98 comment=f"Q4. Digits backward, length {seqlen}, trial 2", 

99 ), 

100 ) 

101 for n in cls.Q6_SEQUENCE_NUMS: 

102 fname = f"q6_seq{n}" 

103 setattr( 

104 cls, 

105 fname, 

106 CamcopsColumn( 

107 fname, 

108 Integer, 

109 permitted_value_checker=BIT_CHECKER, 

110 comment=f"Q6. Spatial working memory, sequence {n}", 

111 ), 

112 ) 

113 for n in cls.Q7_PROVERB_NUMS: 

114 fname = "q7_proverb{}".format(n) 

115 setattr( 

116 cls, 

117 fname, 

118 CamcopsColumn( 

119 fname, 

120 Float, 

121 permitted_value_checker=ZERO_TO_ONE_CHECKER, 

122 comment=f"Q7. Proverb {n} (1 = correct explanation, " 

123 f"0.5 = example, 0 = neither)", 

124 ), 

125 ) 

126 for n in cls.Q8_SENTENCE_NUMS: 

127 fname = "q8_sentence{}".format(n) 

128 setattr( 

129 cls, 

130 fname, 

131 CamcopsColumn( 

132 fname, 

133 Integer, 

134 permitted_value_checker=ZERO_TO_TWO_CHECKER, 

135 comment=f"Q8. Hayling, sentence {n}", 

136 ), 

137 ) 

138 super().__init__(name, bases, classdict) 

139 

140 

141class Ifs( 

142 TaskHasPatientMixin, TaskHasClinicianMixin, Task, metaclass=IfsMetaclass 

143): 

144 """ 

145 Server implementation of the IFS task. 

146 """ 

147 

148 __tablename__ = "ifs" 

149 shortname = "IFS" 

150 provides_trackers = True 

151 

152 q1 = CamcopsColumn( 

153 "q1", 

154 Integer, 

155 permitted_value_checker=ZERO_TO_THREE_CHECKER, 

156 comment="Q1. Motor series (motor programming)", 

157 ) 

158 q2 = CamcopsColumn( 

159 "q2", 

160 Integer, 

161 permitted_value_checker=ZERO_TO_THREE_CHECKER, 

162 comment="Q2. Conflicting instructions (interference sensitivity)", 

163 ) 

164 q3 = CamcopsColumn( 

165 "q3", 

166 Integer, 

167 permitted_value_checker=ZERO_TO_THREE_CHECKER, 

168 comment="Q3. Go/no-go (inhibitory control)", 

169 ) 

170 q5 = CamcopsColumn( 

171 "q5", 

172 Integer, 

173 permitted_value_checker=ZERO_TO_TWO_CHECKER, 

174 comment="Q5. Verbal working memory", 

175 ) 

176 

177 Q4_DIGIT_LENGTHS = list(range(2, 7 + 1)) 

178 Q6_SEQUENCE_NUMS = list(range(1, 4 + 1)) 

179 Q7_PROVERB_NUMS = list(range(1, 3 + 1)) 

180 Q8_SENTENCE_NUMS = list(range(1, 3 + 1)) 

181 SIMPLE_Q = ( 

182 ["q1", "q2", "q3", "q5"] 

183 + [f"q6_seq{n}" for n in Q6_SEQUENCE_NUMS] 

184 + [f"q7_proverb{n}" for n in Q7_PROVERB_NUMS] 

185 + [f"q8_sentence{n}" for n in Q8_SENTENCE_NUMS] 

186 ) 

187 MAX_TOTAL = 30 

188 MAX_WM = 10 

189 

190 @staticmethod 

191 def longname(req: "CamcopsRequest") -> str: 

192 _ = req.gettext 

193 return _("INECO Frontal Screening") 

194 

195 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]: 

196 scoredict = self.get_score() 

197 return [ 

198 TrackerInfo( 

199 value=scoredict["total"], 

200 plot_label="IFS total score (higher is better)", 

201 axis_label=f"Total score (out of {self.MAX_TOTAL})", 

202 axis_min=-0.5, 

203 axis_max=self.MAX_TOTAL + 0.5, 

204 ), 

205 TrackerInfo( 

206 value=scoredict["wm"], 

207 plot_label="IFS working memory index (higher is better)", 

208 axis_label=f"Total score (out of {self.MAX_WM})", 

209 axis_min=-0.5, 

210 axis_max=self.MAX_WM + 0.5, 

211 ), 

212 ] 

213 

214 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]: 

215 scoredict = self.get_score() 

216 return self.standard_task_summary_fields() + [ 

217 SummaryElement( 

218 name="total", 

219 coltype=Float(), 

220 value=scoredict["total"], 

221 comment=f"Total (out of {self.MAX_TOTAL}, higher better)", 

222 ), 

223 SummaryElement( 

224 name="wm", 

225 coltype=Integer(), 

226 value=scoredict["wm"], 

227 comment=f"Working memory index (out of {self.MAX_WM}; " 

228 f"sum of Q4 + Q6", 

229 ), 

230 ] 

231 

232 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]: 

233 scoredict = self.get_score() 

234 if not self.is_complete(): 

235 return CTV_INCOMPLETE 

236 return [ 

237 CtvInfo( 

238 content=( 

239 f"Total: {scoredict['total']}/{self.MAX_TOTAL}; " 

240 f"working memory index {scoredict['wm']}/{self.MAX_WM}" 

241 ) 

242 ) 

243 ] 

244 

245 def get_score(self) -> Dict: 

246 q1 = getattr(self, "q1", 0) or 0 

247 q2 = getattr(self, "q2", 0) or 0 

248 q3 = getattr(self, "q3", 0) or 0 

249 q4 = 0 

250 for seqlen in self.Q4_DIGIT_LENGTHS: 

251 val1 = getattr(self, f"q4_len{seqlen}_1") 

252 val2 = getattr(self, f"q4_len{seqlen}_2") 

253 if val1 or val2: 

254 q4 += 1 

255 if not val1 and not val2: 

256 break 

257 q5 = getattr(self, "q5", 0) or 0 

258 q6 = self.sum_fields(["q6_seq" + str(s) for s in range(1, 4 + 1)]) 

259 q7 = self.sum_fields(["q7_proverb" + str(s) for s in range(1, 3 + 1)]) 

260 q8 = self.sum_fields(["q8_sentence" + str(s) for s in range(1, 3 + 1)]) 

261 total = q1 + q2 + q3 + q4 + q5 + q6 + q7 + q8 

262 wm = q4 + q6 # working memory index (though not verbal) 

263 return dict(total=total, wm=wm) 

264 

265 def is_complete(self) -> bool: 

266 if not self.field_contents_valid(): 

267 return False 

268 if self.any_fields_none(self.SIMPLE_Q): 

269 return False 

270 for seqlen in self.Q4_DIGIT_LENGTHS: 

271 val1 = getattr(self, f"q4_len{seqlen}_1") 

272 val2 = getattr(self, f"q4_len{seqlen}_2") 

273 if val1 is None or val2 is None: 

274 return False 

275 if not val1 and not val2: 

276 return True # all done 

277 return True 

278 

279 def get_simple_tr_qa(self, req: CamcopsRequest, qprefix: str) -> str: 

280 q = self.wxstring(req, qprefix + "_title") 

281 val = getattr(self, qprefix) 

282 if val is not None: 

283 a = self.wxstring(req, qprefix + "_a" + str(val)) 

284 else: 

285 a = None 

286 return tr_qa(q, a) 

287 

288 def get_task_html(self, req: CamcopsRequest) -> str: 

289 scoredict = self.get_score() 

290 

291 # Q1 

292 q_a = self.get_simple_tr_qa(req, "q1") 

293 # Q2 

294 q_a += self.get_simple_tr_qa(req, "q2") 

295 # Q3 

296 q_a += self.get_simple_tr_qa(req, "q3") 

297 # Q4 

298 q_a += tr( 

299 td(self.wxstring(req, "q4_title")), 

300 td("", td_class=CssClass.SUBHEADING), 

301 literal=True, 

302 ) 

303 required = True 

304 for n in self.Q4_DIGIT_LENGTHS: 

305 val1 = getattr(self, f"q4_len{n}_1") 

306 val2 = getattr(self, f"q4_len{n}_2") 

307 q = ( 

308 "… " 

309 + self.wxstring(req, f"q4_seq_len{n}_1") 

310 + " / " 

311 + self.wxstring(req, f"q4_seq_len{n}_2") 

312 ) 

313 if required: 

314 score = 1 if val1 or val2 else 0 

315 a = ( 

316 answer(get_correct_incorrect_none(val1)) 

317 + " / " 

318 + answer(get_correct_incorrect_none(val2)) 

319 + f" (scores {score})" 

320 ) 

321 else: 

322 a = "" 

323 q_a += tr(q, a) 

324 if not val1 and not val2: 

325 required = False 

326 # Q5 

327 q_a += self.get_simple_tr_qa(req, "q5") 

328 # Q6 

329 q_a += tr( 

330 td(self.wxstring(req, "q6_title")), 

331 td("", td_class=CssClass.SUBHEADING), 

332 literal=True, 

333 ) 

334 for n in self.Q6_SEQUENCE_NUMS: 

335 nstr = str(n) 

336 val = getattr(self, "q6_seq" + nstr) 

337 q_a += tr_qa("… " + self.wxstring(req, "q6_seq" + nstr), val) 

338 # Q7 

339 q7map = { 

340 None: None, 

341 1: self.wxstring(req, "q7_a_1"), 

342 0.5: self.wxstring(req, "q7_a_half"), 

343 0: self.wxstring(req, "q7_a_0"), 

344 } 

345 q_a += tr( 

346 td(self.wxstring(req, "q7_title")), 

347 td("", td_class=CssClass.SUBHEADING), 

348 literal=True, 

349 ) 

350 for n in self.Q7_PROVERB_NUMS: 

351 nstr = str(n) 

352 val = getattr(self, "q7_proverb" + nstr) 

353 a = q7map.get(val, INVALID_VALUE) 

354 q_a += tr_qa("… " + self.wxstring(req, "q7_proverb" + nstr), a) 

355 # Q8 

356 q8map = { 

357 None: None, 

358 2: self.wxstring(req, "q8_a2"), 

359 1: self.wxstring(req, "q8_a1"), 

360 0: self.wxstring(req, "q8_a0"), 

361 } 

362 q_a += tr( 

363 td(self.wxstring(req, "q8_title")), 

364 td("", td_class=CssClass.SUBHEADING), 

365 literal=True, 

366 ) 

367 for n in self.Q8_SENTENCE_NUMS: 

368 nstr = str(n) 

369 val = getattr(self, "q8_sentence" + nstr) 

370 a = q8map.get(val, INVALID_VALUE) 

371 q_a += tr_qa("… " + self.wxstring(req, "q8_sentence_" + nstr), a) 

372 

373 return f""" 

374 <div class="{CssClass.SUMMARY}"> 

375 <table class="{CssClass.SUMMARY}"> 

376 {self.get_is_complete_tr(req)} 

377 <tr> 

378 <td>Total (higher better)</td> 

379 <td>{answer(scoredict['total'])} / {self.MAX_TOTAL}</td> 

380 </td> 

381 <tr> 

382 <td>Working memory index <sup>1</sup></td> 

383 <td>{answer(scoredict['wm'])} / {self.MAX_WM}</td> 

384 </td> 

385 </table> 

386 </div> 

387 <table class="{CssClass.TASKDETAIL}"> 

388 <tr> 

389 <th width="50%">Question</th> 

390 <th width="50%">Answer</th> 

391 </tr> 

392 {q_a} 

393 </table> 

394 <div class="{CssClass.FOOTNOTES}"> 

395 [1] Sum of scores for Q4 + Q6. 

396 </div> 

397 {DATA_COLLECTION_UNLESS_UPGRADED_DIV} 

398 """ # noqa: E501