Coverage for tasks/pcl5.py: 50%
94 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/tasks/pcl5.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
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.
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.
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/>.
26===============================================================================
28"""
30from typing import Any, Dict, List, Tuple, Type
32from cardinal_pythonlib.classes import classproperty
33from cardinal_pythonlib.stringfunc import strseq
34from semantic_version import Version
35from sqlalchemy.ext.declarative import DeclarativeMeta
36from sqlalchemy.sql.sqltypes import Boolean, Integer
38from camcops_server.cc_modules.cc_constants import CssClass
39from camcops_server.cc_modules.cc_ctvinfo import CtvInfo, CTV_INCOMPLETE
40from camcops_server.cc_modules.cc_db import add_multiple_columns
41from camcops_server.cc_modules.cc_html import (
42 answer,
43 get_yes_no,
44 subheading_spanning_two_columns,
45 tr,
46 tr_qa,
47)
48from camcops_server.cc_modules.cc_request import CamcopsRequest
50from camcops_server.cc_modules.cc_summaryelement import SummaryElement
51from camcops_server.cc_modules.cc_task import (
52 get_from_dict,
53 Task,
54 TaskHasPatientMixin,
55)
56from camcops_server.cc_modules.cc_text import SS
57from camcops_server.cc_modules.cc_trackerhelpers import (
58 equally_spaced_int,
59 regular_tracker_axis_ticks_int,
60 TrackerInfo,
61 TrackerLabel,
62)
65# =============================================================================
66# PCL-5
67# =============================================================================
70class Pcl5Metaclass(DeclarativeMeta):
71 """
72 There is a multilayer metaclass problem; see hads.py for discussion.
73 """
75 # noinspection PyInitNewSignature
76 def __init__(
77 cls: Type["Pcl5"],
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_QUESTIONS,
87 minimum=0,
88 maximum=4,
89 comment_fmt="Q{n} ({s}) (0 not at all - 4 extremely)",
90 comment_strings=[
91 "disturbing memories/thoughts/images",
92 "disturbing dreams",
93 "reliving",
94 "upset at reminders",
95 "physical reactions to reminders",
96 "avoid thinking/talking/feelings relating to experience",
97 "avoid activities/situations because they remind",
98 "trouble remembering important parts of stressful event",
99 "strong negative beliefs about self/others/world",
100 "blaming",
101 "strong negative emotions",
102 "loss of interest in previously enjoyed activities",
103 "feeling distant / cut off from people",
104 "feeling emotionally numb",
105 "irritable, angry and/or aggressive",
106 "risk-taking and/or self-harming behaviour",
107 "super alert/on guard",
108 "jumpy/easily startled",
109 "difficulty concentrating",
110 "hard to sleep",
111 ],
112 )
113 super().__init__(name, bases, classdict)
116class Pcl5(TaskHasPatientMixin, Task, metaclass=Pcl5Metaclass):
117 """
118 Server implementation of the PCL-5 task.
119 """
121 __tablename__ = "pcl5"
122 shortname = "PCL-5"
123 provides_trackers = True
124 extrastring_taskname = "pcl5"
125 N_QUESTIONS = 20
126 SCORED_FIELDS = strseq("q", 1, N_QUESTIONS)
127 TASK_FIELDS = SCORED_FIELDS # may be overridden
128 MIN_SCORE = 0
129 MAX_SCORE = 4 * N_QUESTIONS
131 @staticmethod
132 def longname(req: "CamcopsRequest") -> str:
133 _ = req.gettext
134 return _("PTSD Checklist, DSM-5 version")
136 # noinspection PyMethodParameters
137 @classproperty
138 def minimum_client_version(cls) -> Version:
139 return Version("2.2.8")
141 def is_complete(self) -> bool:
142 return (
143 self.all_fields_not_none(self.TASK_FIELDS)
144 and self.field_contents_valid()
145 )
147 def total_score(self) -> int:
148 return self.sum_fields(self.SCORED_FIELDS)
150 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
151 line_step = 20
152 preliminary_cutoff = 33
153 return [
154 TrackerInfo(
155 value=self.total_score(),
156 plot_label="PCL-5 total score",
157 axis_label=f"Total score ({self.MIN_SCORE}-{self.MAX_SCORE})",
158 axis_min=self.MIN_SCORE - 0.5,
159 axis_max=self.MAX_SCORE + 0.5,
160 axis_ticks=regular_tracker_axis_ticks_int(
161 self.MIN_SCORE, self.MAX_SCORE, step=line_step
162 ),
163 horizontal_lines=equally_spaced_int(
164 self.MIN_SCORE + line_step,
165 self.MAX_SCORE - line_step,
166 step=line_step,
167 )
168 + [preliminary_cutoff],
169 horizontal_labels=[
170 TrackerLabel(
171 preliminary_cutoff,
172 self.wxstring(req, "preliminary_cutoff"),
173 )
174 ],
175 )
176 ]
178 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
179 if not self.is_complete():
180 return CTV_INCOMPLETE
181 return [CtvInfo(content=f"PCL-5 total score {self.total_score()}")]
183 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
184 return self.standard_task_summary_fields() + [
185 SummaryElement(
186 name="total",
187 coltype=Integer(),
188 value=self.total_score(),
189 comment=f"Total score ({self.MIN_SCORE}-{self.MAX_SCORE})",
190 ),
191 SummaryElement(
192 name="num_symptomatic",
193 coltype=Integer(),
194 value=self.num_symptomatic(),
195 comment="Total number of symptoms considered symptomatic "
196 "(meaning scoring 2 or more)",
197 ),
198 SummaryElement(
199 name="num_symptomatic_B",
200 coltype=Integer(),
201 value=self.num_symptomatic_b(),
202 comment="Number of group B symptoms considered symptomatic "
203 "(meaning scoring 2 or more)",
204 ),
205 SummaryElement(
206 name="num_symptomatic_C",
207 coltype=Integer(),
208 value=self.num_symptomatic_c(),
209 comment="Number of group C symptoms considered symptomatic "
210 "(meaning scoring 2 or more)",
211 ),
212 SummaryElement(
213 name="num_symptomatic_D",
214 coltype=Integer(),
215 value=self.num_symptomatic_d(),
216 comment="Number of group D symptoms considered symptomatic "
217 "(meaning scoring 2 or more)",
218 ),
219 SummaryElement(
220 name="num_symptomatic_E",
221 coltype=Integer(),
222 value=self.num_symptomatic_e(),
223 comment="Number of group D symptoms considered symptomatic "
224 "(meaning scoring 2 or more)",
225 ),
226 SummaryElement(
227 name="ptsd",
228 coltype=Boolean(),
229 value=self.ptsd(),
230 comment="Provisionally meets DSM-5 criteria for PTSD",
231 ),
232 ]
234 def get_num_symptomatic(self, first: int, last: int) -> int:
235 n = 0
236 for i in range(first, last + 1):
237 value = getattr(self, "q" + str(i))
238 if value is not None and value >= 2:
239 n += 1
240 return n
242 def num_symptomatic(self) -> int:
243 return self.get_num_symptomatic(1, self.N_QUESTIONS)
245 def num_symptomatic_b(self) -> int:
246 return self.get_num_symptomatic(1, 5)
248 def num_symptomatic_c(self) -> int:
249 return self.get_num_symptomatic(6, 7)
251 def num_symptomatic_d(self) -> int:
252 return self.get_num_symptomatic(8, 14)
254 def num_symptomatic_e(self) -> int:
255 return self.get_num_symptomatic(15, 20)
257 def ptsd(self) -> bool:
258 num_symptomatic_b = self.num_symptomatic_b()
259 num_symptomatic_c = self.num_symptomatic_c()
260 num_symptomatic_d = self.num_symptomatic_d()
261 num_symptomatic_e = self.num_symptomatic_e()
262 return (
263 num_symptomatic_b >= 1
264 and num_symptomatic_c >= 1
265 and num_symptomatic_d >= 2
266 and num_symptomatic_e >= 2
267 )
269 def get_task_html(self, req: CamcopsRequest) -> str:
270 score = self.total_score()
271 num_symptomatic = self.num_symptomatic()
272 num_symptomatic_b = self.num_symptomatic_b()
273 num_symptomatic_c = self.num_symptomatic_c()
274 num_symptomatic_d = self.num_symptomatic_d()
275 num_symptomatic_e = self.num_symptomatic_e()
276 ptsd = self.ptsd()
277 answer_dict = {None: None}
278 for option in range(5):
279 answer_dict[option] = (
280 str(option) + " – " + self.wxstring(req, "a" + str(option))
281 )
282 q_a = ""
284 section_start = {
285 1: "B (intrusion symptoms)",
286 6: "C (avoidance)",
287 8: "D (negative cognition/mood)",
288 15: "E (arousal/reactivity)",
289 }
291 for q in range(1, self.N_QUESTIONS + 1):
292 if q in section_start:
293 section = section_start[q]
294 q_a += subheading_spanning_two_columns(
295 f"DSM-5 section {section}"
296 )
298 q_a += tr_qa(
299 self.wxstring(req, "q" + str(q) + "_s"),
300 get_from_dict(answer_dict, getattr(self, "q" + str(q))),
301 )
303 h = """
304 <div class="{CssClass.SUMMARY}">
305 <table class="{CssClass.SUMMARY}">
306 {tr_is_complete}
307 {total_score}
308 {num_symptomatic}
309 {dsm_criteria_met}
310 </table>
311 </div>
312 <table class="{CssClass.TASKDETAIL}">
313 <tr>
314 <th width="70%">Question</th>
315 <th width="30%">Answer</th>
316 </tr>
317 {q_a}
318 </table>
319 <div class="{CssClass.FOOTNOTES}">
320 [1] Questions with scores ≥2 are considered symptomatic; see
321 https://www.ptsd.va.gov/professional/assessment/adult-sr/ptsd-checklist.asp
322 [2] ≥1 ‘B’ symptoms and ≥1 ‘C’ symptoms and ≥2 ‘D’ symptoms
323 and ≥2 ‘E’ symptoms.
324 </div>
325 """.format( # noqa
326 CssClass=CssClass,
327 tr_is_complete=self.get_is_complete_tr(req),
328 total_score=tr_qa(f"{req.sstring(SS.TOTAL_SCORE)} (0–80)", score),
329 num_symptomatic=tr(
330 "Number symptomatic <sup>[1]</sup>: B, C, D, E (total)",
331 answer(num_symptomatic_b)
332 + ", "
333 + answer(num_symptomatic_c)
334 + ", "
335 + answer(num_symptomatic_d)
336 + ", "
337 + answer(num_symptomatic_e)
338 + " ("
339 + answer(num_symptomatic)
340 + ")",
341 ),
342 dsm_criteria_met=tr_qa(
343 self.wxstring(req, "dsm_criteria_met") + " <sup>[2]</sup>",
344 get_yes_no(req, ptsd),
345 ),
346 q_a=q_a,
347 )
348 return h