Coverage for tasks/hamd.py : 42%

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/hamd.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, Tuple, Type
31from cardinal_pythonlib.stringfunc import strseq
32from sqlalchemy.ext.declarative import DeclarativeMeta
33from sqlalchemy.sql.sqltypes import Integer
35from camcops_server.cc_modules.cc_constants import CssClass
36from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
37from camcops_server.cc_modules.cc_db import add_multiple_columns
38from camcops_server.cc_modules.cc_html import answer, tr, tr_qa
39from camcops_server.cc_modules.cc_request import CamcopsRequest
40from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup
41from camcops_server.cc_modules.cc_sqla_coltypes import (
42 CamcopsColumn,
43 SummaryCategoryColType,
44 ZERO_TO_ONE_CHECKER,
45 ZERO_TO_TWO_CHECKER,
46 ZERO_TO_THREE_CHECKER,
47)
48from camcops_server.cc_modules.cc_summaryelement import SummaryElement
49from camcops_server.cc_modules.cc_task import (
50 get_from_dict,
51 Task,
52 TaskHasClinicianMixin,
53 TaskHasPatientMixin,
54)
55from camcops_server.cc_modules.cc_text import SS
56from camcops_server.cc_modules.cc_trackerhelpers import (
57 TrackerInfo,
58 TrackerLabel,
59)
62# =============================================================================
63# HAM-D
64# =============================================================================
66MAX_SCORE = (
67 4 * 15 - # Q1-15 scored 0-5
68 (2 * 6) + # except Q4-6, 12-14 scored 0-2
69 2 * 2 # Q16-17
70) # ... and not scored beyond Q17... total 52
73class HamdMetaclass(DeclarativeMeta):
74 # noinspection PyInitNewSignature
75 def __init__(cls: Type['Hamd'],
76 name: str,
77 bases: Tuple[Type, ...],
78 classdict: Dict[str, Any]) -> None:
79 add_multiple_columns(
80 cls, "q", 1, 15,
81 comment_fmt="Q{n}, {s} (scored 0-4, except 0-2 for "
82 "Q4-6/12-14, higher worse)",
83 minimum=0, maximum=4, # amended below
84 comment_strings=[
85 "depressed mood", "guilt", "suicide", "early insomnia",
86 "middle insomnia", "late insomnia", "work/activities",
87 "psychomotor retardation", "agitation",
88 "anxiety, psychological", "anxiety, somatic",
89 "somatic symptoms, gastointestinal",
90 "somatic symptoms, general", "genital symptoms",
91 "hypochondriasis"
92 ]
93 )
94 add_multiple_columns(
95 cls, "q", 19, 21,
96 comment_fmt="Q{n} (not scored), {s} (0-4 for Q19, "
97 "0-3 for Q20, 0-2 for Q21, higher worse)",
98 minimum=0, maximum=4, # below
99 comment_strings=["depersonalization/derealization",
100 "paranoid symptoms",
101 "obsessional/compulsive symptoms"]
102 )
103 # Now fix the wrong bits. Hardly elegant!
104 for qnum in [4, 5, 6, 12, 13, 14, 21]:
105 qname = "q" + str(qnum)
106 col = getattr(cls, qname)
107 col.set_permitted_value_checker(ZERO_TO_TWO_CHECKER)
108 # noinspection PyUnresolvedReferences
109 cls.q20.set_permitted_value_checker(ZERO_TO_THREE_CHECKER)
111 super().__init__(name, bases, classdict)
114class Hamd(TaskHasPatientMixin, TaskHasClinicianMixin, Task,
115 metaclass=HamdMetaclass):
116 """
117 Server implementation of the HAM-D task.
118 """
119 __tablename__ = "hamd"
120 shortname = "HAM-D"
121 provides_trackers = True
123 NSCOREDQUESTIONS = 17
124 NQUESTIONS = 21
125 TASK_FIELDS = strseq("q", 1, NQUESTIONS) + [
126 "whichq16", "q16a", "q16b", "q17", "q18a", "q18b"
127 ]
129 whichq16 = CamcopsColumn(
130 "whichq16", Integer,
131 permitted_value_checker=ZERO_TO_ONE_CHECKER,
132 comment="Method of assessing weight loss (0 = A, by history; "
133 "1 = B, by measured change)"
134 )
135 q16a = CamcopsColumn(
136 "q16a", Integer,
137 permitted_value_checker=ZERO_TO_THREE_CHECKER,
138 comment="Q16A, weight loss, by history (0 none - 2 definite,"
139 " or 3 not assessed [not scored])"
140 )
141 q16b = CamcopsColumn(
142 "q16b", Integer,
143 permitted_value_checker=ZERO_TO_THREE_CHECKER,
144 comment="Q16B, weight loss, by measurement (0 none - "
145 "2 more than 2lb, or 3 not assessed [not scored])"
146 )
147 q17 = CamcopsColumn(
148 "q17", Integer,
149 permitted_value_checker=ZERO_TO_TWO_CHECKER,
150 comment="Q17, lack of insight (0-2, higher worse)"
151 )
152 q18a = CamcopsColumn(
153 "q18a", Integer,
154 permitted_value_checker=ZERO_TO_TWO_CHECKER,
155 comment="Q18A (not scored), diurnal variation, presence "
156 "(0 none, 1 worse AM, 2 worse PM)"
157 )
158 q18b = CamcopsColumn(
159 "q18b", Integer,
160 permitted_value_checker=ZERO_TO_TWO_CHECKER,
161 comment="Q18B (not scored), diurnal variation, severity "
162 "(0-2, higher more severe)"
163 )
165 @staticmethod
166 def longname(req: "CamcopsRequest") -> str:
167 _ = req.gettext
168 return _("Hamilton Rating Scale for Depression")
170 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
171 return [TrackerInfo(
172 value=self.total_score(),
173 plot_label="HAM-D total score",
174 axis_label=f"Total score (out of {MAX_SCORE})",
175 axis_min=-0.5,
176 axis_max=MAX_SCORE + 0.5,
177 horizontal_lines=[22.5, 19.5, 14.5, 7.5],
178 horizontal_labels=[
179 TrackerLabel(25, self.wxstring(req, "severity_verysevere")),
180 TrackerLabel(21, self.wxstring(req, "severity_severe")),
181 TrackerLabel(17, self.wxstring(req, "severity_moderate")),
182 TrackerLabel(11, self.wxstring(req, "severity_mild")),
183 TrackerLabel(3.75, self.wxstring(req, "severity_none")),
184 ]
185 )]
187 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
188 if not self.is_complete():
189 return CTV_INCOMPLETE
190 return [CtvInfo(content=(
191 f"HAM-D total score {self.total_score()}/{MAX_SCORE} "
192 f"({self.severity(req)})"
193 ))]
195 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
196 return self.standard_task_summary_fields() + [
197 SummaryElement(name="total",
198 coltype=Integer(),
199 value=self.total_score(),
200 comment=f"Total score (/{MAX_SCORE})"),
201 SummaryElement(name="severity",
202 coltype=SummaryCategoryColType,
203 value=self.severity(req),
204 comment="Severity"),
205 ]
207 # noinspection PyUnresolvedReferences
208 def is_complete(self) -> bool:
209 if not self.field_contents_valid():
210 return False
211 if self.q1 is None or self.q9 is None or self.q10 is None:
212 return False
213 if self.q1 == 0:
214 # Special limited-information completeness
215 return True
216 if self.q2 is not None and self.q3 is not None \
217 and (self.q2 + self.q3 == 0):
218 # Special limited-information completeness
219 return True
220 # Otherwise, any null values cause problems
221 if self.whichq16 is None:
222 return False
223 for i in range(1, self.NSCOREDQUESTIONS + 1):
224 if i == 16:
225 if (self.whichq16 == 0 and self.q16a is None) \
226 or (self.whichq16 == 1 and self.q16b is None):
227 return False
228 else:
229 if getattr(self, "q" + str(i)) is None:
230 return False
231 return True
233 def total_score(self) -> int:
234 total = 0
235 for i in range(1, self.NSCOREDQUESTIONS + 1):
236 if i == 16:
237 relevant_field = "q16a" if self.whichq16 == 0 else "q16b"
238 score = self.sum_fields([relevant_field])
239 if score != 3: # ... a value that's ignored
240 total += score
241 else:
242 total += self.sum_fields(["q" + str(i)])
243 return total
245 def severity(self, req: CamcopsRequest) -> str:
246 score = self.total_score()
247 if score >= 23:
248 return self.wxstring(req, "severity_verysevere")
249 elif score >= 19:
250 return self.wxstring(req, "severity_severe")
251 elif score >= 14:
252 return self.wxstring(req, "severity_moderate")
253 elif score >= 8:
254 return self.wxstring(req, "severity_mild")
255 else:
256 return self.wxstring(req, "severity_none")
258 def get_task_html(self, req: CamcopsRequest) -> str:
259 score = self.total_score()
260 severity = self.severity(req)
261 task_field_list_for_display = (
262 strseq("q", 1, 15) +
263 [
264 "whichq16",
265 "q16a" if self.whichq16 == 0 else "q16b", # funny one
266 "q17",
267 "q18a",
268 "q18b"
269 ] +
270 strseq("q", 19, 21)
271 )
272 answer_dicts_dict = {}
273 for q in task_field_list_for_display:
274 d = {None: None}
275 for option in range(0, 5):
276 if (q == "q4" or q == "q5" or q == "q6" or q == "q12" or
277 q == "q13" or q == "q14" or q == "q17" or
278 q == "q18" or q == "q21") and option > 2:
279 continue
280 d[option] = self.wxstring(req, "" + q + "_option" + str(option))
281 answer_dicts_dict[q] = d
282 q_a = ""
283 for q in task_field_list_for_display:
284 if q == "whichq16":
285 qstr = self.wxstring(req, "whichq16_title")
286 else:
287 if q == "q16a" or q == "q16b":
288 rangestr = " <sup>range 0–2; ‘3’ not scored</sup>"
289 else:
290 col = getattr(self.__class__, q) # type: CamcopsColumn
291 rangestr = " <sup>range {}–{}</sup>".format(
292 col.permitted_value_checker.minimum,
293 col.permitted_value_checker.maximum
294 )
295 qstr = self.wxstring(req, "" + q + "_s") + rangestr
296 q_a += tr_qa(qstr, get_from_dict(answer_dicts_dict[q],
297 getattr(self, q)))
298 return """
299 <div class="{CssClass.SUMMARY}">
300 <table class="{CssClass.SUMMARY}">
301 {tr_is_complete}
302 {total_score}
303 {severity}
304 </table>
305 </div>
306 <table class="{CssClass.TASKDETAIL}">
307 <tr>
308 <th width="40%">Question</th>
309 <th width="60%">Answer</th>
310 </tr>
311 {q_a}
312 </table>
313 <div class="{CssClass.FOOTNOTES}">
314 [1] Only Q1–Q17 scored towards the total.
315 Re Q16: values of ‘3’ (‘not assessed’) are not actively
316 scored, after e.g. Guy W (1976) <i>ECDEU Assessment Manual
317 for Psychopharmacology, revised</i>, pp. 180–192, esp.
318 pp. 187, 189
319 (https://archive.org/stream/ecdeuassessmentm1933guyw).
320 [2] ≥23 very severe, ≥19 severe, ≥14 moderate,
321 ≥8 mild, <8 none.
322 </div>
323 """.format(
324 CssClass=CssClass,
325 tr_is_complete=self.get_is_complete_tr(req),
326 total_score=tr(
327 req.sstring(SS.TOTAL_SCORE) + " <sup>[1]</sup>",
328 answer(score) + " / {}".format(MAX_SCORE)
329 ),
330 severity=tr_qa(
331 self.wxstring(req, "severity") + " <sup>[2]</sup>",
332 severity
333 ),
334 q_a=q_a,
335 )
337 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
338 codes = [SnomedExpression(req.snomed(SnomedLookup.HAMD_PROCEDURE_ASSESSMENT))] # noqa
339 if self.is_complete():
340 codes.append(SnomedExpression(
341 req.snomed(SnomedLookup.HAMD_SCALE),
342 {
343 req.snomed(SnomedLookup.HAMD_SCORE): self.total_score(),
344 }
345 ))
346 return codes