Coverage for tasks/ifs.py: 38%

1"""

2 camcops_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

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

29

30 from sqlalchemy.orm import Mapped

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

32

33 from camcops_server.cc_modules.cc_constants import (

34 CssClass,

35 DATA_COLLECTION_UNLESS_UPGRADED_DIV,

36 INVALID_VALUE,

37)

38 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

39 from camcops_server.cc_modules.cc_html import (

40 answer,

41 get_correct_incorrect_none,

42 td,

43 tr,

44 tr_qa,

45)

46 from camcops_server.cc_modules.cc_request import CamcopsRequest

47 from 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)

55 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

56 from camcops_server.cc_modules.cc_task import (

57 Task,

58 TaskHasClinicianMixin,

59 TaskHasPatientMixin,

60)

61 from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo

62

63

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

65# IFS

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

67

68

69 class 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