Coverage for tasks/cape42.py : 40%

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