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