Coverage for tasks/phq9.py: 41%

111 statements  

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

1""" 

2camcops_server/tasks/phq9.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 

28import logging 

29from typing import Any, cast, Dict, List, Optional, Type 

30 

31from cardinal_pythonlib.stringfunc import strseq 

32from sqlalchemy.orm import Mapped 

33from sqlalchemy.sql.sqltypes import Boolean, Integer 

34 

35from camcops_server.cc_modules.cc_constants import CssClass 

36from camcops_server.cc_modules.cc_ctvinfo import CtvInfo, CTV_INCOMPLETE 

37from camcops_server.cc_modules.cc_db import add_multiple_columns 

38from camcops_server.cc_modules.cc_fhir import ( 

39 FHIRAnsweredQuestion, 

40 FHIRAnswerType, 

41 FHIRQuestionType, 

42) 

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

44from camcops_server.cc_modules.cc_request import CamcopsRequest 

45from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup 

46from camcops_server.cc_modules.cc_sqla_coltypes import ( 

47 mapped_camcops_column, 

48 SummaryCategoryColType, 

49 ZERO_TO_THREE_CHECKER, 

50) 

51from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

52from camcops_server.cc_modules.cc_task import ( 

53 get_from_dict, 

54 Task, 

55 TaskHasPatientMixin, 

56) 

57from camcops_server.cc_modules.cc_text import SS 

58from camcops_server.cc_modules.cc_trackerhelpers import ( 

59 TrackerAxisTick, 

60 TrackerInfo, 

61 TrackerLabel, 

62) 

63 

64log = logging.getLogger(__name__) 

65 

66 

67# ============================================================================= 

68# PHQ-9 

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

70 

71 

72class Phq9( # type: ignore[misc] 

73 TaskHasPatientMixin, 

74 Task, 

75): 

76 """ 

77 Server implementation of the PHQ9 task. 

78 """ 

79 

80 __tablename__ = "phq9" 

81 shortname = "PHQ-9" 

82 provides_trackers = True 

83 

84 @classmethod 

85 def extend_columns(cls: Type["Phq9"], **kwargs: Any) -> None: 

86 add_multiple_columns( 

87 cls, 

88 "q", 

89 1, 

90 cls.N_MAIN_QUESTIONS, 

91 minimum=0, 

92 maximum=3, 

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

94 comment_strings=[ 

95 "anhedonia", 

96 "mood", 

97 "sleep", 

98 "energy", 

99 "appetite", 

100 "self-esteem/guilt", 

101 "concentration", 

102 "psychomotor", 

103 "death/self-harm", 

104 ], 

105 ) 

106 

107 q10: Mapped[Optional[int]] = mapped_camcops_column( 

108 permitted_value_checker=ZERO_TO_THREE_CHECKER, 

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

110 "all - 3 extremely difficult)", 

111 ) 

112 

113 N_MAIN_QUESTIONS = 9 

114 MAX_SCORE_MAIN = 3 * N_MAIN_QUESTIONS 

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

116 

117 @staticmethod 

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

119 _ = req.gettext 

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

121 

122 def is_complete(self) -> bool: 

123 if self.any_fields_none(self.MAIN_QUESTIONS): 

124 return False 

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

126 return False 

127 if not self.field_contents_valid(): 

128 return False 

129 return True 

130 

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

132 return [ 

133 TrackerInfo( 

134 value=self.total_score(), 

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

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

137 axis_min=-0.5, 

138 axis_max=self.MAX_SCORE_MAIN + 0.5, 

139 axis_ticks=[ 

140 TrackerAxisTick(27, "27"), 

141 TrackerAxisTick(25, "25"), 

142 TrackerAxisTick(20, "20"), 

143 TrackerAxisTick(15, "15"), 

144 TrackerAxisTick(10, "10"), 

145 TrackerAxisTick(5, "5"), 

146 TrackerAxisTick(0, "0"), 

147 ], 

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

149 horizontal_labels=[ 

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

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

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

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

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

155 ], 

156 ) 

157 ] 

158 

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

160 if not self.is_complete(): 

161 return CTV_INCOMPLETE 

162 return [ 

163 CtvInfo( 

164 content=( 

165 f"PHQ-9 total score " 

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

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

168 ) 

169 ) 

170 ] 

171 

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

173 return self.standard_task_summary_fields() + [ 

174 SummaryElement( 

175 name="total", 

176 coltype=Integer(), 

177 value=self.total_score(), 

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

179 ), 

180 SummaryElement( 

181 name="n_core", 

182 coltype=Integer(), 

183 value=self.n_core(), 

184 comment="Number of core symptoms", 

185 ), 

186 SummaryElement( 

187 name="n_other", 

188 coltype=Integer(), 

189 value=self.n_other(), 

190 comment="Number of other symptoms", 

191 ), 

192 SummaryElement( 

193 name="n_total", 

194 coltype=Integer(), 

195 value=self.n_total(), 

196 comment="Total number of symptoms", 

197 ), 

198 SummaryElement( 

199 name="is_mds", 

200 coltype=Boolean(), 

201 value=self.is_mds(), 

202 comment="PHQ9 major depressive syndrome?", 

203 ), 

204 SummaryElement( 

205 name="is_ods", 

206 coltype=Boolean(), 

207 value=self.is_ods(), 

208 comment="PHQ9 other depressive syndrome?", 

209 ), 

210 SummaryElement( 

211 name="severity", 

212 coltype=SummaryCategoryColType, 

213 value=self.severity(req), 

214 comment="PHQ9 depression severity", 

215 ), 

216 ] 

217 

218 def total_score(self) -> int: 

219 return cast(int, self.sum_fields(self.MAIN_QUESTIONS)) 

220 

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

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

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

224 

225 def n_core(self) -> int: 

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

227 

228 def n_other(self) -> int: 

229 return ( 

230 self.one_if_q_ge(3, 2) 

231 + self.one_if_q_ge(4, 2) 

232 + self.one_if_q_ge(5, 2) 

233 + self.one_if_q_ge(6, 2) 

234 + self.one_if_q_ge(7, 2) 

235 + self.one_if_q_ge(8, 2) 

236 + self.one_if_q_ge(9, 1) 

237 ) # suicidality 

238 # suicidality counted whenever present 

239 

240 def n_total(self) -> int: 

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

242 

243 def is_mds(self) -> bool: 

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

245 

246 def is_ods(self) -> bool: 

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

248 

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

250 total = self.total_score() 

251 if total >= 20: 

252 return req.sstring(SS.SEVERE) 

253 elif total >= 15: 

254 return req.sstring(SS.MODERATELY_SEVERE) 

255 elif total >= 10: 

256 return req.sstring(SS.MODERATE) 

257 elif total >= 5: 

258 return req.sstring(SS.MILD) 

259 else: 

260 return req.sstring(SS.NONE) 

261 

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

263 main_dict = { 

264 None: None, 

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

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

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

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

269 } 

270 q10_dict = { 

271 None: None, 

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

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

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

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

276 } 

277 q_a = "" 

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

279 nstr = str(i) 

280 q_a += tr_qa( 

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

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

283 ) 

284 q_a += tr_qa( 

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

286 get_from_dict(q10_dict, self.q10), 

287 ) 

288 

289 h = """ 

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

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

292 {tr_is_complete} 

293 {total_score} 

294 {depression_severity} 

295 {n_symptoms} 

296 {mds} 

297 {ods} 

298 </table> 

299 </div> 

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

301 Ratings are over the last 2 weeks. 

302 </div> 

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

304 <tr> 

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

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

307 </tr> 

308 {q_a} 

309 </table> 

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

311 [1] Sum for questions 1–9. 

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

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

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

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

316 rated ≥1. 

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

318 DSM-IV-TR page 356). 

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

320 DSM-IV-TR page 775). 

321 </div> 

322 """.format( 

323 CssClass=CssClass, 

324 tr_is_complete=self.get_is_complete_tr(req), 

325 total_score=tr( 

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

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

328 ), 

329 depression_severity=tr_qa( 

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

331 self.severity(req), 

332 ), 

333 n_symptoms=tr( 

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

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

336 answer(self.n_core()) 

337 + "/2, " 

338 + answer(self.n_other()) 

339 + "/7, " 

340 + answer(self.n_total()) 

341 + "/9", 

342 ), 

343 mds=tr_qa( 

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

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

346 ), 

347 ods=tr_qa( 

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

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

350 ), 

351 q_a=q_a, 

352 ) 

353 return h 

354 

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

356 procedure = req.snomed( 

357 SnomedLookup.PHQ9_PROCEDURE_DEPRESSION_SCREENING 

358 ) 

359 codes = [SnomedExpression(procedure)] 

360 if self.is_complete(): 

361 scale = req.snomed(SnomedLookup.PHQ9_SCALE) 

362 score = req.snomed(SnomedLookup.PHQ9_SCORE) 

363 screen_negative = req.snomed( 

364 SnomedLookup.PHQ9_FINDING_NEGATIVE_SCREENING_FOR_DEPRESSION 

365 ) 

366 screen_positive = req.snomed( 

367 SnomedLookup.PHQ9_FINDING_POSITIVE_SCREENING_FOR_DEPRESSION 

368 ) 

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

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

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

372 # depression. 

373 procedure_result = screen_positive 

374 else: 

375 procedure_result = screen_negative 

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

377 codes.append(SnomedExpression(procedure_result)) 

378 return codes 

379 

380 def get_fhir_questionnaire( 

381 self, req: "CamcopsRequest" 

382 ) -> List[FHIRAnsweredQuestion]: 

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

384 

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

386 for index in range(4): 

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

388 for q_field in self.MAIN_QUESTIONS: 

389 items.append( 

390 FHIRAnsweredQuestion( 

391 qname=q_field, 

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

393 qtype=FHIRQuestionType.CHOICE, 

394 answer_type=FHIRAnswerType.INTEGER, 

395 answer=getattr(self, q_field), 

396 answer_options=main_options, 

397 ) 

398 ) 

399 

400 q10_options = {} 

401 for index in range(4): 

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

403 items.append( 

404 FHIRAnsweredQuestion( 

405 qname="q10", 

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

407 qtype=FHIRQuestionType.CHOICE, 

408 answer_type=FHIRAnswerType.INTEGER, 

409 answer=self.q10, 

410 answer_options=q10_options, 

411 ) 

412 ) 

413 

414 return items