Coverage for tasks/cape42.py: 39%
136 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/cape42.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
30import cardinal_pythonlib.rnc_web as ws
31from sqlalchemy.sql.sqltypes import Float, Integer
33from camcops_server.cc_modules.cc_constants import CssClass
34from camcops_server.cc_modules.cc_db import add_multiple_columns
35from camcops_server.cc_modules.cc_html import answer, tr
36from camcops_server.cc_modules.cc_request import CamcopsRequest
37from camcops_server.cc_modules.cc_summaryelement import SummaryElement
38from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin
39from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo
42# =============================================================================
43# CAPE-42
44# =============================================================================
46QUESTION_SNIPPETS = [
47 # 1-10
48 "sad",
49 "double meaning",
50 "not very animated",
51 "not a talker",
52 "magazines/TV personal",
53 "some people not what they seem",
54 "persecuted",
55 "few/no emotions",
56 "pessimistic",
57 "conspiracy",
58 # 11-20
59 "destined for importance",
60 "no future",
61 "special/unusual person",
62 "no longer want to live",
63 "telepathy",
64 "no interest being with others",
65 "electrical devices influence thinking",
66 "lacking motivation",
67 "cry about nothing",
68 "occult",
69 # 21-30
70 "lack energy",
71 "people look oddly because of appearance",
72 "mind empty",
73 "thoughts removed",
74 "do nothing",
75 "thoughts not own",
76 "feelings lacking intensity",
77 "others might hear thoughts",
78 "lack spontaneity",
79 "thought echo",
80 # 31-40
81 "controlled by other force",
82 "emotions blunted",
83 "hear voices",
84 "hear voices conversing",
85 "neglecting appearance/hygiene",
86 "never get things done",
87 "few hobbies/interests",
88 "feel guilty",
89 "feel a failure",
90 "tense",
91 # 41-42
92 "Capgras",
93 "see things others cannot",
94]
95NQUESTIONS = 42
96POSITIVE = [
97 2,
98 5,
99 6,
100 7,
101 10,
102 11,
103 13,
104 15,
105 17,
106 20,
107 22,
108 24,
109 26,
110 28,
111 30,
112 31,
113 33,
114 34,
115 41,
116 42,
117]
118DEPRESSIVE = [1, 9, 12, 14, 19, 38, 39, 40]
119NEGATIVE = [3, 4, 8, 16, 18, 21, 23, 25, 27, 29, 32, 35, 36, 37]
120ALL = list(range(1, NQUESTIONS + 1))
121MIN_SCORE_PER_Q = 1
122MAX_SCORE_PER_Q = 4
124ALL_MIN = MIN_SCORE_PER_Q * NQUESTIONS
125ALL_MAX = MAX_SCORE_PER_Q * NQUESTIONS
126POS_MIN = MIN_SCORE_PER_Q * len(POSITIVE)
127POS_MAX = MAX_SCORE_PER_Q * len(POSITIVE)
128NEG_MIN = MIN_SCORE_PER_Q * len(NEGATIVE)
129NEG_MAX = MAX_SCORE_PER_Q * len(NEGATIVE)
130DEP_MIN = MIN_SCORE_PER_Q * len(DEPRESSIVE)
131DEP_MAX = MAX_SCORE_PER_Q * len(DEPRESSIVE)
133DP = 2
136class Cape42( # type: ignore[misc]
137 TaskHasPatientMixin,
138 Task,
139):
140 """
141 Server implementation of the CAPE-42 task.
142 """
144 __tablename__ = "cape42"
145 shortname = "CAPE-42"
146 provides_trackers = True
147 info_filename_stem = "cape"
149 @classmethod
150 def extend_columns(cls: Type["Cape42"], **kwargs: Any) -> None:
151 add_multiple_columns(
152 cls,
153 "frequency",
154 1,
155 NQUESTIONS,
156 minimum=MIN_SCORE_PER_Q,
157 maximum=MAX_SCORE_PER_Q,
158 comment_fmt=(
159 "Q{n} ({s}): frequency? (1 never, 2 sometimes, 3 often, "
160 "4 nearly always)"
161 ),
162 comment_strings=QUESTION_SNIPPETS,
163 )
164 add_multiple_columns(
165 cls,
166 "distress",
167 1,
168 NQUESTIONS,
169 minimum=MIN_SCORE_PER_Q,
170 maximum=MAX_SCORE_PER_Q,
171 comment_fmt=(
172 "Q{n} ({s}): distress (1 not, 2 a bit, 3 quite, 4 very), if "
173 "frequency > 1"
174 ),
175 comment_strings=QUESTION_SNIPPETS,
176 )
178 @staticmethod
179 def longname(req: "CamcopsRequest") -> str:
180 _ = req.gettext
181 return _("Community Assessment of Psychic Experiences")
183 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
184 fstr1 = "CAPE-42 weighted frequency score: "
185 dstr1 = "CAPE-42 weighted distress score: "
186 wtr = f" ({MIN_SCORE_PER_Q}–{MAX_SCORE_PER_Q})"
187 fstr2 = " weighted freq. score" + wtr
188 dstr2 = " weighted distress score" + wtr
189 axis_min = MIN_SCORE_PER_Q - 0.2
190 axis_max = MAX_SCORE_PER_Q + 0.2
191 return [
192 TrackerInfo(
193 value=self.weighted_frequency_score(ALL),
194 plot_label=fstr1 + "overall",
195 axis_label="Overall" + fstr2,
196 axis_min=axis_min,
197 axis_max=axis_max,
198 ),
199 TrackerInfo(
200 value=self.weighted_distress_score(ALL),
201 plot_label=dstr1 + "overall",
202 axis_label="Overall" + dstr2,
203 axis_min=axis_min,
204 axis_max=axis_max,
205 ),
206 TrackerInfo(
207 value=self.weighted_frequency_score(POSITIVE),
208 plot_label=fstr1 + "positive symptoms",
209 axis_label="Positive Sx" + fstr2,
210 axis_min=axis_min,
211 axis_max=axis_max,
212 ),
213 TrackerInfo(
214 value=self.weighted_distress_score(POSITIVE),
215 plot_label=dstr1 + "positive symptoms",
216 axis_label="Positive Sx" + dstr2,
217 axis_min=axis_min,
218 axis_max=axis_max,
219 ),
220 TrackerInfo(
221 value=self.weighted_frequency_score(NEGATIVE),
222 plot_label=fstr1 + "negative symptoms",
223 axis_label="Negative Sx" + fstr2,
224 axis_min=axis_min,
225 axis_max=axis_max,
226 ),
227 TrackerInfo(
228 value=self.weighted_distress_score(NEGATIVE),
229 plot_label=dstr1 + "negative symptoms",
230 axis_label="Negative Sx" + dstr2,
231 axis_min=axis_min,
232 axis_max=axis_max,
233 ),
234 TrackerInfo(
235 value=self.weighted_frequency_score(DEPRESSIVE),
236 plot_label=fstr1 + "depressive symptoms",
237 axis_label="Depressive Sx" + fstr2,
238 axis_min=axis_min,
239 axis_max=axis_max,
240 ),
241 TrackerInfo(
242 value=self.weighted_distress_score(DEPRESSIVE),
243 plot_label=dstr1 + "depressive symptoms",
244 axis_label="Depressive Sx" + dstr2,
245 axis_min=axis_min,
246 axis_max=axis_max,
247 ),
248 ]
250 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
251 wtr = f" ({MIN_SCORE_PER_Q}-{MAX_SCORE_PER_Q})"
252 return self.standard_task_summary_fields() + [
253 SummaryElement(
254 name="all_freq",
255 coltype=Integer(),
256 value=self.frequency_score(ALL),
257 comment=(
258 "Total score = frequency score for all questions "
259 f"({ALL_MIN}-{ALL_MAX})"
260 ),
261 ),
262 SummaryElement(
263 name="all_distress",
264 coltype=Integer(),
265 value=self.distress_score(ALL),
266 comment=(
267 "Distress score for all questions "
268 f"({ALL_MIN}-{ALL_MAX})"
269 ),
270 ),
271 SummaryElement(
272 name="positive_frequency",
273 coltype=Integer(),
274 value=self.frequency_score(POSITIVE),
275 comment=(
276 "Frequency score for positive symptom questions "
277 f"({POS_MIN}-{POS_MAX})"
278 ),
279 ),
280 SummaryElement(
281 name="positive_distress",
282 coltype=Integer(),
283 value=self.distress_score(POSITIVE),
284 comment=(
285 "Distress score for positive symptom questions "
286 f"({POS_MIN}-{POS_MAX})"
287 ),
288 ),
289 SummaryElement(
290 name="negative_frequency",
291 coltype=Integer(),
292 value=self.frequency_score(NEGATIVE),
293 comment=(
294 "Frequency score for negative symptom questions "
295 f"({NEG_MIN}-{NEG_MAX})"
296 ),
297 ),
298 SummaryElement(
299 name="negative_distress",
300 coltype=Integer(),
301 value=self.distress_score(NEGATIVE),
302 comment=(
303 "Distress score for negative symptom questions "
304 f"({NEG_MIN}-{NEG_MAX})"
305 ),
306 ),
307 SummaryElement(
308 name="depressive_frequency",
309 coltype=Integer(),
310 value=self.frequency_score(DEPRESSIVE),
311 comment=(
312 "Frequency score for depressive symptom questions "
313 f"({DEP_MIN}-{DEP_MAX})"
314 ),
315 ),
316 SummaryElement(
317 name="depressive_distress",
318 coltype=Integer(),
319 value=self.distress_score(DEPRESSIVE),
320 comment=(
321 "Distress score for depressive symptom questions "
322 f"({DEP_MIN}-{DEP_MAX})"
323 ),
324 ),
325 SummaryElement(
326 name="wt_all_freq",
327 coltype=Float(),
328 value=self.weighted_frequency_score(ALL),
329 comment="Weighted frequency score: overall" + wtr,
330 ),
331 SummaryElement(
332 name="wt_all_distress",
333 coltype=Float(),
334 value=self.weighted_distress_score(ALL),
335 comment="Weighted distress score: overall" + wtr,
336 ),
337 SummaryElement(
338 name="wt_pos_freq",
339 coltype=Float(),
340 value=self.weighted_frequency_score(POSITIVE),
341 comment="Weighted frequency score: positive symptoms" + wtr,
342 ),
343 SummaryElement(
344 name="wt_pos_distress",
345 coltype=Float(),
346 value=self.weighted_distress_score(POSITIVE),
347 comment="Weighted distress score: positive symptoms" + wtr,
348 ),
349 SummaryElement(
350 name="wt_neg_freq",
351 coltype=Float(),
352 value=self.weighted_frequency_score(NEGATIVE),
353 comment="Weighted frequency score: negative symptoms" + wtr,
354 ),
355 SummaryElement(
356 name="wt_neg_distress",
357 coltype=Float(),
358 value=self.weighted_distress_score(NEGATIVE),
359 comment="Weighted distress score: negative symptoms" + wtr,
360 ),
361 SummaryElement(
362 name="wt_dep_freq",
363 coltype=Float(),
364 value=self.weighted_frequency_score(DEPRESSIVE),
365 comment="Weighted frequency score: depressive symptoms" + wtr,
366 ),
367 SummaryElement(
368 name="wt_dep_distress",
369 coltype=Float(),
370 value=self.weighted_distress_score(DEPRESSIVE),
371 comment="Weighted distress score: depressive symptoms" + wtr,
372 ),
373 ]
375 def is_question_complete(self, q: int) -> bool:
376 f = self.get_frequency(q)
377 if f is None:
378 return False
379 if f > 1 and self.get_distress(q) is None:
380 return False
381 return True
383 def is_complete(self) -> bool:
384 if not self.field_contents_valid():
385 return False
386 for q in ALL:
387 if not self.is_question_complete(q):
388 return False
389 return True
391 def get_frequency(self, q: int) -> Optional[int]:
392 return getattr(self, "frequency" + str(q))
394 def get_distress(self, q: int) -> Optional[int]:
395 return getattr(self, "distress" + str(q))
397 def get_distress_score(self, q: int) -> Optional[int]:
398 if not self.endorsed(q):
399 return MIN_SCORE_PER_Q
400 return self.get_distress(q)
402 def endorsed(self, q: int) -> bool:
403 f = self.get_frequency(q)
404 return f is not None and f > MIN_SCORE_PER_Q
406 def distress_score(self, qlist: List[int]) -> int:
407 score = 0
408 for q in qlist:
409 d = self.get_distress_score(q)
410 if d is not None:
411 score += d
412 return score
414 def frequency_score(self, qlist: List[int]) -> int:
415 score = 0
416 for q in qlist:
417 f = self.get_frequency(q)
418 if f is not None:
419 score += f
420 return score
422 def weighted_frequency_score(self, qlist: List[int]) -> Optional[float]:
423 score = 0
424 n = 0
425 for q in qlist:
426 f = self.get_frequency(q)
427 if f is not None:
428 score += f
429 n += 1
430 if n == 0:
431 return None
432 return score / n
434 def weighted_distress_score(self, qlist: List[int]) -> Optional[float]:
435 score = 0
436 n = 0
437 for q in qlist:
438 f = self.get_frequency(q)
439 d = self.get_distress_score(q)
440 if f is not None and d is not None:
441 score += d
442 n += 1
443 if n == 0:
444 return None
445 return score / n
447 @staticmethod
448 def question_category(q: int) -> str:
449 if q in POSITIVE:
450 return "P"
451 if q in NEGATIVE:
452 return "N"
453 if q in DEPRESSIVE:
454 return "D"
455 return "?"
457 def get_task_html(self, req: CamcopsRequest) -> str:
458 q_a = ""
459 for q in ALL:
460 q_a += tr(
461 f"{q}. "
462 + self.wxstring(req, "q" + str(q))
463 + " (<i>"
464 + self.question_category(q)
465 + "</i>)",
466 answer(self.get_frequency(q)),
467 answer(
468 self.get_distress_score(q) if self.endorsed(q) else None,
469 default=str(MIN_SCORE_PER_Q),
470 ),
471 )
473 raw_overall = tr(
474 f"Overall <sup>[1]</sup> ({ALL_MIN}–{ALL_MAX})",
475 self.frequency_score(ALL),
476 self.distress_score(ALL),
477 )
478 raw_positive = tr(
479 f"Positive symptoms ({POS_MIN}–{POS_MAX})",
480 self.frequency_score(POSITIVE),
481 self.distress_score(POSITIVE),
482 )
483 raw_negative = tr(
484 f"Negative symptoms ({NEG_MIN}–{NEG_MAX})",
485 self.frequency_score(NEGATIVE),
486 self.distress_score(NEGATIVE),
487 )
488 raw_depressive = tr(
489 f"Depressive symptoms ({DEP_MIN}–{DEP_MAX})",
490 self.frequency_score(DEPRESSIVE),
491 self.distress_score(DEPRESSIVE),
492 )
493 weighted_overall = tr(
494 f"Overall ({len(ALL)} questions)",
495 ws.number_to_dp(self.weighted_frequency_score(ALL), DP),
496 ws.number_to_dp(self.weighted_distress_score(ALL), DP),
497 )
498 weighted_positive = tr(
499 f"Positive symptoms ({len(POSITIVE)} questions)",
500 ws.number_to_dp(self.weighted_frequency_score(POSITIVE), DP),
501 ws.number_to_dp(self.weighted_distress_score(POSITIVE), DP),
502 )
503 weighted_negative = tr(
504 f"Negative symptoms ({len(NEGATIVE)} questions)",
505 ws.number_to_dp(self.weighted_frequency_score(NEGATIVE), DP),
506 ws.number_to_dp(self.weighted_distress_score(NEGATIVE), DP),
507 )
508 weighted_depressive = tr(
509 f"Depressive symptoms ({len(DEPRESSIVE)} questions)",
510 ws.number_to_dp(self.weighted_frequency_score(DEPRESSIVE), DP),
511 ws.number_to_dp(self.weighted_distress_score(DEPRESSIVE), DP),
512 )
513 return f"""
514 <div class="{CssClass.SUMMARY}">
515 <table class="{CssClass.SUMMARY}">
516 {self.get_is_complete_tr(req)}
517 </table>
518 <table class="{CssClass.SUMMARY}">
519 <tr>
520 <th>Domain (with score range)</th>
521 <th>Frequency (total score)</th>
522 <th>Distress (total score)</th>
523 </tr>
524 {raw_overall}
525 {raw_positive}
526 {raw_negative}
527 {raw_depressive}
528 </table>
529 <table class="{CssClass.SUMMARY}">
530 <tr>
531 <th>Domain</th>
532 <th>Weighted frequency score <sup>[3]</sup></th>
533 <th>Weighted distress score <sup>[3]</sup></th>
534 </tr>
535 {weighted_overall}
536 {weighted_positive}
537 {weighted_negative}
538 {weighted_depressive}
539 </table>
540 </div>
541 <div class="{CssClass.EXPLANATION}">
542 FREQUENCY:
543 1 {self.wxstring(req, "frequency_option1")},
544 2 {self.wxstring(req, "frequency_option2")},
545 3 {self.wxstring(req, "frequency_option3")},
546 4 {self.wxstring(req, "frequency_option4")}.
547 DISTRESS:
548 1 {self.wxstring(req, "distress_option1")},
549 2 {self.wxstring(req, "distress_option2")},
550 3 {self.wxstring(req, "distress_option3")},
551 4 {self.wxstring(req, "distress_option4")}.
552 </div>
553 <table class="{CssClass.TASKDETAIL}">
554 <tr>
555 <th width="70%">
556 Question (P positive, N negative, D depressive)
557 </th>
558 <th width="15%">Frequency
559 ({MIN_SCORE_PER_Q}–{MAX_SCORE_PER_Q})</th>
560 <th width="15%">Distress
561 ({MIN_SCORE_PER_Q}–{MAX_SCORE_PER_Q})
562 <sup>[2]</sup></th>
563 </tr>
564 {q_a}
565 </table>
566 <div class="{CssClass.FOOTNOTES}">
567 [1] “Total” score is the overall frequency score (the sum of
568 frequency scores for all questions).
569 [2] Distress coerced to 1 if frequency is 1.
570 [3] Sum score per dimension divided by number of completed
571 items. Shown to {DP} decimal places. Will be in the range
572 {MIN_SCORE_PER_Q}–{MAX_SCORE_PER_Q}, or blank if not
573 calculable.
574 </div>
575 """