Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/hamd.py 

5 

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

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

12 CamCOPS is free software: you can redistribute it and/or modify 

13 it under the terms of the GNU General Public License as published by 

14 the Free Software Foundation, either version 3 of the License, or 

15 (at your option) any later version. 

16 

17 CamCOPS is distributed in the hope that it will be useful, 

18 but WITHOUT ANY WARRANTY; without even the implied warranty of 

19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

20 GNU General Public License for more details. 

21 

22 You should have received a copy of the GNU General Public License 

23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

24 

25=============================================================================== 

26 

27""" 

28 

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

30 

31from cardinal_pythonlib.stringfunc import strseq 

32from sqlalchemy.ext.declarative import DeclarativeMeta 

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 CamcopsColumn, 

43 SummaryCategoryColType, 

44 ZERO_TO_ONE_CHECKER, 

45 ZERO_TO_TWO_CHECKER, 

46 ZERO_TO_THREE_CHECKER, 

47) 

48from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

49from camcops_server.cc_modules.cc_task import ( 

50 get_from_dict, 

51 Task, 

52 TaskHasClinicianMixin, 

53 TaskHasPatientMixin, 

54) 

55from camcops_server.cc_modules.cc_text import SS 

56from camcops_server.cc_modules.cc_trackerhelpers import ( 

57 TrackerInfo, 

58 TrackerLabel, 

59) 

60 

61 

62# ============================================================================= 

63# HAM-D 

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

65 

66MAX_SCORE = ( 

67 4 * 15 - # Q1-15 scored 0-5 

68 (2 * 6) + # except Q4-6, 12-14 scored 0-2 

69 2 * 2 # Q16-17 

70) # ... and not scored beyond Q17... total 52 

71 

72 

73class HamdMetaclass(DeclarativeMeta): 

74 # noinspection PyInitNewSignature 

75 def __init__(cls: Type['Hamd'], 

76 name: str, 

77 bases: Tuple[Type, ...], 

78 classdict: Dict[str, Any]) -> None: 

79 add_multiple_columns( 

80 cls, "q", 1, 15, 

81 comment_fmt="Q{n}, {s} (scored 0-4, except 0-2 for " 

82 "Q4-6/12-14, higher worse)", 

83 minimum=0, maximum=4, # amended below 

84 comment_strings=[ 

85 "depressed mood", "guilt", "suicide", "early insomnia", 

86 "middle insomnia", "late insomnia", "work/activities", 

87 "psychomotor retardation", "agitation", 

88 "anxiety, psychological", "anxiety, somatic", 

89 "somatic symptoms, gastointestinal", 

90 "somatic symptoms, general", "genital symptoms", 

91 "hypochondriasis" 

92 ] 

93 ) 

94 add_multiple_columns( 

95 cls, "q", 19, 21, 

96 comment_fmt="Q{n} (not scored), {s} (0-4 for Q19, " 

97 "0-3 for Q20, 0-2 for Q21, higher worse)", 

98 minimum=0, maximum=4, # below 

99 comment_strings=["depersonalization/derealization", 

100 "paranoid symptoms", 

101 "obsessional/compulsive symptoms"] 

102 ) 

103 # Now fix the wrong bits. Hardly elegant! 

104 for qnum in [4, 5, 6, 12, 13, 14, 21]: 

105 qname = "q" + str(qnum) 

106 col = getattr(cls, qname) 

107 col.set_permitted_value_checker(ZERO_TO_TWO_CHECKER) 

108 # noinspection PyUnresolvedReferences 

109 cls.q20.set_permitted_value_checker(ZERO_TO_THREE_CHECKER) 

110 

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

112 

113 

114class Hamd(TaskHasPatientMixin, TaskHasClinicianMixin, Task, 

115 metaclass=HamdMetaclass): 

116 """ 

117 Server implementation of the HAM-D task. 

118 """ 

119 __tablename__ = "hamd" 

120 shortname = "HAM-D" 

121 provides_trackers = True 

122 

123 NSCOREDQUESTIONS = 17 

124 NQUESTIONS = 21 

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

126 "whichq16", "q16a", "q16b", "q17", "q18a", "q18b" 

127 ] 

128 

129 whichq16 = CamcopsColumn( 

130 "whichq16", Integer, 

131 permitted_value_checker=ZERO_TO_ONE_CHECKER, 

132 comment="Method of assessing weight loss (0 = A, by history; " 

133 "1 = B, by measured change)" 

134 ) 

135 q16a = CamcopsColumn( 

136 "q16a", Integer, 

137 permitted_value_checker=ZERO_TO_THREE_CHECKER, 

138 comment="Q16A, weight loss, by history (0 none - 2 definite," 

139 " or 3 not assessed [not scored])" 

140 ) 

141 q16b = CamcopsColumn( 

142 "q16b", Integer, 

143 permitted_value_checker=ZERO_TO_THREE_CHECKER, 

144 comment="Q16B, weight loss, by measurement (0 none - " 

145 "2 more than 2lb, or 3 not assessed [not scored])" 

146 ) 

147 q17 = CamcopsColumn( 

148 "q17", Integer, 

149 permitted_value_checker=ZERO_TO_TWO_CHECKER, 

150 comment="Q17, lack of insight (0-2, higher worse)" 

151 ) 

152 q18a = CamcopsColumn( 

153 "q18a", Integer, 

154 permitted_value_checker=ZERO_TO_TWO_CHECKER, 

155 comment="Q18A (not scored), diurnal variation, presence " 

156 "(0 none, 1 worse AM, 2 worse PM)" 

157 ) 

158 q18b = CamcopsColumn( 

159 "q18b", Integer, 

160 permitted_value_checker=ZERO_TO_TWO_CHECKER, 

161 comment="Q18B (not scored), diurnal variation, severity " 

162 "(0-2, higher more severe)" 

163 ) 

164 

165 @staticmethod 

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

167 _ = req.gettext 

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

169 

170 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]: 

171 return [TrackerInfo( 

172 value=self.total_score(), 

173 plot_label="HAM-D total score", 

174 axis_label=f"Total score (out of {MAX_SCORE})", 

175 axis_min=-0.5, 

176 axis_max=MAX_SCORE + 0.5, 

177 horizontal_lines=[22.5, 19.5, 14.5, 7.5], 

178 horizontal_labels=[ 

179 TrackerLabel(25, self.wxstring(req, "severity_verysevere")), 

180 TrackerLabel(21, self.wxstring(req, "severity_severe")), 

181 TrackerLabel(17, self.wxstring(req, "severity_moderate")), 

182 TrackerLabel(11, self.wxstring(req, "severity_mild")), 

183 TrackerLabel(3.75, self.wxstring(req, "severity_none")), 

184 ] 

185 )] 

186 

187 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]: 

188 if not self.is_complete(): 

189 return CTV_INCOMPLETE 

190 return [CtvInfo(content=( 

191 f"HAM-D total score {self.total_score()}/{MAX_SCORE} " 

192 f"({self.severity(req)})" 

193 ))] 

194 

195 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]: 

196 return self.standard_task_summary_fields() + [ 

197 SummaryElement(name="total", 

198 coltype=Integer(), 

199 value=self.total_score(), 

200 comment=f"Total score (/{MAX_SCORE})"), 

201 SummaryElement(name="severity", 

202 coltype=SummaryCategoryColType, 

203 value=self.severity(req), 

204 comment="Severity"), 

205 ] 

206 

207 # noinspection PyUnresolvedReferences 

208 def is_complete(self) -> bool: 

209 if not self.field_contents_valid(): 

210 return False 

211 if self.q1 is None or self.q9 is None or self.q10 is None: 

212 return False 

213 if self.q1 == 0: 

214 # Special limited-information completeness 

215 return True 

216 if self.q2 is not None and self.q3 is not None \ 

217 and (self.q2 + self.q3 == 0): 

218 # Special limited-information completeness 

219 return True 

220 # Otherwise, any null values cause problems 

221 if self.whichq16 is None: 

222 return False 

223 for i in range(1, self.NSCOREDQUESTIONS + 1): 

224 if i == 16: 

225 if (self.whichq16 == 0 and self.q16a is None) \ 

226 or (self.whichq16 == 1 and self.q16b is None): 

227 return False 

228 else: 

229 if getattr(self, "q" + str(i)) is None: 

230 return False 

231 return True 

232 

233 def total_score(self) -> int: 

234 total = 0 

235 for i in range(1, self.NSCOREDQUESTIONS + 1): 

236 if i == 16: 

237 relevant_field = "q16a" if self.whichq16 == 0 else "q16b" 

238 score = self.sum_fields([relevant_field]) 

239 if score != 3: # ... a value that's ignored 

240 total += score 

241 else: 

242 total += self.sum_fields(["q" + str(i)]) 

243 return total 

244 

245 def severity(self, req: CamcopsRequest) -> str: 

246 score = self.total_score() 

247 if score >= 23: 

248 return self.wxstring(req, "severity_verysevere") 

249 elif score >= 19: 

250 return self.wxstring(req, "severity_severe") 

251 elif score >= 14: 

252 return self.wxstring(req, "severity_moderate") 

253 elif score >= 8: 

254 return self.wxstring(req, "severity_mild") 

255 else: 

256 return self.wxstring(req, "severity_none") 

257 

258 def get_task_html(self, req: CamcopsRequest) -> str: 

259 score = self.total_score() 

260 severity = self.severity(req) 

261 task_field_list_for_display = ( 

262 strseq("q", 1, 15) + 

263 [ 

264 "whichq16", 

265 "q16a" if self.whichq16 == 0 else "q16b", # funny one 

266 "q17", 

267 "q18a", 

268 "q18b" 

269 ] + 

270 strseq("q", 19, 21) 

271 ) 

272 answer_dicts_dict = {} 

273 for q in task_field_list_for_display: 

274 d = {None: None} 

275 for option in range(0, 5): 

276 if (q == "q4" or q == "q5" or q == "q6" or q == "q12" or 

277 q == "q13" or q == "q14" or q == "q17" or 

278 q == "q18" or q == "q21") and option > 2: 

279 continue 

280 d[option] = self.wxstring(req, "" + q + "_option" + str(option)) 

281 answer_dicts_dict[q] = d 

282 q_a = "" 

283 for q in task_field_list_for_display: 

284 if q == "whichq16": 

285 qstr = self.wxstring(req, "whichq16_title") 

286 else: 

287 if q == "q16a" or q == "q16b": 

288 rangestr = " <sup>range 0–2; ‘3’ not scored</sup>" 

289 else: 

290 col = getattr(self.__class__, q) # type: CamcopsColumn 

291 rangestr = " <sup>range {}–{}</sup>".format( 

292 col.permitted_value_checker.minimum, 

293 col.permitted_value_checker.maximum 

294 ) 

295 qstr = self.wxstring(req, "" + q + "_s") + rangestr 

296 q_a += tr_qa(qstr, get_from_dict(answer_dicts_dict[q], 

297 getattr(self, q))) 

298 return """ 

299 <div class="{CssClass.SUMMARY}"> 

300 <table class="{CssClass.SUMMARY}"> 

301 {tr_is_complete} 

302 {total_score} 

303 {severity} 

304 </table> 

305 </div> 

306 <table class="{CssClass.TASKDETAIL}"> 

307 <tr> 

308 <th width="40%">Question</th> 

309 <th width="60%">Answer</th> 

310 </tr> 

311 {q_a} 

312 </table> 

313 <div class="{CssClass.FOOTNOTES}"> 

314 [1] Only Q1–Q17 scored towards the total. 

315 Re Q16: values of ‘3’ (‘not assessed’) are not actively 

316 scored, after e.g. Guy W (1976) <i>ECDEU Assessment Manual 

317 for Psychopharmacology, revised</i>, pp. 180–192, esp. 

318 pp. 187, 189 

319 (https://archive.org/stream/ecdeuassessmentm1933guyw). 

320 [2] ≥23 very severe, ≥19 severe, ≥14 moderate, 

321 ≥8 mild, &lt;8 none. 

322 </div> 

323 """.format( 

324 CssClass=CssClass, 

325 tr_is_complete=self.get_is_complete_tr(req), 

326 total_score=tr( 

327 req.sstring(SS.TOTAL_SCORE) + " <sup>[1]</sup>", 

328 answer(score) + " / {}".format(MAX_SCORE) 

329 ), 

330 severity=tr_qa( 

331 self.wxstring(req, "severity") + " <sup>[2]</sup>", 

332 severity 

333 ), 

334 q_a=q_a, 

335 ) 

336 

337 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]: 

338 codes = [SnomedExpression(req.snomed(SnomedLookup.HAMD_PROCEDURE_ASSESSMENT))] # noqa 

339 if self.is_complete(): 

340 codes.append(SnomedExpression( 

341 req.snomed(SnomedLookup.HAMD_SCALE), 

342 { 

343 req.snomed(SnomedLookup.HAMD_SCORE): self.total_score(), 

344 } 

345 )) 

346 return codes