Coverage for tasks/panss.py: 66%

79 statements  

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

1""" 

2camcops_server/tasks/panss.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, Type 

29 

30from cardinal_pythonlib.stringfunc import strseq 

31from sqlalchemy.sql.sqltypes import Integer 

32 

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 

51 

52 

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

54# PANSS 

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

56 

57 

58class Panss( # type: ignore[misc] 

59 TaskHasPatientMixin, 

60 TaskHasClinicianMixin, 

61 Task, 

62): 

63 """ 

64 Server implementation of the PANSS task. 

65 """ 

66 

67 __tablename__ = "panss" 

68 shortname = "PANSS" 

69 provides_trackers = True 

70 

71 NUM_P = 7 

72 NUM_N = 7 

73 NUM_G = 16 

74 

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 ) 

140 

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 

145 

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 

156 

157 @staticmethod 

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

159 _ = req.gettext 

160 return _("Positive and Negative Syndrome Scale") 

161 

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 ] 

199 

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 ] 

214 

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 ] 

249 

250 def is_complete(self) -> bool: 

251 return ( 

252 self.all_fields_not_none(self.TASK_FIELDS) 

253 and self.field_contents_valid() 

254 ) 

255 

256 def total_score(self) -> int: 

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

258 

259 def score_p(self) -> int: 

260 return cast(int, self.sum_fields(self.P_FIELDS)) 

261 

262 def score_n(self) -> int: 

263 return cast(int, self.sum_fields(self.N_FIELDS)) 

264 

265 def score_g(self) -> int: 

266 return cast(int, self.sum_fields(self.G_FIELDS)) 

267 

268 def composite(self) -> int: 

269 return cast(int, self.score_p() - self.score_n()) 

270 

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 

338 

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))]