Coverage for tasks/cbir.py: 49%
87 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/cbir.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, List, Optional, Type
30from cardinal_pythonlib.stringfunc import strseq
31from sqlalchemy.orm import Mapped, mapped_column
32from sqlalchemy.sql.sqltypes import Float, UnicodeText
34from camcops_server.cc_modules.cc_constants import CssClass
35from camcops_server.cc_modules.cc_db import add_multiple_columns
36from camcops_server.cc_modules.cc_html import (
37 answer,
38 get_yes_no,
39 subheading_spanning_three_columns,
40 tr,
41)
42from camcops_server.cc_modules.cc_request import CamcopsRequest
43from camcops_server.cc_modules.cc_sqla_coltypes import (
44 BIT_CHECKER,
45 mapped_camcops_column,
46)
47from camcops_server.cc_modules.cc_summaryelement import SummaryElement
48from camcops_server.cc_modules.cc_task import (
49 get_from_dict,
50 Task,
51 TaskHasPatientMixin,
52 TaskHasRespondentMixin,
53)
56# =============================================================================
57# CBI-R
58# =============================================================================
60QUESTION_SNIPPETS = [
61 "memory: poor day to day memory", # 1
62 "memory: asks same questions",
63 "memory: loses things",
64 "memory: forgets familiar names",
65 "memory: forgets names of objects", # 5
66 "memory: poor concentration",
67 "memory: forgets day",
68 "memory: confused in unusual surroundings",
69 "everyday: electrical appliances",
70 "everyday: writing", # 10
71 "everyday: using telephone",
72 "everyday: making hot drink",
73 "everyday: money",
74 "self-care: grooming",
75 "self-care: dressing", # 15
76 "self-care: feeding",
77 "self-care: bathing",
78 "behaviour: inappropriate humour",
79 "behaviour: temper outbursts",
80 "behaviour: uncooperative", # 20
81 "behaviour: socially embarrassing",
82 "behaviour: tactless/suggestive",
83 "behaviour: impulsive",
84 "mood: cries",
85 "mood: sad/depressed", # 25
86 "mood: restless/agitated",
87 "mood: irritable",
88 "beliefs: visual hallucinations",
89 "beliefs: auditory hallucinations",
90 "beliefs: delusions", # 30
91 "eating: sweet tooth",
92 "eating: repetitive",
93 "eating: increased appetite",
94 "eating: table manners",
95 "sleep: disturbed at night", # 35
96 "sleep: daytime sleep increased",
97 "stereotypy/motor: rigid/fixed opinions",
98 "stereotypy/motor: routines",
99 "stereotypy/motor: preoccupied with time",
100 "stereotypy/motor: expression/catchphrase", # 40
101 "motivation: less enthusiasm in usual interests",
102 "motivation: no interest in new things",
103 "motivation: fails to contact friends/family",
104 "motivation: indifferent to family/friend concerns",
105 "motivation: reduced affection", # 45
106]
109class CbiR( # type: ignore[misc]
110 TaskHasPatientMixin,
111 TaskHasRespondentMixin,
112 Task,
113):
114 """
115 Server implementation of the CBI-R task.
116 """
118 __tablename__ = "cbir"
119 shortname = "CBI-R"
121 @classmethod
122 def extend_columns(cls: Type["CbiR"], **kwargs: Any) -> None:
123 add_multiple_columns(
124 cls,
125 "frequency",
126 1,
127 cls.NQUESTIONS,
128 comment_fmt="Frequency Q{n}, {s} (0-4, higher worse)",
129 minimum=cls.MIN_SCORE,
130 maximum=cls.MAX_SCORE,
131 comment_strings=QUESTION_SNIPPETS,
132 )
133 add_multiple_columns(
134 cls,
135 "distress",
136 1,
137 cls.NQUESTIONS,
138 comment_fmt="Distress Q{n}, {s} (0-4, higher worse)",
139 minimum=cls.MIN_SCORE,
140 maximum=cls.MAX_SCORE,
141 comment_strings=QUESTION_SNIPPETS,
142 )
144 confirm_blanks: Mapped[Optional[int]] = mapped_camcops_column(
145 permitted_value_checker=BIT_CHECKER,
146 comment="Respondent confirmed that blanks are deliberate (N/A) "
147 "(0/NULL no, 1 yes)",
148 )
149 comments: Mapped[Optional[str]] = mapped_column(
150 UnicodeText, comment="Additional comments"
151 )
153 MIN_SCORE = 0
154 MAX_SCORE = 4
155 QNUMS_MEMORY = (1, 8) # tuple: first, last
156 QNUMS_EVERYDAY = (9, 13)
157 QNUMS_SELF = (14, 17)
158 QNUMS_BEHAVIOUR = (18, 23)
159 QNUMS_MOOD = (24, 27)
160 QNUMS_BELIEFS = (28, 30)
161 QNUMS_EATING = (31, 34)
162 QNUMS_SLEEP = (35, 36)
163 QNUMS_STEREOTYPY = (37, 40)
164 QNUMS_MOTIVATION = (41, 45)
166 NQUESTIONS = 45
167 TASK_FIELDS = strseq("frequency", 1, NQUESTIONS) + strseq(
168 "distress", 1, NQUESTIONS
169 )
171 @staticmethod
172 def longname(req: "CamcopsRequest") -> str:
173 _ = req.gettext
174 return _("Cambridge Behavioural Inventory, Revised")
176 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
177 return self.standard_task_summary_fields() + [
178 SummaryElement(
179 name="memory_frequency_pct",
180 coltype=Float(),
181 value=self.frequency_subscore(*self.QNUMS_MEMORY),
182 comment="Memory/orientation: frequency score (% of max)",
183 ),
184 SummaryElement(
185 name="memory_distress_pct",
186 coltype=Float(),
187 value=self.distress_subscore(*self.QNUMS_MEMORY),
188 comment="Memory/orientation: distress score (% of max)",
189 ),
190 SummaryElement(
191 name="everyday_frequency_pct",
192 coltype=Float(),
193 value=self.frequency_subscore(*self.QNUMS_EVERYDAY),
194 comment="Everyday skills: frequency score (% of max)",
195 ),
196 SummaryElement(
197 name="everyday_distress_pct",
198 coltype=Float(),
199 value=self.distress_subscore(*self.QNUMS_EVERYDAY),
200 comment="Everyday skills: distress score (% of max)",
201 ),
202 SummaryElement(
203 name="selfcare_frequency_pct",
204 coltype=Float(),
205 value=self.frequency_subscore(*self.QNUMS_SELF),
206 comment="Self-care: frequency score (% of max)",
207 ),
208 SummaryElement(
209 name="selfcare_distress_pct",
210 coltype=Float(),
211 value=self.distress_subscore(*self.QNUMS_SELF),
212 comment="Self-care: distress score (% of max)",
213 ),
214 SummaryElement(
215 name="behaviour_frequency_pct",
216 coltype=Float(),
217 value=self.frequency_subscore(*self.QNUMS_BEHAVIOUR),
218 comment="Abnormal behaviour: frequency score (% of max)",
219 ),
220 SummaryElement(
221 name="behaviour_distress_pct",
222 coltype=Float(),
223 value=self.distress_subscore(*self.QNUMS_BEHAVIOUR),
224 comment="Abnormal behaviour: distress score (% of max)",
225 ),
226 SummaryElement(
227 name="mood_frequency_pct",
228 coltype=Float(),
229 value=self.frequency_subscore(*self.QNUMS_MOOD),
230 comment="Mood: frequency score (% of max)",
231 ),
232 SummaryElement(
233 name="mood_distress_pct",
234 coltype=Float(),
235 value=self.distress_subscore(*self.QNUMS_MOOD),
236 comment="Mood: distress score (% of max)",
237 ),
238 SummaryElement(
239 name="beliefs_frequency_pct",
240 coltype=Float(),
241 value=self.frequency_subscore(*self.QNUMS_BELIEFS),
242 comment="Beliefs: frequency score (% of max)",
243 ),
244 SummaryElement(
245 name="beliefs_distress_pct",
246 coltype=Float(),
247 value=self.distress_subscore(*self.QNUMS_BELIEFS),
248 comment="Beliefs: distress score (% of max)",
249 ),
250 SummaryElement(
251 name="eating_frequency_pct",
252 coltype=Float(),
253 value=self.frequency_subscore(*self.QNUMS_EATING),
254 comment="Eating habits: frequency score (% of max)",
255 ),
256 SummaryElement(
257 name="eating_distress_pct",
258 coltype=Float(),
259 value=self.distress_subscore(*self.QNUMS_EATING),
260 comment="Eating habits: distress score (% of max)",
261 ),
262 SummaryElement(
263 name="sleep_frequency_pct",
264 coltype=Float(),
265 value=self.frequency_subscore(*self.QNUMS_SLEEP),
266 comment="Sleep: frequency score (% of max)",
267 ),
268 SummaryElement(
269 name="sleep_distress_pct",
270 coltype=Float(),
271 value=self.distress_subscore(*self.QNUMS_SLEEP),
272 comment="Sleep: distress score (% of max)",
273 ),
274 SummaryElement(
275 name="stereotypic_frequency_pct",
276 coltype=Float(),
277 value=self.frequency_subscore(*self.QNUMS_STEREOTYPY),
278 comment="Stereotypic and motor behaviours: frequency "
279 "score (% of max)",
280 ),
281 SummaryElement(
282 name="stereotypic_distress_pct",
283 coltype=Float(),
284 value=self.distress_subscore(*self.QNUMS_STEREOTYPY),
285 comment="Stereotypic and motor behaviours: distress "
286 "score (% of max)",
287 ),
288 SummaryElement(
289 name="motivation_frequency_pct",
290 coltype=Float(),
291 value=self.frequency_subscore(*self.QNUMS_MOTIVATION),
292 comment="Motivation: frequency score (% of max)",
293 ),
294 SummaryElement(
295 name="motivation_distress_pct",
296 coltype=Float(),
297 value=self.distress_subscore(*self.QNUMS_MOTIVATION),
298 comment="Motivation: distress score (% of max)",
299 ),
300 ]
302 def subscore(
303 self, first: int, last: int, fieldprefix: str
304 ) -> Optional[float]:
305 score = 0
306 n = 0
307 for q in range(first, last + 1):
308 value = getattr(self, fieldprefix + str(q))
309 if value is not None:
310 score += value / self.MAX_SCORE
311 n += 1
312 return 100 * score / n if n > 0 else None
314 def frequency_subscore(self, first: int, last: int) -> Optional[float]:
315 return self.subscore(first, last, "frequency")
317 def distress_subscore(self, first: int, last: int) -> Optional[float]:
318 return self.subscore(first, last, "distress")
320 def is_complete(self) -> bool:
321 if (
322 not self.field_contents_valid()
323 or not self.is_respondent_complete()
324 ):
325 return False
326 if self.confirm_blanks:
327 return True
328 return self.all_fields_not_none(self.TASK_FIELDS)
330 def get_task_html(self, req: CamcopsRequest) -> str:
331 freq_dict: dict[Optional[int], Optional[str]] = {None: None}
332 distress_dict: dict[Optional[int], Optional[str]] = {None: None}
333 for a in range(self.MIN_SCORE, self.MAX_SCORE + 1):
334 freq_dict[a] = self.wxstring(req, "f" + str(a))
335 distress_dict[a] = self.wxstring(req, "d" + str(a))
337 heading_memory = self.wxstring(req, "h_memory")
338 heading_everyday = self.wxstring(req, "h_everyday")
339 heading_selfcare = self.wxstring(req, "h_selfcare")
340 heading_behaviour = self.wxstring(req, "h_abnormalbehaviour")
341 heading_mood = self.wxstring(req, "h_mood")
342 heading_beliefs = self.wxstring(req, "h_beliefs")
343 heading_eating = self.wxstring(req, "h_eating")
344 heading_sleep = self.wxstring(req, "h_sleep")
345 heading_motor = self.wxstring(req, "h_stereotypy_motor")
346 heading_motivation = self.wxstring(req, "h_motivation")
348 def get_question_rows(first: int, last: int) -> str:
349 html = ""
350 for q in range(first, last + 1):
351 f = getattr(self, "frequency" + str(q))
352 d = getattr(self, "distress" + str(q))
353 fa = (
354 f"{f}: {get_from_dict(freq_dict, f)}"
355 if f is not None
356 else None
357 )
358 da = (
359 f"{d}: {get_from_dict(distress_dict, d)}"
360 if d is not None
361 else None
362 )
363 html += tr(
364 self.wxstring(req, "q" + str(q)), answer(fa), answer(da)
365 )
366 return html
368 h = f"""
369 <div class="{CssClass.SUMMARY}">
370 <table class="{CssClass.SUMMARY}">
371 {self.get_is_complete_tr(req)}
372 </table>
373 <table class="{CssClass.SUMMARY}">
374 <tr>
375 <th>Subscale</th>
376 <th>Frequency (% of max)</th>
377 <th>Distress (% of max)</th>
378 </tr>
379 <tr>
380 <td>{heading_memory}</td>
381 <td>{answer(self.frequency_subscore(*self.QNUMS_MEMORY))}</td>
382 <td>{answer(self.distress_subscore(*self.QNUMS_MEMORY))}</td>
383 </tr>
384 <tr>
385 <td>{heading_everyday}</td>
386 <td>{answer(self.frequency_subscore(*self.QNUMS_EVERYDAY))}</td>
387 <td>{answer(self.distress_subscore(*self.QNUMS_EVERYDAY))}</td>
388 </tr>
389 <tr>
390 <td>{heading_selfcare}</td>
391 <td>{answer(self.frequency_subscore(*self.QNUMS_SELF))}</td>
392 <td>{answer(self.distress_subscore(*self.QNUMS_SELF))}</td>
393 </tr>
394 <tr>
395 <td>{heading_behaviour}</td>
396 <td>{answer(self.frequency_subscore(*self.QNUMS_BEHAVIOUR))}</td>
397 <td>{answer(self.distress_subscore(*self.QNUMS_BEHAVIOUR))}</td>
398 </tr>
399 <tr>
400 <td>{heading_mood}</td>
401 <td>{answer(self.frequency_subscore(*self.QNUMS_MOOD))}</td>
402 <td>{answer(self.distress_subscore(*self.QNUMS_MOOD))}</td>
403 </tr>
404 <tr>
405 <td>{heading_beliefs}</td>
406 <td>{answer(self.frequency_subscore(*self.QNUMS_BELIEFS))}</td>
407 <td>{answer(self.distress_subscore(*self.QNUMS_BELIEFS))}</td>
408 </tr>
409 <tr>
410 <td>{heading_eating}</td>
411 <td>{answer(self.frequency_subscore(*self.QNUMS_EATING))}</td>
412 <td>{answer(self.distress_subscore(*self.QNUMS_EATING))}</td>
413 </tr>
414 <tr>
415 <td>{heading_sleep}</td>
416 <td>{answer(self.frequency_subscore(*self.QNUMS_SLEEP))}</td>
417 <td>{answer(self.distress_subscore(*self.QNUMS_SLEEP))}</td>
418 </tr>
419 <tr>
420 <td>{heading_motor}</td>
421 <td>{answer(self.frequency_subscore(*self.QNUMS_STEREOTYPY))}</td>
422 <td>{answer(self.distress_subscore(*self.QNUMS_STEREOTYPY))}</td>
423 </tr>
424 <tr>
425 <td>{heading_motivation}</td>
426 <td>{answer(self.frequency_subscore(*self.QNUMS_MOTIVATION))}</td>
427 <td>{answer(self.distress_subscore(*self.QNUMS_MOTIVATION))}</td>
428 </tr>
429 </table>
430 </div>
431 <table class="{CssClass.TASKDETAIL}">
432 {tr(
433 "Respondent confirmed that blanks are deliberate (N/A)",
434 answer(get_yes_no(req, self.confirm_blanks))
435 )}
436 {tr("Comments", answer(self.comments, default=""))}
437 </table>
438 <table class="{CssClass.TASKDETAIL}">
439 <tr>
440 <th width="50%">Question</th>
441 <th width="25%">Frequency (0–4)</th>
442 <th width="25%">Distress (0–4)</th>
443 </tr>
444 {subheading_spanning_three_columns(heading_memory)}
445 {get_question_rows(*self.QNUMS_MEMORY)}
446 {subheading_spanning_three_columns(heading_everyday)}
447 {get_question_rows(*self.QNUMS_EVERYDAY)}
448 {subheading_spanning_three_columns(heading_selfcare)}
449 {get_question_rows(*self.QNUMS_SELF)}
450 {subheading_spanning_three_columns(heading_behaviour)}
451 {get_question_rows(*self.QNUMS_BEHAVIOUR)}
452 {subheading_spanning_three_columns(heading_mood)}
453 {get_question_rows(*self.QNUMS_MOOD)}
454 {subheading_spanning_three_columns(heading_beliefs)}
455 {get_question_rows(*self.QNUMS_BELIEFS)}
456 {subheading_spanning_three_columns(heading_eating)}
457 {get_question_rows(*self.QNUMS_EATING)}
458 {subheading_spanning_three_columns(heading_sleep)}
459 {get_question_rows(*self.QNUMS_SLEEP)}
460 {subheading_spanning_three_columns(heading_motor)}
461 {get_question_rows(*self.QNUMS_STEREOTYPY)}
462 {subheading_spanning_three_columns(heading_motivation)}
463 {get_question_rows(*self.QNUMS_MOTIVATION)}
464 </table>
465 """
466 return h