Coverage for tasks/pcl5.py: 49%

91 statements  

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

1""" 

2camcops_server/tasks/pcl5.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.classes import classproperty 

31from cardinal_pythonlib.stringfunc import strseq 

32from semantic_version import Version 

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_html import ( 

39 answer, 

40 get_yes_no, 

41 subheading_spanning_two_columns, 

42 tr, 

43 tr_qa, 

44) 

45from camcops_server.cc_modules.cc_request import CamcopsRequest 

46 

47from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

48from camcops_server.cc_modules.cc_task import ( 

49 get_from_dict, 

50 Task, 

51 TaskHasPatientMixin, 

52) 

53from camcops_server.cc_modules.cc_text import SS 

54from camcops_server.cc_modules.cc_trackerhelpers import ( 

55 equally_spaced_int, 

56 regular_tracker_axis_ticks_int, 

57 TrackerInfo, 

58 TrackerLabel, 

59) 

60 

61 

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

63# PCL-5 

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

65 

66 

67class Pcl5( # type: ignore[misc] 

68 TaskHasPatientMixin, 

69 Task, 

70): 

71 """ 

72 Server implementation of the PCL-5 task. 

73 """ 

74 

75 __tablename__ = "pcl5" 

76 shortname = "PCL-5" 

77 provides_trackers = True 

78 extrastring_taskname = "pcl5" 

79 N_QUESTIONS = 20 

80 

81 @classmethod 

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

83 add_multiple_columns( 

84 cls, 

85 "q", 

86 1, 

87 cls.N_QUESTIONS, 

88 minimum=0, 

89 maximum=4, 

90 comment_fmt="Q{n} ({s}) (0 not at all - 4 extremely)", 

91 comment_strings=[ 

92 "disturbing memories/thoughts/images", 

93 "disturbing dreams", 

94 "reliving", 

95 "upset at reminders", 

96 "physical reactions to reminders", 

97 "avoid thinking/talking/feelings relating to experience", 

98 "avoid activities/situations because they remind", 

99 "trouble remembering important parts of stressful event", 

100 "strong negative beliefs about self/others/world", 

101 "blaming", 

102 "strong negative emotions", 

103 "loss of interest in previously enjoyed activities", 

104 "feeling distant / cut off from people", 

105 "feeling emotionally numb", 

106 "irritable, angry and/or aggressive", 

107 "risk-taking and/or self-harming behaviour", 

108 "super alert/on guard", 

109 "jumpy/easily startled", 

110 "difficulty concentrating", 

111 "hard to sleep", 

112 ], 

113 ) 

114 

115 SCORED_FIELDS = strseq("q", 1, N_QUESTIONS) 

116 TASK_FIELDS = SCORED_FIELDS # may be overridden 

117 MIN_SCORE = 0 

118 MAX_SCORE = 4 * N_QUESTIONS 

119 

120 @staticmethod 

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

122 _ = req.gettext 

123 return _("PTSD Checklist, DSM-5 version") 

124 

125 # noinspection PyMethodParameters 

126 @classproperty 

127 def minimum_client_version(cls) -> Version: 

128 return Version("2.2.8") 

129 

130 def is_complete(self) -> bool: 

131 return ( 

132 self.all_fields_not_none(self.TASK_FIELDS) 

133 and self.field_contents_valid() 

134 ) 

135 

136 def total_score(self) -> int: 

137 return cast(int, self.sum_fields(self.SCORED_FIELDS)) 

138 

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

140 line_step = 20 

141 preliminary_cutoff = 33 

142 return [ 

143 TrackerInfo( 

144 value=self.total_score(), 

145 plot_label="PCL-5 total score", 

146 axis_label=f"Total score ({self.MIN_SCORE}-{self.MAX_SCORE})", 

147 axis_min=self.MIN_SCORE - 0.5, 

148 axis_max=self.MAX_SCORE + 0.5, 

149 axis_ticks=regular_tracker_axis_ticks_int( 

150 self.MIN_SCORE, self.MAX_SCORE, step=line_step 

151 ), 

152 horizontal_lines=equally_spaced_int( 

153 self.MIN_SCORE + line_step, 

154 self.MAX_SCORE - line_step, 

155 step=line_step, 

156 ) 

157 + [preliminary_cutoff], 

158 horizontal_labels=[ 

159 TrackerLabel( 

160 preliminary_cutoff, 

161 self.wxstring(req, "preliminary_cutoff"), 

162 ) 

163 ], 

164 ) 

165 ] 

166 

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

168 if not self.is_complete(): 

169 return CTV_INCOMPLETE 

170 return [CtvInfo(content=f"PCL-5 total score {self.total_score()}")] 

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.MIN_SCORE}-{self.MAX_SCORE})", 

179 ), 

180 SummaryElement( 

181 name="num_symptomatic", 

182 coltype=Integer(), 

183 value=self.num_symptomatic(), 

184 comment="Total number of symptoms considered symptomatic " 

185 "(meaning scoring 2 or more)", 

186 ), 

187 SummaryElement( 

188 name="num_symptomatic_B", 

189 coltype=Integer(), 

190 value=self.num_symptomatic_b(), 

191 comment="Number of group B symptoms considered symptomatic " 

192 "(meaning scoring 2 or more)", 

193 ), 

194 SummaryElement( 

195 name="num_symptomatic_C", 

196 coltype=Integer(), 

197 value=self.num_symptomatic_c(), 

198 comment="Number of group C symptoms considered symptomatic " 

199 "(meaning scoring 2 or more)", 

200 ), 

201 SummaryElement( 

202 name="num_symptomatic_D", 

203 coltype=Integer(), 

204 value=self.num_symptomatic_d(), 

205 comment="Number of group D symptoms considered symptomatic " 

206 "(meaning scoring 2 or more)", 

207 ), 

208 SummaryElement( 

209 name="num_symptomatic_E", 

210 coltype=Integer(), 

211 value=self.num_symptomatic_e(), 

212 comment="Number of group D symptoms considered symptomatic " 

213 "(meaning scoring 2 or more)", 

214 ), 

215 SummaryElement( 

216 name="ptsd", 

217 coltype=Boolean(), 

218 value=self.ptsd(), 

219 comment="Provisionally meets DSM-5 criteria for PTSD", 

220 ), 

221 ] 

222 

223 def get_num_symptomatic(self, first: int, last: int) -> int: 

224 n = 0 

225 for i in range(first, last + 1): 

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

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

228 n += 1 

229 return n 

230 

231 def num_symptomatic(self) -> int: 

232 return self.get_num_symptomatic(1, self.N_QUESTIONS) 

233 

234 def num_symptomatic_b(self) -> int: 

235 return self.get_num_symptomatic(1, 5) 

236 

237 def num_symptomatic_c(self) -> int: 

238 return self.get_num_symptomatic(6, 7) 

239 

240 def num_symptomatic_d(self) -> int: 

241 return self.get_num_symptomatic(8, 14) 

242 

243 def num_symptomatic_e(self) -> int: 

244 return self.get_num_symptomatic(15, 20) 

245 

246 def ptsd(self) -> bool: 

247 num_symptomatic_b = self.num_symptomatic_b() 

248 num_symptomatic_c = self.num_symptomatic_c() 

249 num_symptomatic_d = self.num_symptomatic_d() 

250 num_symptomatic_e = self.num_symptomatic_e() 

251 return ( 

252 num_symptomatic_b >= 1 

253 and num_symptomatic_c >= 1 

254 and num_symptomatic_d >= 2 

255 and num_symptomatic_e >= 2 

256 ) 

257 

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

259 score = self.total_score() 

260 num_symptomatic = self.num_symptomatic() 

261 num_symptomatic_b = self.num_symptomatic_b() 

262 num_symptomatic_c = self.num_symptomatic_c() 

263 num_symptomatic_d = self.num_symptomatic_d() 

264 num_symptomatic_e = self.num_symptomatic_e() 

265 ptsd = self.ptsd() 

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

267 for option in range(5): 

268 answer_dict[option] = ( 

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

270 ) 

271 q_a = "" 

272 

273 section_start = { 

274 1: "B (intrusion symptoms)", 

275 6: "C (avoidance)", 

276 8: "D (negative cognition/mood)", 

277 15: "E (arousal/reactivity)", 

278 } 

279 

280 for q in range(1, self.N_QUESTIONS + 1): 

281 if q in section_start: 

282 section = section_start[q] 

283 q_a += subheading_spanning_two_columns( 

284 f"DSM-5 section {section}" 

285 ) 

286 

287 q_a += tr_qa( 

288 self.wxstring(req, "q" + str(q) + "_s"), 

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

290 ) 

291 

292 h = """ 

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

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

295 {tr_is_complete} 

296 {total_score} 

297 {num_symptomatic} 

298 {dsm_criteria_met} 

299 </table> 

300 </div> 

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

302 <tr> 

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

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

305 </tr> 

306 {q_a} 

307 </table> 

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

309 [1] Questions with scores ≥2 are considered symptomatic; see 

310 https://www.ptsd.va.gov/professional/assessment/adult-sr/ptsd-checklist.asp 

311 [2] ≥1 ‘B’ symptoms and ≥1 ‘C’ symptoms and ≥2 ‘D’ symptoms 

312 and ≥2 ‘E’ symptoms. 

313 </div> 

314 """.format( 

315 CssClass=CssClass, 

316 tr_is_complete=self.get_is_complete_tr(req), 

317 total_score=tr_qa(f"{req.sstring(SS.TOTAL_SCORE)} (0–80)", score), 

318 num_symptomatic=tr( 

319 "Number symptomatic <sup>[1]</sup>: B, C, D, E (total)", 

320 answer(num_symptomatic_b) 

321 + ", " 

322 + answer(num_symptomatic_c) 

323 + ", " 

324 + answer(num_symptomatic_d) 

325 + ", " 

326 + answer(num_symptomatic_e) 

327 + " (" 

328 + answer(num_symptomatic) 

329 + ")", 

330 ), 

331 dsm_criteria_met=tr_qa( 

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

333 get_yes_no(req, ptsd), 

334 ), 

335 q_a=q_a, 

336 ) 

337 return h