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