Coverage for tasks/ifs.py: 38%

135 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 14:23 +0100

1""" 

2camcops_server/tasks/ifs.py 

3 

4=============================================================================== 

5 

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

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

8 

9 This file is part of CamCOPS. 

10 

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

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

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

14 (at your option) any later version. 

15 

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

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

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

19 GNU General Public License for more details. 

20 

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

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

23 

24=============================================================================== 

25 

26""" 

27 

28from typing import Any, Dict, List, Optional, Type 

29 

30from sqlalchemy.orm import Mapped 

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

32 

33from camcops_server.cc_modules.cc_constants import ( 

34 CssClass, 

35 DATA_COLLECTION_UNLESS_UPGRADED_DIV, 

36 INVALID_VALUE, 

37) 

38from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

39from camcops_server.cc_modules.cc_html import ( 

40 answer, 

41 get_correct_incorrect_none, 

42 td, 

43 tr, 

44 tr_qa, 

45) 

46from camcops_server.cc_modules.cc_request import CamcopsRequest 

47from camcops_server.cc_modules.cc_sqla_coltypes import ( 

48 BIT_CHECKER, 

49 camcops_column, 

50 mapped_camcops_column, 

51 ZERO_TO_ONE_CHECKER, 

52 ZERO_TO_TWO_CHECKER, 

53 ZERO_TO_THREE_CHECKER, 

54) 

55from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

56from camcops_server.cc_modules.cc_task import ( 

57 Task, 

58 TaskHasClinicianMixin, 

59 TaskHasPatientMixin, 

60) 

61from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

62 

63 

64# ============================================================================= 

65# IFS 

66# ============================================================================= 

67 

68 

69class Ifs( # type: ignore[misc] 

70 TaskHasPatientMixin, 

71 TaskHasClinicianMixin, 

72 Task, 

73): 

74 """ 

75 Server implementation of the IFS task. 

76 """ 

77 

78 __tablename__ = "ifs" 

79 shortname = "IFS" 

80 provides_trackers = True 

81 

82 @classmethod 

83 def extend_columns(cls: Type["Ifs"], **kwargs: Any) -> None: 

84 for seqlen in cls.Q4_DIGIT_LENGTHS: 

85 fname1 = f"q4_len{seqlen}_1" 

86 fname2 = f"q4_len{seqlen}_2" 

87 setattr( 

88 cls, 

89 fname1, 

90 camcops_column( 

91 fname1, 

92 Boolean, 

93 permitted_value_checker=BIT_CHECKER, 

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

95 ), 

96 ) 

97 setattr( 

98 cls, 

99 fname2, 

100 camcops_column( 

101 fname2, 

102 Boolean, 

103 permitted_value_checker=BIT_CHECKER, 

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

105 ), 

106 ) 

107 for n in cls.Q6_SEQUENCE_NUMS: 

108 fname = f"q6_seq{n}" 

109 setattr( 

110 cls, 

111 fname, 

112 camcops_column( 

113 fname, 

114 Integer, 

115 permitted_value_checker=BIT_CHECKER, 

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

117 ), 

118 ) 

119 for n in cls.Q7_PROVERB_NUMS: 

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

121 setattr( 

122 cls, 

123 fname, 

124 camcops_column( 

125 fname, 

126 Float, 

127 permitted_value_checker=ZERO_TO_ONE_CHECKER, 

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

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

130 ), 

131 ) 

132 for n in cls.Q8_SENTENCE_NUMS: 

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

134 setattr( 

135 cls, 

136 fname, 

137 camcops_column( 

138 fname, 

139 Integer, 

140 permitted_value_checker=ZERO_TO_TWO_CHECKER, 

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

142 ), 

143 ) 

144 

145 q1: Mapped[Optional[int]] = mapped_camcops_column( 

146 permitted_value_checker=ZERO_TO_THREE_CHECKER, 

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

148 ) 

149 q2: Mapped[Optional[int]] = mapped_camcops_column( 

150 permitted_value_checker=ZERO_TO_THREE_CHECKER, 

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

152 ) 

153 q3: Mapped[Optional[int]] = mapped_camcops_column( 

154 permitted_value_checker=ZERO_TO_THREE_CHECKER, 

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

156 ) 

157 q5: Mapped[Optional[int]] = mapped_camcops_column( 

158 permitted_value_checker=ZERO_TO_TWO_CHECKER, 

159 comment="Q5. Verbal working memory", 

160 ) 

161 

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

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

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

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

166 SIMPLE_Q = ( 

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

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

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

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

171 ) 

172 MAX_TOTAL = 30 

173 MAX_WM = 10 

174 

175 @staticmethod 

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

177 _ = req.gettext 

178 return _("INECO Frontal Screening") 

179 

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

181 scoredict = self.get_score() 

182 return [ 

183 TrackerInfo( 

184 value=scoredict["total"], 

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

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

187 axis_min=-0.5, 

188 axis_max=self.MAX_TOTAL + 0.5, 

189 ), 

190 TrackerInfo( 

191 value=scoredict["wm"], 

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

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

194 axis_min=-0.5, 

195 axis_max=self.MAX_WM + 0.5, 

196 ), 

197 ] 

198 

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

200 scoredict = self.get_score() 

201 return self.standard_task_summary_fields() + [ 

202 SummaryElement( 

203 name="total", 

204 coltype=Float(), 

205 value=scoredict["total"], 

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

207 ), 

208 SummaryElement( 

209 name="wm", 

210 coltype=Integer(), 

211 value=scoredict["wm"], 

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

213 f"sum of Q4 + Q6", 

214 ), 

215 ] 

216 

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

218 scoredict = self.get_score() 

219 if not self.is_complete(): 

220 return CTV_INCOMPLETE 

221 return [ 

222 CtvInfo( 

223 content=( 

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

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

226 ) 

227 ) 

228 ] 

229 

230 def get_score(self) -> Dict: 

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

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

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

234 q4 = 0 

235 for seqlen in self.Q4_DIGIT_LENGTHS: 

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

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

238 if val1 or val2: 

239 q4 += 1 

240 if not val1 and not val2: 

241 break 

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

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

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

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

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

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

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

249 

250 def is_complete(self) -> bool: 

251 if not self.field_contents_valid(): 

252 return False 

253 if self.any_fields_none(self.SIMPLE_Q): 

254 return False 

255 for seqlen in self.Q4_DIGIT_LENGTHS: 

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

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

258 if val1 is None or val2 is None: 

259 return False 

260 if not val1 and not val2: 

261 return True # all done 

262 return True 

263 

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

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

266 val = getattr(self, qprefix) 

267 if val is not None: 

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

269 else: 

270 a = None 

271 return tr_qa(q, a) 

272 

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

274 scoredict = self.get_score() 

275 

276 # Q1 

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

278 # Q2 

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

280 # Q3 

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

282 # Q4 

283 q_a += tr( 

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

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

286 literal=True, 

287 ) 

288 required = True 

289 for n in self.Q4_DIGIT_LENGTHS: 

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

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

292 q = ( 

293 "… " 

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

295 + " / " 

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

297 ) 

298 if required: 

299 score = 1 if val1 or val2 else 0 

300 a = ( 

301 answer(get_correct_incorrect_none(val1)) 

302 + " / " 

303 + answer(get_correct_incorrect_none(val2)) 

304 + f" (scores {score})" 

305 ) 

306 else: 

307 a = "" 

308 q_a += tr(q, a) 

309 if not val1 and not val2: 

310 required = False 

311 # Q5 

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

313 # Q6 

314 q_a += tr( 

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

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

317 literal=True, 

318 ) 

319 for n in self.Q6_SEQUENCE_NUMS: 

320 nstr = str(n) 

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

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

323 # Q7 

324 q7map = { 

325 None: None, 

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

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

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

329 } 

330 q_a += tr( 

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

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

333 literal=True, 

334 ) 

335 for n in self.Q7_PROVERB_NUMS: 

336 nstr = str(n) 

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

338 a = q7map.get(val, INVALID_VALUE) 

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

340 # Q8 

341 q8map = { 

342 None: None, 

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

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

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

346 } 

347 q_a += tr( 

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

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

350 literal=True, 

351 ) 

352 for n in self.Q8_SENTENCE_NUMS: 

353 nstr = str(n) 

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

355 a = q8map.get(val, INVALID_VALUE) 

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

357 

358 return f""" 

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

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

361 {self.get_is_complete_tr(req)} 

362 <tr> 

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

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

365 </td> 

366 <tr> 

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

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

369 </td> 

370 </table> 

371 </div> 

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

373 <tr> 

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

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

376 </tr> 

377 {q_a} 

378 </table> 

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

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

381 </div> 

382 {DATA_COLLECTION_UNLESS_UPGRADED_DIV} 

383 """ # noqa: E501