Coverage for tasks/phq15.py: 43%
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/phq15.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, cast, List, Optional, Type
30from cardinal_pythonlib.stringfunc import strseq
31from sqlalchemy.sql.sqltypes import Integer
33from camcops_server.cc_modules.cc_constants import CssClass
34from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
35from camcops_server.cc_modules.cc_db import add_multiple_columns
36from camcops_server.cc_modules.cc_html import answer, get_yes_no, tr, tr_qa
37from camcops_server.cc_modules.cc_request import CamcopsRequest
38from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup
39from camcops_server.cc_modules.cc_sqla_coltypes import SummaryCategoryColType
40from camcops_server.cc_modules.cc_summaryelement import SummaryElement
41from camcops_server.cc_modules.cc_task import (
42 get_from_dict,
43 Task,
44 TaskHasPatientMixin,
45)
46from camcops_server.cc_modules.cc_text import SS
47from camcops_server.cc_modules.cc_trackerhelpers import (
48 TrackerInfo,
49 TrackerLabel,
50)
53# =============================================================================
54# PHQ-15
55# =============================================================================
58class Phq15( # type: ignore[misc]
59 TaskHasPatientMixin,
60 Task,
61):
62 """
63 Server implementation of the PHQ-15 task.
64 """
66 __tablename__ = "phq15"
67 shortname = "PHQ-15"
68 provides_trackers = True
70 NQUESTIONS = 15
71 MAX_TOTAL = 30
73 @classmethod
74 def extend_columns(cls: Type["Phq15"], **kwargs: Any) -> None:
75 add_multiple_columns(
76 cls,
77 "q",
78 1,
79 cls.NQUESTIONS,
80 minimum=0,
81 maximum=2,
82 comment_fmt="Q{n} ({s}) (0 not bothered at all - "
83 "2 bothered a lot)",
84 comment_strings=[
85 "stomach pain",
86 "back pain",
87 "limb/joint pain",
88 "F - menstrual",
89 "headaches",
90 "chest pain",
91 "dizziness",
92 "fainting",
93 "palpitations",
94 "breathless",
95 "sex",
96 "constipation/diarrhoea",
97 "nausea/indigestion",
98 "energy",
99 "sleep",
100 ],
101 )
103 ONE_TO_THREE = strseq("q", 1, 3)
104 FIVE_TO_END = strseq("q", 5, NQUESTIONS)
105 TASK_FIELDS = strseq("q", 1, NQUESTIONS)
107 @staticmethod
108 def longname(req: "CamcopsRequest") -> str:
109 _ = req.gettext
110 return _("Patient Health Questionnaire-15")
112 # noinspection PyUnresolvedReferences
113 def is_complete(self) -> bool:
114 if not self.field_contents_valid():
115 return False
116 if self.any_fields_none(self.ONE_TO_THREE):
117 return False
118 if self.any_fields_none(self.FIVE_TO_END):
119 return False
120 if self.is_female():
121 return self.q4 is not None # type: ignore[attr-defined]
122 else:
123 return True
125 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
126 return [
127 TrackerInfo(
128 value=self.total_score(),
129 plot_label="PHQ-15 total score (rating somatic symptoms)",
130 axis_label=f"Score for Q1-15 (out of {self.MAX_TOTAL})",
131 axis_min=-0.5,
132 axis_max=self.MAX_TOTAL + 0.5,
133 horizontal_lines=[14.5, 9.5, 4.5],
134 horizontal_labels=[
135 TrackerLabel(22, req.sstring(SS.SEVERE)),
136 TrackerLabel(12, req.sstring(SS.MODERATE)),
137 TrackerLabel(7, req.sstring(SS.MILD)),
138 TrackerLabel(2.25, req.sstring(SS.NONE)),
139 ],
140 )
141 ]
143 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
144 if not self.is_complete():
145 return CTV_INCOMPLETE
146 return [
147 CtvInfo(
148 content=(
149 f"PHQ-15 total score "
150 f"{self.total_score()}/{self.MAX_TOTAL} "
151 f"({self.severity( req)})"
152 )
153 )
154 ]
156 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
157 return self.standard_task_summary_fields() + [
158 SummaryElement(
159 name="total",
160 coltype=Integer(),
161 value=self.total_score(),
162 comment=f"Total score (/{self.MAX_TOTAL})",
163 ),
164 SummaryElement(
165 name="severity",
166 coltype=SummaryCategoryColType,
167 value=self.severity(req),
168 comment="Severity",
169 ),
170 ]
172 def total_score(self) -> int:
173 return cast(int, self.sum_fields(self.TASK_FIELDS))
175 def num_severe(self) -> int:
176 n = 0
177 for i in range(1, self.NQUESTIONS + 1):
178 value = getattr(self, "q" + str(i))
179 if value is not None and value >= 2:
180 n += 1
181 return n
183 def severity(self, req: CamcopsRequest) -> str:
184 score = self.total_score()
185 if score >= 15:
186 return req.sstring(SS.SEVERE)
187 elif score >= 10:
188 return req.sstring(SS.MODERATE)
189 elif score >= 5:
190 return req.sstring(SS.MILD)
191 else:
192 return req.sstring(SS.NONE)
194 def get_task_html(self, req: CamcopsRequest) -> str:
195 score = self.total_score()
196 nsevere = self.num_severe()
197 somatoform_likely = nsevere >= 3
198 severity = self.severity(req)
199 answer_dict: dict[Optional[int], Optional[str]] = {None: None}
200 for option in range(0, 3):
201 answer_dict[option] = (
202 str(option) + " – " + self.wxstring(req, "a" + str(option))
203 )
204 q_a = ""
205 for q in range(1, self.NQUESTIONS + 1):
206 q_a += tr_qa(
207 self.wxstring(req, "q" + str(q)),
208 get_from_dict(answer_dict, getattr(self, "q" + str(q))),
209 )
210 h = """
211 <div class="{CssClass.SUMMARY}">
212 <table class="{CssClass.SUMMARY}">
213 {tr_is_complete}
214 {total_score}
215 {n_severe_symptoms}
216 {exceeds_somatoform_cutoff}
217 {symptom_severity}
218 </table>
219 </div>
220 <table class="{CssClass.TASKDETAIL}">
221 <tr>
222 <th width="70%">Question</th>
223 <th width="30%">Answer</th>
224 </tr>
225 {q_a}
226 </table>
227 <div class="{CssClass.FOOTNOTES}">
228 [1] In males, maximum score is actually 28.
229 [2] Questions with scores ≥2 are considered severe.
230 [3] ≥3 severe symptoms.
231 [4] Total score ≥15 severe, ≥10 moderate, ≥5 mild,
232 otherwise none.
233 </div>
234 """.format(
235 CssClass=CssClass,
236 tr_is_complete=self.get_is_complete_tr(req),
237 total_score=tr(
238 req.sstring(SS.TOTAL_SCORE) + " <sup>[1]</sup>",
239 answer(score) + f" / {self.MAX_TOTAL}",
240 ),
241 n_severe_symptoms=tr_qa(
242 self.wxstring(req, "n_severe_symptoms") + " <sup>[2]</sup>",
243 nsevere,
244 ),
245 exceeds_somatoform_cutoff=tr_qa(
246 self.wxstring(req, "exceeds_somatoform_cutoff")
247 + " <sup>[3]</sup>",
248 get_yes_no(req, somatoform_likely),
249 ),
250 symptom_severity=tr_qa(
251 self.wxstring(req, "symptom_severity") + " <sup>[4]</sup>",
252 severity,
253 ),
254 q_a=q_a,
255 )
256 return h
258 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
259 procedure = req.snomed(SnomedLookup.PHQ15_PROCEDURE)
260 codes = [SnomedExpression(procedure)]
261 if self.is_complete():
262 scale = req.snomed(SnomedLookup.PHQ15_SCALE)
263 score = req.snomed(SnomedLookup.PHQ15_SCORE)
264 codes.append(SnomedExpression(scale, {score: self.total_score()}))
265 return codes