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