Coverage for tasks/phq9.py: 42%

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

30import logging 

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

32 

33from cardinal_pythonlib.stringfunc import strseq 

34from sqlalchemy.ext.declarative import DeclarativeMeta 

35from sqlalchemy.sql.sqltypes import Boolean, Integer 

36 

37from camcops_server.cc_modules.cc_constants import CssClass 

38from camcops_server.cc_modules.cc_ctvinfo import CtvInfo, CTV_INCOMPLETE 

39from camcops_server.cc_modules.cc_db import add_multiple_columns 

40from camcops_server.cc_modules.cc_fhir import ( 

41 FHIRAnsweredQuestion, 

42 FHIRAnswerType, 

43 FHIRQuestionType, 

44) 

45from camcops_server.cc_modules.cc_html import answer, get_yes_no, tr, tr_qa 

46from camcops_server.cc_modules.cc_request import CamcopsRequest 

47from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup 

48from camcops_server.cc_modules.cc_sqla_coltypes import ( 

49 CamcopsColumn, 

50 SummaryCategoryColType, 

51 ZERO_TO_THREE_CHECKER, 

52) 

53from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

54from camcops_server.cc_modules.cc_task import ( 

55 get_from_dict, 

56 Task, 

57 TaskHasPatientMixin, 

58) 

59from camcops_server.cc_modules.cc_text import SS 

60from camcops_server.cc_modules.cc_trackerhelpers import ( 

61 TrackerAxisTick, 

62 TrackerInfo, 

63 TrackerLabel, 

64) 

65 

66log = logging.getLogger(__name__) 

67 

68 

69# ============================================================================= 

70# PHQ-9 

71# ============================================================================= 

72 

73 

74class Phq9Metaclass(DeclarativeMeta): 

75 # noinspection PyInitNewSignature 

76 def __init__( 

77 cls: Type["Phq9"], 

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 cls.N_MAIN_QUESTIONS, 

87 minimum=0, 

88 maximum=3, 

89 comment_fmt="Q{n} ({s}) (0 not at all - 3 nearly every day)", 

90 comment_strings=[ 

91 "anhedonia", 

92 "mood", 

93 "sleep", 

94 "energy", 

95 "appetite", 

96 "self-esteem/guilt", 

97 "concentration", 

98 "psychomotor", 

99 "death/self-harm", 

100 ], 

101 ) 

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

103 

104 

105class Phq9(TaskHasPatientMixin, Task, metaclass=Phq9Metaclass): 

106 """ 

107 Server implementation of the PHQ9 task. 

108 """ 

109 

110 __tablename__ = "phq9" 

111 shortname = "PHQ-9" 

112 provides_trackers = True 

113 

114 q10 = CamcopsColumn( 

115 "q10", 

116 Integer, 

117 permitted_value_checker=ZERO_TO_THREE_CHECKER, 

118 comment="Q10 (difficulty in activities) (0 not difficult at " 

119 "all - 3 extremely difficult)", 

120 ) 

121 

122 N_MAIN_QUESTIONS = 9 

123 MAX_SCORE_MAIN = 3 * N_MAIN_QUESTIONS 

124 MAIN_QUESTIONS = strseq("q", 1, N_MAIN_QUESTIONS) 

125 

126 @staticmethod 

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

128 _ = req.gettext 

129 return _("Patient Health Questionnaire-9") 

130 

131 def is_complete(self) -> bool: 

132 if self.any_fields_none(self.MAIN_QUESTIONS): 

133 return False 

134 if self.total_score() > 0 and self.q10 is None: 

135 return False 

136 if not self.field_contents_valid(): 

137 return False 

138 return True 

139 

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

141 return [ 

142 TrackerInfo( 

143 value=self.total_score(), 

144 plot_label="PHQ-9 total score (rating depressive symptoms)", 

145 axis_label=f"Score for Q1-9 (out of {self.MAX_SCORE_MAIN})", 

146 axis_min=-0.5, 

147 axis_max=self.MAX_SCORE_MAIN + 0.5, 

148 axis_ticks=[ 

149 TrackerAxisTick(27, "27"), 

150 TrackerAxisTick(25, "25"), 

151 TrackerAxisTick(20, "20"), 

152 TrackerAxisTick(15, "15"), 

153 TrackerAxisTick(10, "10"), 

154 TrackerAxisTick(5, "5"), 

155 TrackerAxisTick(0, "0"), 

156 ], 

157 horizontal_lines=[19.5, 14.5, 9.5, 4.5], 

158 horizontal_labels=[ 

159 TrackerLabel(23, req.sstring(SS.SEVERE)), 

160 TrackerLabel(17, req.sstring(SS.MODERATELY_SEVERE)), 

161 TrackerLabel(12, req.sstring(SS.MODERATE)), 

162 TrackerLabel(7, req.sstring(SS.MILD)), 

163 TrackerLabel(2.25, req.sstring(SS.NONE)), 

164 ], 

165 ) 

166 ] 

167 

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

169 if not self.is_complete(): 

170 return CTV_INCOMPLETE 

171 return [ 

172 CtvInfo( 

173 content=( 

174 f"PHQ-9 total score " 

175 f"{self.total_score()}/{self.MAX_SCORE_MAIN} " 

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

177 ) 

178 ) 

179 ] 

180 

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

182 return self.standard_task_summary_fields() + [ 

183 SummaryElement( 

184 name="total", 

185 coltype=Integer(), 

186 value=self.total_score(), 

187 comment=f"Total score (/{self.MAX_SCORE_MAIN})", 

188 ), 

189 SummaryElement( 

190 name="n_core", 

191 coltype=Integer(), 

192 value=self.n_core(), 

193 comment="Number of core symptoms", 

194 ), 

195 SummaryElement( 

196 name="n_other", 

197 coltype=Integer(), 

198 value=self.n_other(), 

199 comment="Number of other symptoms", 

200 ), 

201 SummaryElement( 

202 name="n_total", 

203 coltype=Integer(), 

204 value=self.n_total(), 

205 comment="Total number of symptoms", 

206 ), 

207 SummaryElement( 

208 name="is_mds", 

209 coltype=Boolean(), 

210 value=self.is_mds(), 

211 comment="PHQ9 major depressive syndrome?", 

212 ), 

213 SummaryElement( 

214 name="is_ods", 

215 coltype=Boolean(), 

216 value=self.is_ods(), 

217 comment="PHQ9 other depressive syndrome?", 

218 ), 

219 SummaryElement( 

220 name="severity", 

221 coltype=SummaryCategoryColType, 

222 value=self.severity(req), 

223 comment="PHQ9 depression severity", 

224 ), 

225 ] 

226 

227 def total_score(self) -> int: 

228 return self.sum_fields(self.MAIN_QUESTIONS) 

229 

230 def one_if_q_ge(self, qnum: int, threshold: int) -> int: 

231 value = getattr(self, "q" + str(qnum)) 

232 return 1 if value is not None and value >= threshold else 0 

233 

234 def n_core(self) -> int: 

235 return self.one_if_q_ge(1, 2) + self.one_if_q_ge(2, 2) 

236 

237 def n_other(self) -> int: 

238 return ( 

239 self.one_if_q_ge(3, 2) 

240 + self.one_if_q_ge(4, 2) 

241 + self.one_if_q_ge(5, 2) 

242 + self.one_if_q_ge(6, 2) 

243 + self.one_if_q_ge(7, 2) 

244 + self.one_if_q_ge(8, 2) 

245 + self.one_if_q_ge(9, 1) 

246 ) # suicidality 

247 # suicidality counted whenever present 

248 

249 def n_total(self) -> int: 

250 return self.n_core() + self.n_other() 

251 

252 def is_mds(self) -> bool: 

253 return self.n_core() >= 1 and self.n_total() >= 5 

254 

255 def is_ods(self) -> bool: 

256 return self.n_core() >= 1 and 2 <= self.n_total() <= 4 

257 

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

259 total = self.total_score() 

260 if total >= 20: 

261 return req.sstring(SS.SEVERE) 

262 elif total >= 15: 

263 return req.sstring(SS.MODERATELY_SEVERE) 

264 elif total >= 10: 

265 return req.sstring(SS.MODERATE) 

266 elif total >= 5: 

267 return req.sstring(SS.MILD) 

268 else: 

269 return req.sstring(SS.NONE) 

270 

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

272 main_dict = { 

273 None: None, 

274 0: "0 — " + self.wxstring(req, "a0"), 

275 1: "1 — " + self.wxstring(req, "a1"), 

276 2: "2 — " + self.wxstring(req, "a2"), 

277 3: "3 — " + self.wxstring(req, "a3"), 

278 } 

279 q10_dict = { 

280 None: None, 

281 0: "0 — " + self.wxstring(req, "fa0"), 

282 1: "1 — " + self.wxstring(req, "fa1"), 

283 2: "2 — " + self.wxstring(req, "fa2"), 

284 3: "3 — " + self.wxstring(req, "fa3"), 

285 } 

286 q_a = "" 

287 for i in range(1, self.N_MAIN_QUESTIONS + 1): 

288 nstr = str(i) 

289 q_a += tr_qa( 

290 self.wxstring(req, "q" + nstr), 

291 get_from_dict(main_dict, getattr(self, "q" + nstr)), 

292 ) 

293 q_a += tr_qa( 

294 "10. " + self.wxstring(req, "finalq"), 

295 get_from_dict(q10_dict, self.q10), 

296 ) 

297 

298 h = """ 

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

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

301 {tr_is_complete} 

302 {total_score} 

303 {depression_severity} 

304 {n_symptoms} 

305 {mds} 

306 {ods} 

307 </table> 

308 </div> 

309 <div class="{CssClass.EXPLANATION}"> 

310 Ratings are over the last 2 weeks. 

311 </div> 

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

313 <tr> 

314 <th width="60%">Question</th> 

315 <th width="40%">Answer</th> 

316 </tr> 

317 {q_a} 

318 </table> 

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

320 [1] Sum for questions 1–9. 

321 [2] Total score ≥20 severe, ≥15 moderately severe, 

322 ≥10 moderate, ≥5 mild, &lt;5 none. 

323 [3] Number of questions 1–2 rated ≥2. 

324 [4] Number of questions 3–8 rated ≥2, or question 9 

325 rated ≥1. 

326 [5] ≥1 core symptom and ≥5 total symptoms (as per 

327 DSM-IV-TR page 356). 

328 [6] ≥1 core symptom and 2–4 total symptoms (as per 

329 DSM-IV-TR page 775). 

330 </div> 

331 """.format( 

332 CssClass=CssClass, 

333 tr_is_complete=self.get_is_complete_tr(req), 

334 total_score=tr( 

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

336 answer(self.total_score()) + f" / {self.MAX_SCORE_MAIN}", 

337 ), 

338 depression_severity=tr_qa( 

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

340 self.severity(req), 

341 ), 

342 n_symptoms=tr( 

343 "Number of symptoms: core <sup>[3]</sup>, other " 

344 "<sup>[4]</sup>, total", 

345 answer(self.n_core()) 

346 + "/2, " 

347 + answer(self.n_other()) 

348 + "/7, " 

349 + answer(self.n_total()) 

350 + "/9", 

351 ), 

352 mds=tr_qa( 

353 self.wxstring(req, "mds") + " <sup>[5]</sup>", 

354 get_yes_no(req, self.is_mds()), 

355 ), 

356 ods=tr_qa( 

357 self.wxstring(req, "ods") + " <sup>[6]</sup>", 

358 get_yes_no(req, self.is_ods()), 

359 ), 

360 q_a=q_a, 

361 ) 

362 return h 

363 

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

365 procedure = req.snomed( 

366 SnomedLookup.PHQ9_PROCEDURE_DEPRESSION_SCREENING 

367 ) 

368 codes = [SnomedExpression(procedure)] 

369 if self.is_complete(): 

370 scale = req.snomed(SnomedLookup.PHQ9_SCALE) 

371 score = req.snomed(SnomedLookup.PHQ9_SCORE) 

372 screen_negative = req.snomed( 

373 SnomedLookup.PHQ9_FINDING_NEGATIVE_SCREENING_FOR_DEPRESSION 

374 ) 

375 screen_positive = req.snomed( 

376 SnomedLookup.PHQ9_FINDING_POSITIVE_SCREENING_FOR_DEPRESSION 

377 ) 

378 if self.is_mds() or self.is_ods(): 

379 # Threshold debatable, but if you have "other depressive 

380 # syndrome", it seems wrong to say you've screened negative for 

381 # depression. 

382 procedure_result = screen_positive 

383 else: 

384 procedure_result = screen_negative 

385 codes.append(SnomedExpression(scale, {score: self.total_score()})) 

386 codes.append(SnomedExpression(procedure_result)) 

387 return codes 

388 

389 def get_fhir_questionnaire( 

390 self, req: "CamcopsRequest" 

391 ) -> List[FHIRAnsweredQuestion]: 

392 items = [] # type: List[FHIRAnsweredQuestion] 

393 

394 main_options = {} # type: Dict[int, str] 

395 for index in range(4): 

396 main_options[index] = self.wxstring(req, f"a{index}") 

397 for q_field in self.MAIN_QUESTIONS: 

398 items.append( 

399 FHIRAnsweredQuestion( 

400 qname=q_field, 

401 qtext=self.xstring(req, q_field), 

402 qtype=FHIRQuestionType.CHOICE, 

403 answer_type=FHIRAnswerType.INTEGER, 

404 answer=getattr(self, q_field), 

405 answer_options=main_options, 

406 ) 

407 ) 

408 

409 q10_options = {} 

410 for index in range(4): 

411 q10_options[index] = self.wxstring(req, f"fa{index}") 

412 items.append( 

413 FHIRAnsweredQuestion( 

414 qname="q10", 

415 qtext="10. " + self.xstring(req, "finalq"), 

416 qtype=FHIRQuestionType.CHOICE, 

417 answer_type=FHIRAnswerType.INTEGER, 

418 answer=self.q10, 

419 answer_options=q10_options, 

420 ) 

421 ) 

422 

423 return items