Coverage for tasks/panss.py: 66%
79 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/tasks/panss.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
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.
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.
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/>.
24===============================================================================
26"""
28from typing import Any, cast, List, Type
30from cardinal_pythonlib.stringfunc import strseq
31from sqlalchemy.sql.sqltypes import Integer
33from camcops_server.cc_modules.cc_constants import (
34 CssClass,
35 DATA_COLLECTION_ONLY_DIV,
36)
37from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
38from camcops_server.cc_modules.cc_db import add_multiple_columns
39from camcops_server.cc_modules.cc_html import tr_qa
40from camcops_server.cc_modules.cc_request import CamcopsRequest
41from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup
42from camcops_server.cc_modules.cc_summaryelement import SummaryElement
43from camcops_server.cc_modules.cc_task import (
44 get_from_dict,
45 Task,
46 TaskHasClinicianMixin,
47 TaskHasPatientMixin,
48)
49from camcops_server.cc_modules.cc_text import SS
50from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo
53# =============================================================================
54# PANSS
55# =============================================================================
58class Panss( # type: ignore[misc]
59 TaskHasPatientMixin,
60 TaskHasClinicianMixin,
61 Task,
62):
63 """
64 Server implementation of the PANSS task.
65 """
67 __tablename__ = "panss"
68 shortname = "PANSS"
69 provides_trackers = True
71 NUM_P = 7
72 NUM_N = 7
73 NUM_G = 16
75 @classmethod
76 def extend_columns(cls: Type["Panss"], **kwargs: Any) -> None:
77 add_multiple_columns(
78 cls,
79 "p",
80 1,
81 cls.NUM_P,
82 minimum=1,
83 maximum=7,
84 comment_fmt="P{n}: {s} (1 absent - 7 extreme)",
85 comment_strings=[
86 "delusions",
87 "conceptual disorganisation",
88 "hallucinatory behaviour",
89 "excitement",
90 "grandiosity",
91 "suspiciousness/persecution",
92 "hostility",
93 ],
94 )
95 add_multiple_columns(
96 cls,
97 "n",
98 1,
99 cls.NUM_N,
100 minimum=1,
101 maximum=7,
102 comment_fmt="N{n}: {s} (1 absent - 7 extreme)",
103 comment_strings=[
104 "blunted affect",
105 "emotional withdrawal",
106 "poor rapport",
107 "passive/apathetic social withdrawal",
108 "difficulty in abstract thinking",
109 "lack of spontaneity/conversation flow",
110 "stereotyped thinking",
111 ],
112 )
113 add_multiple_columns(
114 cls,
115 "g",
116 1,
117 cls.NUM_G,
118 minimum=1,
119 maximum=7,
120 comment_fmt="G{n}: {s} (1 absent - 7 extreme)",
121 comment_strings=[
122 "somatic concern",
123 "anxiety",
124 "guilt feelings",
125 "tension",
126 "mannerisms/posturing",
127 "depression",
128 "motor retardation",
129 "uncooperativeness",
130 "unusual thought content",
131 "disorientation",
132 "poor attention",
133 "lack of judgement/insight",
134 "disturbance of volition",
135 "poor impulse control",
136 "preoccupation",
137 "active social avoidance",
138 ],
139 )
141 P_FIELDS = strseq("p", 1, NUM_P)
142 N_FIELDS = strseq("n", 1, NUM_N)
143 G_FIELDS = strseq("g", 1, NUM_G)
144 TASK_FIELDS = P_FIELDS + N_FIELDS + G_FIELDS
146 MIN_P = 1 * NUM_P
147 MAX_P = 7 * NUM_P
148 MIN_N = 1 * NUM_N
149 MAX_N = 7 * NUM_N
150 MIN_G = 1 * NUM_G
151 MAX_G = 7 * NUM_G
152 MIN_TOTAL = MIN_P + MIN_N + MIN_G
153 MAX_TOTAL = MAX_P + MAX_N + MAX_G
154 MIN_P_MINUS_N = MIN_P - MAX_N
155 MAX_P_MINUS_N = MAX_P - MIN_N
157 @staticmethod
158 def longname(req: "CamcopsRequest") -> str:
159 _ = req.gettext
160 return _("Positive and Negative Syndrome Scale")
162 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
163 return [
164 TrackerInfo(
165 value=self.total_score(),
166 plot_label="PANSS total score",
167 axis_label=f"Total score ({self.MIN_TOTAL}-{self.MAX_TOTAL})",
168 axis_min=self.MIN_TOTAL - 0.5,
169 axis_max=self.MAX_TOTAL + 0.5,
170 ),
171 TrackerInfo(
172 value=self.score_p(),
173 plot_label="PANSS P score",
174 axis_label=f"P score ({self.MIN_P}-{self.MAX_P})",
175 axis_min=self.MIN_P - 0.5,
176 axis_max=self.MAX_P + 0.5,
177 ),
178 TrackerInfo(
179 value=self.score_n(),
180 plot_label="PANSS N score",
181 axis_label=f"N score ({self.MIN_N}-{self.MAX_N})",
182 axis_min=self.MIN_N - 0.5,
183 axis_max=self.MAX_N + 0.5,
184 ),
185 TrackerInfo(
186 value=self.score_g(),
187 plot_label="PANSS G score",
188 axis_label=f"G score ({self.MIN_G}-{self.MAX_G})",
189 axis_min=self.MIN_G - 0.5,
190 axis_max=self.MAX_G + 0.5,
191 ),
192 TrackerInfo(
193 value=self.composite(),
194 plot_label=f"PANSS composite score "
195 f"({self.MIN_P_MINUS_N} to {self.MAX_P_MINUS_N})",
196 axis_label="P - N",
197 ),
198 ]
200 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
201 if not self.is_complete():
202 return CTV_INCOMPLETE
203 return [
204 CtvInfo(
205 content=(
206 f"PANSS total score {self.total_score()} "
207 f"(P {self.score_p()}, "
208 f"N {self.score_n()}, "
209 f"G {self.score_g()}, "
210 f"composite P–N {self.composite()})"
211 )
212 )
213 ]
215 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
216 return self.standard_task_summary_fields() + [
217 SummaryElement(
218 name="total",
219 coltype=Integer(),
220 value=self.total_score(),
221 comment=f"Total score ({self.MIN_TOTAL}-{self.MAX_TOTAL})",
222 ),
223 SummaryElement(
224 name="p",
225 coltype=Integer(),
226 value=self.score_p(),
227 comment=f"Positive symptom (P) score ({self.MIN_P}-{self.MAX_P})", # noqa
228 ),
229 SummaryElement(
230 name="n",
231 coltype=Integer(),
232 value=self.score_n(),
233 comment=f"Negative symptom (N) score ({self.MIN_N}-{self.MAX_N})", # noqa
234 ),
235 SummaryElement(
236 name="g",
237 coltype=Integer(),
238 value=self.score_g(),
239 comment=f"General symptom (G) score ({self.MIN_G}-{self.MAX_G})", # noqa
240 ),
241 SummaryElement(
242 name="composite",
243 coltype=Integer(),
244 value=self.composite(),
245 comment=f"Composite score (P - N) ({self.MIN_P_MINUS_N} "
246 f"to {self.MAX_P_MINUS_N})",
247 ),
248 ]
250 def is_complete(self) -> bool:
251 return (
252 self.all_fields_not_none(self.TASK_FIELDS)
253 and self.field_contents_valid()
254 )
256 def total_score(self) -> int:
257 return cast(int, self.sum_fields(self.TASK_FIELDS))
259 def score_p(self) -> int:
260 return cast(int, self.sum_fields(self.P_FIELDS))
262 def score_n(self) -> int:
263 return cast(int, self.sum_fields(self.N_FIELDS))
265 def score_g(self) -> int:
266 return cast(int, self.sum_fields(self.G_FIELDS))
268 def composite(self) -> int:
269 return cast(int, self.score_p() - self.score_n())
271 def get_task_html(self, req: CamcopsRequest) -> str:
272 p = self.score_p()
273 n = self.score_n()
274 g = self.score_g()
275 composite = self.composite()
276 total = p + n + g
277 answers = {
278 None: None,
279 1: self.wxstring(req, "option1"),
280 2: self.wxstring(req, "option2"),
281 3: self.wxstring(req, "option3"),
282 4: self.wxstring(req, "option4"),
283 5: self.wxstring(req, "option5"),
284 6: self.wxstring(req, "option6"),
285 7: self.wxstring(req, "option7"),
286 }
287 q_a = ""
288 for q in self.TASK_FIELDS:
289 q_a += tr_qa(
290 self.wxstring(req, "" + q + "_s"),
291 get_from_dict(answers, getattr(self, q)),
292 )
293 h = """
294 <div class="{CssClass.SUMMARY}">
295 <table class="{CssClass.SUMMARY}">
296 {tr_is_complete}
297 {total_score}
298 {p}
299 {n}
300 {g}
301 {composite}
302 </table>
303 </div>
304 <table class="{CssClass.TASKDETAIL}">
305 <tr>
306 <th width="40%">Question</th>
307 <th width="60%">Answer</th>
308 </tr>
309 {q_a}
310 </table>
311 {DATA_COLLECTION_ONLY_DIV}
312 """.format(
313 CssClass=CssClass,
314 tr_is_complete=self.get_is_complete_tr(req),
315 total_score=tr_qa(
316 f"{req.sstring(SS.TOTAL_SCORE)} "
317 f"({self.MIN_TOTAL}–{self.MAX_TOTAL})",
318 total,
319 ),
320 p=tr_qa(
321 f"{self.wxstring(req, 'p')} ({self.MIN_P}–{self.MAX_P})", p
322 ),
323 n=tr_qa(
324 f"{self.wxstring(req, 'n')} ({self.MIN_N}–{self.MAX_N})", n
325 ),
326 g=tr_qa(
327 f"{self.wxstring(req, 'g')} ({self.MIN_G}–{self.MAX_G})", g
328 ),
329 composite=tr_qa(
330 f"{self.wxstring(req, 'composite')} "
331 f"({self.MIN_P_MINUS_N}–{self.MAX_P_MINUS_N})",
332 composite,
333 ),
334 q_a=q_a,
335 DATA_COLLECTION_ONLY_DIV=DATA_COLLECTION_ONLY_DIV,
336 )
337 return h
339 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
340 if not self.is_complete():
341 return []
342 return [SnomedExpression(req.snomed(SnomedLookup.PANSS_SCALE))]