Coverage for tasks/hamd.py: 42%

119 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/hamd.py 

5 

6=============================================================================== 

7 

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

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

10 

11 This file is part of CamCOPS. 

12 

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. 

17 

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. 

22 

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

25 

26=============================================================================== 

27 

28""" 

29 

30from typing import Any, Dict, List, Tuple, Type 

31 

32from cardinal_pythonlib.stringfunc import strseq 

33from sqlalchemy.ext.declarative import DeclarativeMeta 

34from sqlalchemy.sql.sqltypes import Integer 

35 

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) 

61 

62 

63# ============================================================================= 

64# HAM-D 

65# ============================================================================= 

66 

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 

72 

73 

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) 

131 

132 super().__init__(name, bases, classdict) 

133 

134 

135class Hamd( 

136 TaskHasPatientMixin, TaskHasClinicianMixin, Task, metaclass=HamdMetaclass 

137): 

138 """ 

139 Server implementation of the HAM-D task. 

140 """ 

141 

142 __tablename__ = "hamd" 

143 shortname = "HAM-D" 

144 provides_trackers = True 

145 

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 ] 

156 

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 ) 

198 

199 @staticmethod 

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

201 _ = req.gettext 

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

203 

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 ] 

224 

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 ] 

236 

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 ] 

252 

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 

282 

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 

294 

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

307 

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

396 

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