Coverage for tasks/phq9.py: 42%
113 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/phq9.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"""
30import logging
31from typing import Any, Dict, List, Tuple, Type
33from cardinal_pythonlib.stringfunc import strseq
34from sqlalchemy.ext.declarative import DeclarativeMeta
35from sqlalchemy.sql.sqltypes import Boolean, Integer
37from camcops_server.cc_modules.cc_constants import CssClass
38from camcops_server.cc_modules.cc_ctvinfo import CtvInfo, CTV_INCOMPLETE
39from camcops_server.cc_modules.cc_db import add_multiple_columns
40from camcops_server.cc_modules.cc_fhir import (
41 FHIRAnsweredQuestion,
42 FHIRAnswerType,
43 FHIRQuestionType,
44)
45from camcops_server.cc_modules.cc_html import answer, get_yes_no, tr, tr_qa
46from camcops_server.cc_modules.cc_request import CamcopsRequest
47from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup
48from camcops_server.cc_modules.cc_sqla_coltypes import (
49 CamcopsColumn,
50 SummaryCategoryColType,
51 ZERO_TO_THREE_CHECKER,
52)
53from camcops_server.cc_modules.cc_summaryelement import SummaryElement
54from camcops_server.cc_modules.cc_task import (
55 get_from_dict,
56 Task,
57 TaskHasPatientMixin,
58)
59from camcops_server.cc_modules.cc_text import SS
60from camcops_server.cc_modules.cc_trackerhelpers import (
61 TrackerAxisTick,
62 TrackerInfo,
63 TrackerLabel,
64)
66log = logging.getLogger(__name__)
69# =============================================================================
70# PHQ-9
71# =============================================================================
74class Phq9Metaclass(DeclarativeMeta):
75 # noinspection PyInitNewSignature
76 def __init__(
77 cls: Type["Phq9"],
78 name: str,
79 bases: Tuple[Type, ...],
80 classdict: Dict[str, Any],
81 ) -> None:
82 add_multiple_columns(
83 cls,
84 "q",
85 1,
86 cls.N_MAIN_QUESTIONS,
87 minimum=0,
88 maximum=3,
89 comment_fmt="Q{n} ({s}) (0 not at all - 3 nearly every day)",
90 comment_strings=[
91 "anhedonia",
92 "mood",
93 "sleep",
94 "energy",
95 "appetite",
96 "self-esteem/guilt",
97 "concentration",
98 "psychomotor",
99 "death/self-harm",
100 ],
101 )
102 super().__init__(name, bases, classdict)
105class Phq9(TaskHasPatientMixin, Task, metaclass=Phq9Metaclass):
106 """
107 Server implementation of the PHQ9 task.
108 """
110 __tablename__ = "phq9"
111 shortname = "PHQ-9"
112 provides_trackers = True
114 q10 = CamcopsColumn(
115 "q10",
116 Integer,
117 permitted_value_checker=ZERO_TO_THREE_CHECKER,
118 comment="Q10 (difficulty in activities) (0 not difficult at "
119 "all - 3 extremely difficult)",
120 )
122 N_MAIN_QUESTIONS = 9
123 MAX_SCORE_MAIN = 3 * N_MAIN_QUESTIONS
124 MAIN_QUESTIONS = strseq("q", 1, N_MAIN_QUESTIONS)
126 @staticmethod
127 def longname(req: "CamcopsRequest") -> str:
128 _ = req.gettext
129 return _("Patient Health Questionnaire-9")
131 def is_complete(self) -> bool:
132 if self.any_fields_none(self.MAIN_QUESTIONS):
133 return False
134 if self.total_score() > 0 and self.q10 is None:
135 return False
136 if not self.field_contents_valid():
137 return False
138 return True
140 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
141 return [
142 TrackerInfo(
143 value=self.total_score(),
144 plot_label="PHQ-9 total score (rating depressive symptoms)",
145 axis_label=f"Score for Q1-9 (out of {self.MAX_SCORE_MAIN})",
146 axis_min=-0.5,
147 axis_max=self.MAX_SCORE_MAIN + 0.5,
148 axis_ticks=[
149 TrackerAxisTick(27, "27"),
150 TrackerAxisTick(25, "25"),
151 TrackerAxisTick(20, "20"),
152 TrackerAxisTick(15, "15"),
153 TrackerAxisTick(10, "10"),
154 TrackerAxisTick(5, "5"),
155 TrackerAxisTick(0, "0"),
156 ],
157 horizontal_lines=[19.5, 14.5, 9.5, 4.5],
158 horizontal_labels=[
159 TrackerLabel(23, req.sstring(SS.SEVERE)),
160 TrackerLabel(17, req.sstring(SS.MODERATELY_SEVERE)),
161 TrackerLabel(12, req.sstring(SS.MODERATE)),
162 TrackerLabel(7, req.sstring(SS.MILD)),
163 TrackerLabel(2.25, req.sstring(SS.NONE)),
164 ],
165 )
166 ]
168 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
169 if not self.is_complete():
170 return CTV_INCOMPLETE
171 return [
172 CtvInfo(
173 content=(
174 f"PHQ-9 total score "
175 f"{self.total_score()}/{self.MAX_SCORE_MAIN} "
176 f"({self.severity(req)})"
177 )
178 )
179 ]
181 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
182 return self.standard_task_summary_fields() + [
183 SummaryElement(
184 name="total",
185 coltype=Integer(),
186 value=self.total_score(),
187 comment=f"Total score (/{self.MAX_SCORE_MAIN})",
188 ),
189 SummaryElement(
190 name="n_core",
191 coltype=Integer(),
192 value=self.n_core(),
193 comment="Number of core symptoms",
194 ),
195 SummaryElement(
196 name="n_other",
197 coltype=Integer(),
198 value=self.n_other(),
199 comment="Number of other symptoms",
200 ),
201 SummaryElement(
202 name="n_total",
203 coltype=Integer(),
204 value=self.n_total(),
205 comment="Total number of symptoms",
206 ),
207 SummaryElement(
208 name="is_mds",
209 coltype=Boolean(),
210 value=self.is_mds(),
211 comment="PHQ9 major depressive syndrome?",
212 ),
213 SummaryElement(
214 name="is_ods",
215 coltype=Boolean(),
216 value=self.is_ods(),
217 comment="PHQ9 other depressive syndrome?",
218 ),
219 SummaryElement(
220 name="severity",
221 coltype=SummaryCategoryColType,
222 value=self.severity(req),
223 comment="PHQ9 depression severity",
224 ),
225 ]
227 def total_score(self) -> int:
228 return self.sum_fields(self.MAIN_QUESTIONS)
230 def one_if_q_ge(self, qnum: int, threshold: int) -> int:
231 value = getattr(self, "q" + str(qnum))
232 return 1 if value is not None and value >= threshold else 0
234 def n_core(self) -> int:
235 return self.one_if_q_ge(1, 2) + self.one_if_q_ge(2, 2)
237 def n_other(self) -> int:
238 return (
239 self.one_if_q_ge(3, 2)
240 + self.one_if_q_ge(4, 2)
241 + self.one_if_q_ge(5, 2)
242 + self.one_if_q_ge(6, 2)
243 + self.one_if_q_ge(7, 2)
244 + self.one_if_q_ge(8, 2)
245 + self.one_if_q_ge(9, 1)
246 ) # suicidality
247 # suicidality counted whenever present
249 def n_total(self) -> int:
250 return self.n_core() + self.n_other()
252 def is_mds(self) -> bool:
253 return self.n_core() >= 1 and self.n_total() >= 5
255 def is_ods(self) -> bool:
256 return self.n_core() >= 1 and 2 <= self.n_total() <= 4
258 def severity(self, req: CamcopsRequest) -> str:
259 total = self.total_score()
260 if total >= 20:
261 return req.sstring(SS.SEVERE)
262 elif total >= 15:
263 return req.sstring(SS.MODERATELY_SEVERE)
264 elif total >= 10:
265 return req.sstring(SS.MODERATE)
266 elif total >= 5:
267 return req.sstring(SS.MILD)
268 else:
269 return req.sstring(SS.NONE)
271 def get_task_html(self, req: CamcopsRequest) -> str:
272 main_dict = {
273 None: None,
274 0: "0 — " + self.wxstring(req, "a0"),
275 1: "1 — " + self.wxstring(req, "a1"),
276 2: "2 — " + self.wxstring(req, "a2"),
277 3: "3 — " + self.wxstring(req, "a3"),
278 }
279 q10_dict = {
280 None: None,
281 0: "0 — " + self.wxstring(req, "fa0"),
282 1: "1 — " + self.wxstring(req, "fa1"),
283 2: "2 — " + self.wxstring(req, "fa2"),
284 3: "3 — " + self.wxstring(req, "fa3"),
285 }
286 q_a = ""
287 for i in range(1, self.N_MAIN_QUESTIONS + 1):
288 nstr = str(i)
289 q_a += tr_qa(
290 self.wxstring(req, "q" + nstr),
291 get_from_dict(main_dict, getattr(self, "q" + nstr)),
292 )
293 q_a += tr_qa(
294 "10. " + self.wxstring(req, "finalq"),
295 get_from_dict(q10_dict, self.q10),
296 )
298 h = """
299 <div class="{CssClass.SUMMARY}">
300 <table class="{CssClass.SUMMARY}">
301 {tr_is_complete}
302 {total_score}
303 {depression_severity}
304 {n_symptoms}
305 {mds}
306 {ods}
307 </table>
308 </div>
309 <div class="{CssClass.EXPLANATION}">
310 Ratings are over the last 2 weeks.
311 </div>
312 <table class="{CssClass.TASKDETAIL}">
313 <tr>
314 <th width="60%">Question</th>
315 <th width="40%">Answer</th>
316 </tr>
317 {q_a}
318 </table>
319 <div class="{CssClass.FOOTNOTES}">
320 [1] Sum for questions 1–9.
321 [2] Total score ≥20 severe, ≥15 moderately severe,
322 ≥10 moderate, ≥5 mild, <5 none.
323 [3] Number of questions 1–2 rated ≥2.
324 [4] Number of questions 3–8 rated ≥2, or question 9
325 rated ≥1.
326 [5] ≥1 core symptom and ≥5 total symptoms (as per
327 DSM-IV-TR page 356).
328 [6] ≥1 core symptom and 2–4 total symptoms (as per
329 DSM-IV-TR page 775).
330 </div>
331 """.format(
332 CssClass=CssClass,
333 tr_is_complete=self.get_is_complete_tr(req),
334 total_score=tr(
335 req.sstring(SS.TOTAL_SCORE) + " <sup>[1]</sup>",
336 answer(self.total_score()) + f" / {self.MAX_SCORE_MAIN}",
337 ),
338 depression_severity=tr_qa(
339 self.wxstring(req, "depression_severity") + " <sup>[2]</sup>",
340 self.severity(req),
341 ),
342 n_symptoms=tr(
343 "Number of symptoms: core <sup>[3]</sup>, other "
344 "<sup>[4]</sup>, total",
345 answer(self.n_core())
346 + "/2, "
347 + answer(self.n_other())
348 + "/7, "
349 + answer(self.n_total())
350 + "/9",
351 ),
352 mds=tr_qa(
353 self.wxstring(req, "mds") + " <sup>[5]</sup>",
354 get_yes_no(req, self.is_mds()),
355 ),
356 ods=tr_qa(
357 self.wxstring(req, "ods") + " <sup>[6]</sup>",
358 get_yes_no(req, self.is_ods()),
359 ),
360 q_a=q_a,
361 )
362 return h
364 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
365 procedure = req.snomed(
366 SnomedLookup.PHQ9_PROCEDURE_DEPRESSION_SCREENING
367 )
368 codes = [SnomedExpression(procedure)]
369 if self.is_complete():
370 scale = req.snomed(SnomedLookup.PHQ9_SCALE)
371 score = req.snomed(SnomedLookup.PHQ9_SCORE)
372 screen_negative = req.snomed(
373 SnomedLookup.PHQ9_FINDING_NEGATIVE_SCREENING_FOR_DEPRESSION
374 )
375 screen_positive = req.snomed(
376 SnomedLookup.PHQ9_FINDING_POSITIVE_SCREENING_FOR_DEPRESSION
377 )
378 if self.is_mds() or self.is_ods():
379 # Threshold debatable, but if you have "other depressive
380 # syndrome", it seems wrong to say you've screened negative for
381 # depression.
382 procedure_result = screen_positive
383 else:
384 procedure_result = screen_negative
385 codes.append(SnomedExpression(scale, {score: self.total_score()}))
386 codes.append(SnomedExpression(procedure_result))
387 return codes
389 def get_fhir_questionnaire(
390 self, req: "CamcopsRequest"
391 ) -> List[FHIRAnsweredQuestion]:
392 items = [] # type: List[FHIRAnsweredQuestion]
394 main_options = {} # type: Dict[int, str]
395 for index in range(4):
396 main_options[index] = self.wxstring(req, f"a{index}")
397 for q_field in self.MAIN_QUESTIONS:
398 items.append(
399 FHIRAnsweredQuestion(
400 qname=q_field,
401 qtext=self.xstring(req, q_field),
402 qtype=FHIRQuestionType.CHOICE,
403 answer_type=FHIRAnswerType.INTEGER,
404 answer=getattr(self, q_field),
405 answer_options=main_options,
406 )
407 )
409 q10_options = {}
410 for index in range(4):
411 q10_options[index] = self.wxstring(req, f"fa{index}")
412 items.append(
413 FHIRAnsweredQuestion(
414 qname="q10",
415 qtext="10. " + self.xstring(req, "finalq"),
416 qtype=FHIRQuestionType.CHOICE,
417 answer_type=FHIRAnswerType.INTEGER,
418 answer=self.q10,
419 answer_options=q10_options,
420 )
421 )
423 return items