Coverage for tasks/ifs.py: 38%
135 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/tasks/ifs.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
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.
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.
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/>.
24===============================================================================
26"""
28from typing import Any, Dict, List, Optional, Type
30from sqlalchemy.orm import Mapped
31from sqlalchemy.sql.sqltypes import Boolean, Float, Integer
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
64# =============================================================================
65# IFS
66# =============================================================================
69class Ifs( # type: ignore[misc]
70 TaskHasPatientMixin,
71 TaskHasClinicianMixin,
72 Task,
73):
74 """
75 Server implementation of the IFS task.
76 """
78 __tablename__ = "ifs"
79 shortname = "IFS"
80 provides_trackers = True
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 )
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 )
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
175 @staticmethod
176 def longname(req: "CamcopsRequest") -> str:
177 _ = req.gettext
178 return _("INECO Frontal Screening")
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 ]
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 ]
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 ]
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)
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
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)
273 def get_task_html(self, req: CamcopsRequest) -> str:
274 scoredict = self.get_score()
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)
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