Coverage for tasks/hamd.py: 42%

120 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 14:23 +0100

1""" 

2camcops_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 

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

29 

30from cardinal_pythonlib.stringfunc import strseq 

31from sqlalchemy.orm import Mapped 

32from sqlalchemy.sql.schema import Column 

33from sqlalchemy.sql.sqltypes import Integer 

34 

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) 

62 

63 

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

65# HAM-D 

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

67 

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 

73 

74 

75class 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