Coverage for tasks/hamd.py: 42%

1"""

2 camcops_server/tasks/hamd.py

3

4===============================================================================

5

6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.

7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

8

9 This file is part of CamCOPS.

10

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.

15

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.

20

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/>.

23

24===============================================================================

25

26"""

27

28 from typing import Any, cast, List, Optional, Type

29

30 from cardinal_pythonlib.stringfunc import strseq

31 from sqlalchemy.orm import Mapped

32 from sqlalchemy.sql.schema import Column

33 from sqlalchemy.sql.sqltypes import Integer

34

35 from camcops_server.cc_modules.cc_constants import CssClass

36 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

37 from camcops_server.cc_modules.cc_db import add_multiple_columns

38 from camcops_server.cc_modules.cc_html import answer, tr, tr_qa

39 from camcops_server.cc_modules.cc_request import CamcopsRequest

40 from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup

41 from 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)

50 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

51 from camcops_server.cc_modules.cc_task import (

52 get_from_dict,

53 Task,

54 TaskHasClinicianMixin,

55 TaskHasPatientMixin,

56)

57 from camcops_server.cc_modules.cc_text import SS

58 from camcops_server.cc_modules.cc_trackerhelpers import (

59 TrackerInfo,

60 TrackerLabel,

61)

62

63

64# =============================================================================

65# HAM-D

66# =============================================================================

67

68 MAX_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

73

74

75 class Hamd( # type: ignore[misc]

76 TaskHasPatientMixin,

77 TaskHasClinicianMixin,

78 Task,

79):

80 """

81 Server implementation of the HAM-D task.

82 """

83

84 __tablename__ = "hamd"

85 shortname = "HAM-D"

86 provides_trackers = True

87

88 NSCOREDQUESTIONS = 17

89 NQUESTIONS = 21

90

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 )

199

200 TASK_FIELDS = strseq("q", 1, NQUESTIONS) + [

201 "whichq16",

202 "q16a",

203 "q16b",

204 "q17",

205 "q18a",

206 "q18b",

207 ]

208

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 )

238

239 @staticmethod

240 def longname(req: "CamcopsRequest") -> str:

241 _ = req.gettext

242 return _("Hamilton Rating Scale for Depression")

243

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 ]

264

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 ]

276

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 ]

292

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

322

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

334

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")

347

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, &lt;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 )

439

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