Coverage for tasks/phq15.py: 43%

87 statements  

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

1""" 

2camcops_server/tasks/phq15.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.sql.sqltypes import Integer 

32 

33from camcops_server.cc_modules.cc_constants import CssClass 

34from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

35from camcops_server.cc_modules.cc_db import add_multiple_columns 

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

37from camcops_server.cc_modules.cc_request import CamcopsRequest 

38from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup 

39from camcops_server.cc_modules.cc_sqla_coltypes import SummaryCategoryColType 

40from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

41from camcops_server.cc_modules.cc_task import ( 

42 get_from_dict, 

43 Task, 

44 TaskHasPatientMixin, 

45) 

46from camcops_server.cc_modules.cc_text import SS 

47from camcops_server.cc_modules.cc_trackerhelpers import ( 

48 TrackerInfo, 

49 TrackerLabel, 

50) 

51 

52 

53# ============================================================================= 

54# PHQ-15 

55# ============================================================================= 

56 

57 

58class Phq15( # type: ignore[misc] 

59 TaskHasPatientMixin, 

60 Task, 

61): 

62 """ 

63 Server implementation of the PHQ-15 task. 

64 """ 

65 

66 __tablename__ = "phq15" 

67 shortname = "PHQ-15" 

68 provides_trackers = True 

69 

70 NQUESTIONS = 15 

71 MAX_TOTAL = 30 

72 

73 @classmethod 

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

75 add_multiple_columns( 

76 cls, 

77 "q", 

78 1, 

79 cls.NQUESTIONS, 

80 minimum=0, 

81 maximum=2, 

82 comment_fmt="Q{n} ({s}) (0 not bothered at all - " 

83 "2 bothered a lot)", 

84 comment_strings=[ 

85 "stomach pain", 

86 "back pain", 

87 "limb/joint pain", 

88 "F - menstrual", 

89 "headaches", 

90 "chest pain", 

91 "dizziness", 

92 "fainting", 

93 "palpitations", 

94 "breathless", 

95 "sex", 

96 "constipation/diarrhoea", 

97 "nausea/indigestion", 

98 "energy", 

99 "sleep", 

100 ], 

101 ) 

102 

103 ONE_TO_THREE = strseq("q", 1, 3) 

104 FIVE_TO_END = strseq("q", 5, NQUESTIONS) 

105 TASK_FIELDS = strseq("q", 1, NQUESTIONS) 

106 

107 @staticmethod 

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

109 _ = req.gettext 

110 return _("Patient Health Questionnaire-15") 

111 

112 # noinspection PyUnresolvedReferences 

113 def is_complete(self) -> bool: 

114 if not self.field_contents_valid(): 

115 return False 

116 if self.any_fields_none(self.ONE_TO_THREE): 

117 return False 

118 if self.any_fields_none(self.FIVE_TO_END): 

119 return False 

120 if self.is_female(): 

121 return self.q4 is not None # type: ignore[attr-defined] 

122 else: 

123 return True 

124 

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

126 return [ 

127 TrackerInfo( 

128 value=self.total_score(), 

129 plot_label="PHQ-15 total score (rating somatic symptoms)", 

130 axis_label=f"Score for Q1-15 (out of {self.MAX_TOTAL})", 

131 axis_min=-0.5, 

132 axis_max=self.MAX_TOTAL + 0.5, 

133 horizontal_lines=[14.5, 9.5, 4.5], 

134 horizontal_labels=[ 

135 TrackerLabel(22, req.sstring(SS.SEVERE)), 

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

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

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

139 ], 

140 ) 

141 ] 

142 

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

144 if not self.is_complete(): 

145 return CTV_INCOMPLETE 

146 return [ 

147 CtvInfo( 

148 content=( 

149 f"PHQ-15 total score " 

150 f"{self.total_score()}/{self.MAX_TOTAL} " 

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

152 ) 

153 ) 

154 ] 

155 

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

157 return self.standard_task_summary_fields() + [ 

158 SummaryElement( 

159 name="total", 

160 coltype=Integer(), 

161 value=self.total_score(), 

162 comment=f"Total score (/{self.MAX_TOTAL})", 

163 ), 

164 SummaryElement( 

165 name="severity", 

166 coltype=SummaryCategoryColType, 

167 value=self.severity(req), 

168 comment="Severity", 

169 ), 

170 ] 

171 

172 def total_score(self) -> int: 

173 return cast(int, self.sum_fields(self.TASK_FIELDS)) 

174 

175 def num_severe(self) -> int: 

176 n = 0 

177 for i in range(1, self.NQUESTIONS + 1): 

178 value = getattr(self, "q" + str(i)) 

179 if value is not None and value >= 2: 

180 n += 1 

181 return n 

182 

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

184 score = self.total_score() 

185 if score >= 15: 

186 return req.sstring(SS.SEVERE) 

187 elif score >= 10: 

188 return req.sstring(SS.MODERATE) 

189 elif score >= 5: 

190 return req.sstring(SS.MILD) 

191 else: 

192 return req.sstring(SS.NONE) 

193 

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

195 score = self.total_score() 

196 nsevere = self.num_severe() 

197 somatoform_likely = nsevere >= 3 

198 severity = self.severity(req) 

199 answer_dict: dict[Optional[int], Optional[str]] = {None: None} 

200 for option in range(0, 3): 

201 answer_dict[option] = ( 

202 str(option) + " – " + self.wxstring(req, "a" + str(option)) 

203 ) 

204 q_a = "" 

205 for q in range(1, self.NQUESTIONS + 1): 

206 q_a += tr_qa( 

207 self.wxstring(req, "q" + str(q)), 

208 get_from_dict(answer_dict, getattr(self, "q" + str(q))), 

209 ) 

210 h = """ 

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

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

213 {tr_is_complete} 

214 {total_score} 

215 {n_severe_symptoms} 

216 {exceeds_somatoform_cutoff} 

217 {symptom_severity} 

218 </table> 

219 </div> 

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

221 <tr> 

222 <th width="70%">Question</th> 

223 <th width="30%">Answer</th> 

224 </tr> 

225 {q_a} 

226 </table> 

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

228 [1] In males, maximum score is actually 28. 

229 [2] Questions with scores ≥2 are considered severe. 

230 [3] ≥3 severe symptoms. 

231 [4] Total score ≥15 severe, ≥10 moderate, ≥5 mild, 

232 otherwise none. 

233 </div> 

234 """.format( 

235 CssClass=CssClass, 

236 tr_is_complete=self.get_is_complete_tr(req), 

237 total_score=tr( 

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

239 answer(score) + f" / {self.MAX_TOTAL}", 

240 ), 

241 n_severe_symptoms=tr_qa( 

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

243 nsevere, 

244 ), 

245 exceeds_somatoform_cutoff=tr_qa( 

246 self.wxstring(req, "exceeds_somatoform_cutoff") 

247 + " <sup>[3]</sup>", 

248 get_yes_no(req, somatoform_likely), 

249 ), 

250 symptom_severity=tr_qa( 

251 self.wxstring(req, "symptom_severity") + " <sup>[4]</sup>", 

252 severity, 

253 ), 

254 q_a=q_a, 

255 ) 

256 return h 

257 

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

259 procedure = req.snomed(SnomedLookup.PHQ15_PROCEDURE) 

260 codes = [SnomedExpression(procedure)] 

261 if self.is_complete(): 

262 scale = req.snomed(SnomedLookup.PHQ15_SCALE) 

263 score = req.snomed(SnomedLookup.PHQ15_SCORE) 

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

265 return codes