#!/usr/bin/env python
"""
camcops_server/tasks/cape42.py
===============================================================================
Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
This file is part of CamCOPS.
CamCOPS is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
CamCOPS is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
===============================================================================
"""
from typing import Any, Dict, List, Optional, Tuple, Type
import cardinal_pythonlib.rnc_web as ws
from sqlalchemy.ext.declarative import DeclarativeMeta
from sqlalchemy.sql.sqltypes import Float, Integer
from camcops_server.cc_modules.cc_constants import CssClass
from camcops_server.cc_modules.cc_db import add_multiple_columns
from camcops_server.cc_modules.cc_html import answer, tr
from camcops_server.cc_modules.cc_request import CamcopsRequest
from camcops_server.cc_modules.cc_summaryelement import SummaryElement
from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin
from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo
# =============================================================================
# CAPE-42
# =============================================================================
QUESTION_SNIPPETS = [
# 1-10
"sad",
"double meaning",
"not very animated",
"not a talker",
"magazines/TV personal",
"some people not what they seem",
"persecuted",
"few/no emotions",
"pessimistic",
"conspiracy",
# 11-20
"destined for importance",
"no future",
"special/unusual person",
"no longer want to live",
"telepathy",
"no interest being with others",
"electrical devices influence thinking",
"lacking motivation",
"cry about nothing",
"occult",
# 21-30
"lack energy",
"people look oddly because of appearance",
"mind empty",
"thoughts removed",
"do nothing",
"thoughts not own",
"feelings lacking intensity",
"others might hear thoughts",
"lack spontaneity",
"thought echo",
# 31-40
"controlled by other force",
"emotions blunted",
"hear voices",
"hear voices conversing",
"neglecting appearance/hygiene",
"never get things done",
"few hobbies/interests",
"feel guilty",
"feel a failure",
"tense",
# 41-42
"Capgras",
"see things others cannot",
]
NQUESTIONS = 42
POSITIVE = [
2,
5,
6,
7,
10,
11,
13,
15,
17,
20,
22,
24,
26,
28,
30,
31,
33,
34,
41,
42,
]
DEPRESSIVE = [1, 9, 12, 14, 19, 38, 39, 40]
NEGATIVE = [3, 4, 8, 16, 18, 21, 23, 25, 27, 29, 32, 35, 36, 37]
ALL = list(range(1, NQUESTIONS + 1))
MIN_SCORE_PER_Q = 1
MAX_SCORE_PER_Q = 4
ALL_MIN = MIN_SCORE_PER_Q * NQUESTIONS
ALL_MAX = MAX_SCORE_PER_Q * NQUESTIONS
POS_MIN = MIN_SCORE_PER_Q * len(POSITIVE)
POS_MAX = MAX_SCORE_PER_Q * len(POSITIVE)
NEG_MIN = MIN_SCORE_PER_Q * len(NEGATIVE)
NEG_MAX = MAX_SCORE_PER_Q * len(NEGATIVE)
DEP_MIN = MIN_SCORE_PER_Q * len(DEPRESSIVE)
DEP_MAX = MAX_SCORE_PER_Q * len(DEPRESSIVE)
DP = 2
class Cape42Metaclass(DeclarativeMeta):
# noinspection PyInitNewSignature
def __init__(
cls: Type["Cape42"],
name: str,
bases: Tuple[Type, ...],
classdict: Dict[str, Any],
) -> None:
add_multiple_columns(
cls,
"frequency",
1,
NQUESTIONS,
minimum=MIN_SCORE_PER_Q,
maximum=MAX_SCORE_PER_Q,
comment_fmt=(
"Q{n} ({s}): frequency? (1 never, 2 sometimes, 3 often, "
"4 nearly always)"
),
comment_strings=QUESTION_SNIPPETS,
)
add_multiple_columns(
cls,
"distress",
1,
NQUESTIONS,
minimum=MIN_SCORE_PER_Q,
maximum=MAX_SCORE_PER_Q,
comment_fmt=(
"Q{n} ({s}): distress (1 not, 2 a bit, 3 quite, 4 very), if "
"frequency > 1"
),
comment_strings=QUESTION_SNIPPETS,
)
super().__init__(name, bases, classdict)
[docs]class Cape42(TaskHasPatientMixin, Task, metaclass=Cape42Metaclass):
"""
Server implementation of the CAPE-42 task.
"""
__tablename__ = "cape42"
shortname = "CAPE-42"
provides_trackers = True
info_filename_stem = "cape"
[docs] @staticmethod
def longname(req: "CamcopsRequest") -> str:
_ = req.gettext
return _("Community Assessment of Psychic Experiences")
[docs] def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
fstr1 = "CAPE-42 weighted frequency score: "
dstr1 = "CAPE-42 weighted distress score: "
wtr = f" ({MIN_SCORE_PER_Q}–{MAX_SCORE_PER_Q})"
fstr2 = " weighted freq. score" + wtr
dstr2 = " weighted distress score" + wtr
axis_min = MIN_SCORE_PER_Q - 0.2
axis_max = MAX_SCORE_PER_Q + 0.2
return [
TrackerInfo(
value=self.weighted_frequency_score(ALL),
plot_label=fstr1 + "overall",
axis_label="Overall" + fstr2,
axis_min=axis_min,
axis_max=axis_max,
),
TrackerInfo(
value=self.weighted_distress_score(ALL),
plot_label=dstr1 + "overall",
axis_label="Overall" + dstr2,
axis_min=axis_min,
axis_max=axis_max,
),
TrackerInfo(
value=self.weighted_frequency_score(POSITIVE),
plot_label=fstr1 + "positive symptoms",
axis_label="Positive Sx" + fstr2,
axis_min=axis_min,
axis_max=axis_max,
),
TrackerInfo(
value=self.weighted_distress_score(POSITIVE),
plot_label=dstr1 + "positive symptoms",
axis_label="Positive Sx" + dstr2,
axis_min=axis_min,
axis_max=axis_max,
),
TrackerInfo(
value=self.weighted_frequency_score(NEGATIVE),
plot_label=fstr1 + "negative symptoms",
axis_label="Negative Sx" + fstr2,
axis_min=axis_min,
axis_max=axis_max,
),
TrackerInfo(
value=self.weighted_distress_score(NEGATIVE),
plot_label=dstr1 + "negative symptoms",
axis_label="Negative Sx" + dstr2,
axis_min=axis_min,
axis_max=axis_max,
),
TrackerInfo(
value=self.weighted_frequency_score(DEPRESSIVE),
plot_label=fstr1 + "depressive symptoms",
axis_label="Depressive Sx" + fstr2,
axis_min=axis_min,
axis_max=axis_max,
),
TrackerInfo(
value=self.weighted_distress_score(DEPRESSIVE),
plot_label=dstr1 + "depressive symptoms",
axis_label="Depressive Sx" + dstr2,
axis_min=axis_min,
axis_max=axis_max,
),
]
[docs] def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
wtr = f" ({MIN_SCORE_PER_Q}-{MAX_SCORE_PER_Q})"
return self.standard_task_summary_fields() + [
SummaryElement(
name="all_freq",
coltype=Integer(),
value=self.frequency_score(ALL),
comment=(
"Total score = frequency score for all questions "
f"({ALL_MIN}-{ALL_MAX})"
),
),
SummaryElement(
name="all_distress",
coltype=Integer(),
value=self.distress_score(ALL),
comment=(
"Distress score for all questions "
f"({ALL_MIN}-{ALL_MAX})"
),
),
SummaryElement(
name="positive_frequency",
coltype=Integer(),
value=self.frequency_score(POSITIVE),
comment=(
"Frequency score for positive symptom questions "
f"({POS_MIN}-{POS_MAX})"
),
),
SummaryElement(
name="positive_distress",
coltype=Integer(),
value=self.distress_score(POSITIVE),
comment=(
"Distress score for positive symptom questions "
f"({POS_MIN}-{POS_MAX})"
),
),
SummaryElement(
name="negative_frequency",
coltype=Integer(),
value=self.frequency_score(NEGATIVE),
comment=(
"Frequency score for negative symptom questions "
f"({NEG_MIN}-{NEG_MAX})"
),
),
SummaryElement(
name="negative_distress",
coltype=Integer(),
value=self.distress_score(NEGATIVE),
comment=(
"Distress score for negative symptom questions "
f"({NEG_MIN}-{NEG_MAX})"
),
),
SummaryElement(
name="depressive_frequency",
coltype=Integer(),
value=self.frequency_score(DEPRESSIVE),
comment=(
"Frequency score for depressive symptom questions "
f"({DEP_MIN}-{DEP_MAX})"
),
),
SummaryElement(
name="depressive_distress",
coltype=Integer(),
value=self.distress_score(DEPRESSIVE),
comment=(
"Distress score for depressive symptom questions "
f"({DEP_MIN}-{DEP_MAX})"
),
),
SummaryElement(
name="wt_all_freq",
coltype=Float(),
value=self.weighted_frequency_score(ALL),
comment="Weighted frequency score: overall" + wtr,
),
SummaryElement(
name="wt_all_distress",
coltype=Float(),
value=self.weighted_distress_score(ALL),
comment="Weighted distress score: overall" + wtr,
),
SummaryElement(
name="wt_pos_freq",
coltype=Float(),
value=self.weighted_frequency_score(POSITIVE),
comment="Weighted frequency score: positive symptoms" + wtr,
),
SummaryElement(
name="wt_pos_distress",
coltype=Float(),
value=self.weighted_distress_score(POSITIVE),
comment="Weighted distress score: positive symptoms" + wtr,
),
SummaryElement(
name="wt_neg_freq",
coltype=Float(),
value=self.weighted_frequency_score(NEGATIVE),
comment="Weighted frequency score: negative symptoms" + wtr,
),
SummaryElement(
name="wt_neg_distress",
coltype=Float(),
value=self.weighted_distress_score(NEGATIVE),
comment="Weighted distress score: negative symptoms" + wtr,
),
SummaryElement(
name="wt_dep_freq",
coltype=Float(),
value=self.weighted_frequency_score(DEPRESSIVE),
comment="Weighted frequency score: depressive symptoms" + wtr,
),
SummaryElement(
name="wt_dep_distress",
coltype=Float(),
value=self.weighted_distress_score(DEPRESSIVE),
comment="Weighted distress score: depressive symptoms" + wtr,
),
]
def is_question_complete(self, q: int) -> bool:
f = self.get_frequency(q)
if f is None:
return False
if f > 1 and self.get_distress(q) is None:
return False
return True
[docs] def is_complete(self) -> bool:
if not self.field_contents_valid():
return False
for q in ALL:
if not self.is_question_complete(q):
return False
return True
def get_frequency(self, q: int) -> Optional[int]:
return getattr(self, "frequency" + str(q))
def get_distress(self, q: int) -> Optional[int]:
return getattr(self, "distress" + str(q))
def get_distress_score(self, q: int) -> Optional[int]:
if not self.endorsed(q):
return MIN_SCORE_PER_Q
return self.get_distress(q)
def endorsed(self, q: int) -> bool:
f = self.get_frequency(q)
return f is not None and f > MIN_SCORE_PER_Q
def distress_score(self, qlist: List[int]) -> int:
score = 0
for q in qlist:
d = self.get_distress_score(q)
if d is not None:
score += d
return score
def frequency_score(self, qlist: List[int]) -> int:
score = 0
for q in qlist:
f = self.get_frequency(q)
if f is not None:
score += f
return score
def weighted_frequency_score(self, qlist: List[int]) -> Optional[float]:
score = 0
n = 0
for q in qlist:
f = self.get_frequency(q)
if f is not None:
score += f
n += 1
if n == 0:
return None
return score / n
def weighted_distress_score(self, qlist: List[int]) -> Optional[float]:
score = 0
n = 0
for q in qlist:
f = self.get_frequency(q)
d = self.get_distress_score(q)
if f is not None and d is not None:
score += d
n += 1
if n == 0:
return None
return score / n
@staticmethod
def question_category(q: int) -> str:
if q in POSITIVE:
return "P"
if q in NEGATIVE:
return "N"
if q in DEPRESSIVE:
return "D"
return "?"
[docs] def get_task_html(self, req: CamcopsRequest) -> str:
q_a = ""
for q in ALL:
q_a += tr(
f"{q}. "
+ self.wxstring(req, "q" + str(q))
+ " (<i>"
+ self.question_category(q)
+ "</i>)",
answer(self.get_frequency(q)),
answer(
self.get_distress_score(q) if self.endorsed(q) else None,
default=str(MIN_SCORE_PER_Q),
),
)
raw_overall = tr(
f"Overall <sup>[1]</sup> ({ALL_MIN}–{ALL_MAX})",
self.frequency_score(ALL),
self.distress_score(ALL),
)
raw_positive = tr(
f"Positive symptoms ({POS_MIN}–{POS_MAX})",
self.frequency_score(POSITIVE),
self.distress_score(POSITIVE),
)
raw_negative = tr(
f"Negative symptoms ({NEG_MIN}–{NEG_MAX})",
self.frequency_score(NEGATIVE),
self.distress_score(NEGATIVE),
)
raw_depressive = tr(
f"Depressive symptoms ({DEP_MIN}–{DEP_MAX})",
self.frequency_score(DEPRESSIVE),
self.distress_score(DEPRESSIVE),
)
weighted_overall = tr(
f"Overall ({len(ALL)} questions)",
ws.number_to_dp(self.weighted_frequency_score(ALL), DP),
ws.number_to_dp(self.weighted_distress_score(ALL), DP),
)
weighted_positive = tr(
f"Positive symptoms ({len(POSITIVE)} questions)",
ws.number_to_dp(self.weighted_frequency_score(POSITIVE), DP),
ws.number_to_dp(self.weighted_distress_score(POSITIVE), DP),
)
weighted_negative = tr(
f"Negative symptoms ({len(NEGATIVE)} questions)",
ws.number_to_dp(self.weighted_frequency_score(NEGATIVE), DP),
ws.number_to_dp(self.weighted_distress_score(NEGATIVE), DP),
)
weighted_depressive = tr(
f"Depressive symptoms ({len(DEPRESSIVE)} questions)",
ws.number_to_dp(self.weighted_frequency_score(DEPRESSIVE), DP),
ws.number_to_dp(self.weighted_distress_score(DEPRESSIVE), DP),
)
return f"""
<div class="{CssClass.SUMMARY}">
<table class="{CssClass.SUMMARY}">
{self.get_is_complete_tr(req)}
</table>
<table class="{CssClass.SUMMARY}">
<tr>
<th>Domain (with score range)</th>
<th>Frequency (total score)</th>
<th>Distress (total score)</th>
</tr>
{raw_overall}
{raw_positive}
{raw_negative}
{raw_depressive}
</table>
<table class="{CssClass.SUMMARY}">
<tr>
<th>Domain</th>
<th>Weighted frequency score <sup>[3]</sup></th>
<th>Weighted distress score <sup>[3]</sup></th>
</tr>
{weighted_overall}
{weighted_positive}
{weighted_negative}
{weighted_depressive}
</table>
</div>
<div class="{CssClass.EXPLANATION}">
FREQUENCY:
1 {self.wxstring(req, "frequency_option1")},
2 {self.wxstring(req, "frequency_option2")},
3 {self.wxstring(req, "frequency_option3")},
4 {self.wxstring(req, "frequency_option4")}.
DISTRESS:
1 {self.wxstring(req, "distress_option1")},
2 {self.wxstring(req, "distress_option2")},
3 {self.wxstring(req, "distress_option3")},
4 {self.wxstring(req, "distress_option4")}.
</div>
<table class="{CssClass.TASKDETAIL}">
<tr>
<th width="70%">
Question (P positive, N negative, D depressive)
</th>
<th width="15%">Frequency
({MIN_SCORE_PER_Q}–{MAX_SCORE_PER_Q})</th>
<th width="15%">Distress
({MIN_SCORE_PER_Q}–{MAX_SCORE_PER_Q})
<sup>[2]</sup></th>
</tr>
{q_a}
</table>
<div class="{CssClass.FOOTNOTES}">
[1] “Total” score is the overall frequency score (the sum of
frequency scores for all questions).
[2] Distress coerced to 1 if frequency is 1.
[3] Sum score per dimension divided by number of completed
items. Shown to {DP} decimal places. Will be in the range
{MIN_SCORE_PER_Q}–{MAX_SCORE_PER_Q}, or blank if not
calculable.
</div>
"""