Coverage for tasks/phq15.py: 43%

1"""

2 camcops_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

28 from typing import Any, cast, List, Optional, Type

29

30 from cardinal_pythonlib.stringfunc import strseq

31 from sqlalchemy.sql.sqltypes import Integer

32

33 from camcops_server.cc_modules.cc_constants import CssClass

34 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

35 from camcops_server.cc_modules.cc_db import add_multiple_columns

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

37 from camcops_server.cc_modules.cc_request import CamcopsRequest

38 from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup

39 from camcops_server.cc_modules.cc_sqla_coltypes import SummaryCategoryColType

40 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

41 from camcops_server.cc_modules.cc_task import (

42 get_from_dict,

43 Task,

44 TaskHasPatientMixin,

45)

46 from camcops_server.cc_modules.cc_text import SS

47 from camcops_server.cc_modules.cc_trackerhelpers import (

48 TrackerInfo,

49 TrackerLabel,

50)

51

52

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

54# PHQ-15

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

56

57

58 class 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