Coverage for tasks/cape42.py: 40%

139 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/cape42.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

10 

11 This file is part of CamCOPS. 

12 

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. 

17 

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. 

22 

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/>. 

25 

26=============================================================================== 

27 

28""" 

29 

30from typing import Any, Dict, List, Optional, Tuple, Type 

31 

32import cardinal_pythonlib.rnc_web as ws 

33from sqlalchemy.ext.declarative import DeclarativeMeta 

34from sqlalchemy.sql.sqltypes import Float, Integer 

35 

36from camcops_server.cc_modules.cc_constants import CssClass 

37from camcops_server.cc_modules.cc_db import add_multiple_columns 

38from camcops_server.cc_modules.cc_html import answer, tr 

39from camcops_server.cc_modules.cc_request import CamcopsRequest 

40from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

41from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin 

42from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

43 

44 

45# ============================================================================= 

46# CAPE-42 

47# ============================================================================= 

48 

49QUESTION_SNIPPETS = [ 

50 # 1-10 

51 "sad", 

52 "double meaning", 

53 "not very animated", 

54 "not a talker", 

55 "magazines/TV personal", 

56 "some people not what they seem", 

57 "persecuted", 

58 "few/no emotions", 

59 "pessimistic", 

60 "conspiracy", 

61 # 11-20 

62 "destined for importance", 

63 "no future", 

64 "special/unusual person", 

65 "no longer want to live", 

66 "telepathy", 

67 "no interest being with others", 

68 "electrical devices influence thinking", 

69 "lacking motivation", 

70 "cry about nothing", 

71 "occult", 

72 # 21-30 

73 "lack energy", 

74 "people look oddly because of appearance", 

75 "mind empty", 

76 "thoughts removed", 

77 "do nothing", 

78 "thoughts not own", 

79 "feelings lacking intensity", 

80 "others might hear thoughts", 

81 "lack spontaneity", 

82 "thought echo", 

83 # 31-40 

84 "controlled by other force", 

85 "emotions blunted", 

86 "hear voices", 

87 "hear voices conversing", 

88 "neglecting appearance/hygiene", 

89 "never get things done", 

90 "few hobbies/interests", 

91 "feel guilty", 

92 "feel a failure", 

93 "tense", 

94 # 41-42 

95 "Capgras", 

96 "see things others cannot", 

97] 

98NQUESTIONS = 42 

99POSITIVE = [ 

100 2, 

101 5, 

102 6, 

103 7, 

104 10, 

105 11, 

106 13, 

107 15, 

108 17, 

109 20, 

110 22, 

111 24, 

112 26, 

113 28, 

114 30, 

115 31, 

116 33, 

117 34, 

118 41, 

119 42, 

120] 

121DEPRESSIVE = [1, 9, 12, 14, 19, 38, 39, 40] 

122NEGATIVE = [3, 4, 8, 16, 18, 21, 23, 25, 27, 29, 32, 35, 36, 37] 

123ALL = list(range(1, NQUESTIONS + 1)) 

124MIN_SCORE_PER_Q = 1 

125MAX_SCORE_PER_Q = 4 

126 

127ALL_MIN = MIN_SCORE_PER_Q * NQUESTIONS 

128ALL_MAX = MAX_SCORE_PER_Q * NQUESTIONS 

129POS_MIN = MIN_SCORE_PER_Q * len(POSITIVE) 

130POS_MAX = MAX_SCORE_PER_Q * len(POSITIVE) 

131NEG_MIN = MIN_SCORE_PER_Q * len(NEGATIVE) 

132NEG_MAX = MAX_SCORE_PER_Q * len(NEGATIVE) 

133DEP_MIN = MIN_SCORE_PER_Q * len(DEPRESSIVE) 

134DEP_MAX = MAX_SCORE_PER_Q * len(DEPRESSIVE) 

135 

136DP = 2 

137 

138 

139class Cape42Metaclass(DeclarativeMeta): 

140 # noinspection PyInitNewSignature 

141 def __init__( 

142 cls: Type["Cape42"], 

143 name: str, 

144 bases: Tuple[Type, ...], 

145 classdict: Dict[str, Any], 

146 ) -> None: 

147 add_multiple_columns( 

148 cls, 

149 "frequency", 

150 1, 

151 NQUESTIONS, 

152 minimum=MIN_SCORE_PER_Q, 

153 maximum=MAX_SCORE_PER_Q, 

154 comment_fmt=( 

155 "Q{n} ({s}): frequency? (1 never, 2 sometimes, 3 often, " 

156 "4 nearly always)" 

157 ), 

158 comment_strings=QUESTION_SNIPPETS, 

159 ) 

160 add_multiple_columns( 

161 cls, 

162 "distress", 

163 1, 

164 NQUESTIONS, 

165 minimum=MIN_SCORE_PER_Q, 

166 maximum=MAX_SCORE_PER_Q, 

167 comment_fmt=( 

168 "Q{n} ({s}): distress (1 not, 2 a bit, 3 quite, 4 very), if " 

169 "frequency > 1" 

170 ), 

171 comment_strings=QUESTION_SNIPPETS, 

172 ) 

173 super().__init__(name, bases, classdict) 

174 

175 

176class Cape42(TaskHasPatientMixin, Task, metaclass=Cape42Metaclass): 

177 """ 

178 Server implementation of the CAPE-42 task. 

179 """ 

180 

181 __tablename__ = "cape42" 

182 shortname = "CAPE-42" 

183 provides_trackers = True 

184 info_filename_stem = "cape" 

185 

186 @staticmethod 

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

188 _ = req.gettext 

189 return _("Community Assessment of Psychic Experiences") 

190 

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

192 fstr1 = "CAPE-42 weighted frequency score: " 

193 dstr1 = "CAPE-42 weighted distress score: " 

194 wtr = f" ({MIN_SCORE_PER_Q}–{MAX_SCORE_PER_Q})" 

195 fstr2 = " weighted freq. score" + wtr 

196 dstr2 = " weighted distress score" + wtr 

197 axis_min = MIN_SCORE_PER_Q - 0.2 

198 axis_max = MAX_SCORE_PER_Q + 0.2 

199 return [ 

200 TrackerInfo( 

201 value=self.weighted_frequency_score(ALL), 

202 plot_label=fstr1 + "overall", 

203 axis_label="Overall" + fstr2, 

204 axis_min=axis_min, 

205 axis_max=axis_max, 

206 ), 

207 TrackerInfo( 

208 value=self.weighted_distress_score(ALL), 

209 plot_label=dstr1 + "overall", 

210 axis_label="Overall" + dstr2, 

211 axis_min=axis_min, 

212 axis_max=axis_max, 

213 ), 

214 TrackerInfo( 

215 value=self.weighted_frequency_score(POSITIVE), 

216 plot_label=fstr1 + "positive symptoms", 

217 axis_label="Positive Sx" + fstr2, 

218 axis_min=axis_min, 

219 axis_max=axis_max, 

220 ), 

221 TrackerInfo( 

222 value=self.weighted_distress_score(POSITIVE), 

223 plot_label=dstr1 + "positive symptoms", 

224 axis_label="Positive Sx" + dstr2, 

225 axis_min=axis_min, 

226 axis_max=axis_max, 

227 ), 

228 TrackerInfo( 

229 value=self.weighted_frequency_score(NEGATIVE), 

230 plot_label=fstr1 + "negative symptoms", 

231 axis_label="Negative Sx" + fstr2, 

232 axis_min=axis_min, 

233 axis_max=axis_max, 

234 ), 

235 TrackerInfo( 

236 value=self.weighted_distress_score(NEGATIVE), 

237 plot_label=dstr1 + "negative symptoms", 

238 axis_label="Negative Sx" + dstr2, 

239 axis_min=axis_min, 

240 axis_max=axis_max, 

241 ), 

242 TrackerInfo( 

243 value=self.weighted_frequency_score(DEPRESSIVE), 

244 plot_label=fstr1 + "depressive symptoms", 

245 axis_label="Depressive Sx" + fstr2, 

246 axis_min=axis_min, 

247 axis_max=axis_max, 

248 ), 

249 TrackerInfo( 

250 value=self.weighted_distress_score(DEPRESSIVE), 

251 plot_label=dstr1 + "depressive symptoms", 

252 axis_label="Depressive Sx" + dstr2, 

253 axis_min=axis_min, 

254 axis_max=axis_max, 

255 ), 

256 ] 

257 

258 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]: 

259 wtr = f" ({MIN_SCORE_PER_Q}-{MAX_SCORE_PER_Q})" 

260 return self.standard_task_summary_fields() + [ 

261 SummaryElement( 

262 name="all_freq", 

263 coltype=Integer(), 

264 value=self.frequency_score(ALL), 

265 comment=( 

266 "Total score = frequency score for all questions " 

267 f"({ALL_MIN}-{ALL_MAX})" 

268 ), 

269 ), 

270 SummaryElement( 

271 name="all_distress", 

272 coltype=Integer(), 

273 value=self.distress_score(ALL), 

274 comment=( 

275 "Distress score for all questions " 

276 f"({ALL_MIN}-{ALL_MAX})" 

277 ), 

278 ), 

279 SummaryElement( 

280 name="positive_frequency", 

281 coltype=Integer(), 

282 value=self.frequency_score(POSITIVE), 

283 comment=( 

284 "Frequency score for positive symptom questions " 

285 f"({POS_MIN}-{POS_MAX})" 

286 ), 

287 ), 

288 SummaryElement( 

289 name="positive_distress", 

290 coltype=Integer(), 

291 value=self.distress_score(POSITIVE), 

292 comment=( 

293 "Distress score for positive symptom questions " 

294 f"({POS_MIN}-{POS_MAX})" 

295 ), 

296 ), 

297 SummaryElement( 

298 name="negative_frequency", 

299 coltype=Integer(), 

300 value=self.frequency_score(NEGATIVE), 

301 comment=( 

302 "Frequency score for negative symptom questions " 

303 f"({NEG_MIN}-{NEG_MAX})" 

304 ), 

305 ), 

306 SummaryElement( 

307 name="negative_distress", 

308 coltype=Integer(), 

309 value=self.distress_score(NEGATIVE), 

310 comment=( 

311 "Distress score for negative symptom questions " 

312 f"({NEG_MIN}-{NEG_MAX})" 

313 ), 

314 ), 

315 SummaryElement( 

316 name="depressive_frequency", 

317 coltype=Integer(), 

318 value=self.frequency_score(DEPRESSIVE), 

319 comment=( 

320 "Frequency score for depressive symptom questions " 

321 f"({DEP_MIN}-{DEP_MAX})" 

322 ), 

323 ), 

324 SummaryElement( 

325 name="depressive_distress", 

326 coltype=Integer(), 

327 value=self.distress_score(DEPRESSIVE), 

328 comment=( 

329 "Distress score for depressive symptom questions " 

330 f"({DEP_MIN}-{DEP_MAX})" 

331 ), 

332 ), 

333 SummaryElement( 

334 name="wt_all_freq", 

335 coltype=Float(), 

336 value=self.weighted_frequency_score(ALL), 

337 comment="Weighted frequency score: overall" + wtr, 

338 ), 

339 SummaryElement( 

340 name="wt_all_distress", 

341 coltype=Float(), 

342 value=self.weighted_distress_score(ALL), 

343 comment="Weighted distress score: overall" + wtr, 

344 ), 

345 SummaryElement( 

346 name="wt_pos_freq", 

347 coltype=Float(), 

348 value=self.weighted_frequency_score(POSITIVE), 

349 comment="Weighted frequency score: positive symptoms" + wtr, 

350 ), 

351 SummaryElement( 

352 name="wt_pos_distress", 

353 coltype=Float(), 

354 value=self.weighted_distress_score(POSITIVE), 

355 comment="Weighted distress score: positive symptoms" + wtr, 

356 ), 

357 SummaryElement( 

358 name="wt_neg_freq", 

359 coltype=Float(), 

360 value=self.weighted_frequency_score(NEGATIVE), 

361 comment="Weighted frequency score: negative symptoms" + wtr, 

362 ), 

363 SummaryElement( 

364 name="wt_neg_distress", 

365 coltype=Float(), 

366 value=self.weighted_distress_score(NEGATIVE), 

367 comment="Weighted distress score: negative symptoms" + wtr, 

368 ), 

369 SummaryElement( 

370 name="wt_dep_freq", 

371 coltype=Float(), 

372 value=self.weighted_frequency_score(DEPRESSIVE), 

373 comment="Weighted frequency score: depressive symptoms" + wtr, 

374 ), 

375 SummaryElement( 

376 name="wt_dep_distress", 

377 coltype=Float(), 

378 value=self.weighted_distress_score(DEPRESSIVE), 

379 comment="Weighted distress score: depressive symptoms" + wtr, 

380 ), 

381 ] 

382 

383 def is_question_complete(self, q: int) -> bool: 

384 f = self.get_frequency(q) 

385 if f is None: 

386 return False 

387 if f > 1 and self.get_distress(q) is None: 

388 return False 

389 return True 

390 

391 def is_complete(self) -> bool: 

392 if not self.field_contents_valid(): 

393 return False 

394 for q in ALL: 

395 if not self.is_question_complete(q): 

396 return False 

397 return True 

398 

399 def get_frequency(self, q: int) -> Optional[int]: 

400 return getattr(self, "frequency" + str(q)) 

401 

402 def get_distress(self, q: int) -> Optional[int]: 

403 return getattr(self, "distress" + str(q)) 

404 

405 def get_distress_score(self, q: int) -> Optional[int]: 

406 if not self.endorsed(q): 

407 return MIN_SCORE_PER_Q 

408 return self.get_distress(q) 

409 

410 def endorsed(self, q: int) -> bool: 

411 f = self.get_frequency(q) 

412 return f is not None and f > MIN_SCORE_PER_Q 

413 

414 def distress_score(self, qlist: List[int]) -> int: 

415 score = 0 

416 for q in qlist: 

417 d = self.get_distress_score(q) 

418 if d is not None: 

419 score += d 

420 return score 

421 

422 def frequency_score(self, qlist: List[int]) -> int: 

423 score = 0 

424 for q in qlist: 

425 f = self.get_frequency(q) 

426 if f is not None: 

427 score += f 

428 return score 

429 

430 def weighted_frequency_score(self, qlist: List[int]) -> Optional[float]: 

431 score = 0 

432 n = 0 

433 for q in qlist: 

434 f = self.get_frequency(q) 

435 if f is not None: 

436 score += f 

437 n += 1 

438 if n == 0: 

439 return None 

440 return score / n 

441 

442 def weighted_distress_score(self, qlist: List[int]) -> Optional[float]: 

443 score = 0 

444 n = 0 

445 for q in qlist: 

446 f = self.get_frequency(q) 

447 d = self.get_distress_score(q) 

448 if f is not None and d is not None: 

449 score += d 

450 n += 1 

451 if n == 0: 

452 return None 

453 return score / n 

454 

455 @staticmethod 

456 def question_category(q: int) -> str: 

457 if q in POSITIVE: 

458 return "P" 

459 if q in NEGATIVE: 

460 return "N" 

461 if q in DEPRESSIVE: 

462 return "D" 

463 return "?" 

464 

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

466 q_a = "" 

467 for q in ALL: 

468 q_a += tr( 

469 f"{q}. " 

470 + self.wxstring(req, "q" + str(q)) 

471 + " (<i>" 

472 + self.question_category(q) 

473 + "</i>)", 

474 answer(self.get_frequency(q)), 

475 answer( 

476 self.get_distress_score(q) if self.endorsed(q) else None, 

477 default=str(MIN_SCORE_PER_Q), 

478 ), 

479 ) 

480 

481 raw_overall = tr( 

482 f"Overall <sup>[1]</sup> ({ALL_MIN}–{ALL_MAX})", 

483 self.frequency_score(ALL), 

484 self.distress_score(ALL), 

485 ) 

486 raw_positive = tr( 

487 f"Positive symptoms ({POS_MIN}–{POS_MAX})", 

488 self.frequency_score(POSITIVE), 

489 self.distress_score(POSITIVE), 

490 ) 

491 raw_negative = tr( 

492 f"Negative symptoms ({NEG_MIN}–{NEG_MAX})", 

493 self.frequency_score(NEGATIVE), 

494 self.distress_score(NEGATIVE), 

495 ) 

496 raw_depressive = tr( 

497 f"Depressive symptoms ({DEP_MIN}–{DEP_MAX})", 

498 self.frequency_score(DEPRESSIVE), 

499 self.distress_score(DEPRESSIVE), 

500 ) 

501 weighted_overall = tr( 

502 f"Overall ({len(ALL)} questions)", 

503 ws.number_to_dp(self.weighted_frequency_score(ALL), DP), 

504 ws.number_to_dp(self.weighted_distress_score(ALL), DP), 

505 ) 

506 weighted_positive = tr( 

507 f"Positive symptoms ({len(POSITIVE)} questions)", 

508 ws.number_to_dp(self.weighted_frequency_score(POSITIVE), DP), 

509 ws.number_to_dp(self.weighted_distress_score(POSITIVE), DP), 

510 ) 

511 weighted_negative = tr( 

512 f"Negative symptoms ({len(NEGATIVE)} questions)", 

513 ws.number_to_dp(self.weighted_frequency_score(NEGATIVE), DP), 

514 ws.number_to_dp(self.weighted_distress_score(NEGATIVE), DP), 

515 ) 

516 weighted_depressive = tr( 

517 f"Depressive symptoms ({len(DEPRESSIVE)} questions)", 

518 ws.number_to_dp(self.weighted_frequency_score(DEPRESSIVE), DP), 

519 ws.number_to_dp(self.weighted_distress_score(DEPRESSIVE), DP), 

520 ) 

521 return f""" 

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

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

524 {self.get_is_complete_tr(req)} 

525 </table> 

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

527 <tr> 

528 <th>Domain (with score range)</th> 

529 <th>Frequency (total score)</th> 

530 <th>Distress (total score)</th> 

531 </tr> 

532 {raw_overall} 

533 {raw_positive} 

534 {raw_negative} 

535 {raw_depressive} 

536 </table> 

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

538 <tr> 

539 <th>Domain</th> 

540 <th>Weighted frequency score <sup>[3]</sup></th> 

541 <th>Weighted distress score <sup>[3]</sup></th> 

542 </tr> 

543 {weighted_overall} 

544 {weighted_positive} 

545 {weighted_negative} 

546 {weighted_depressive} 

547 </table> 

548 </div> 

549 <div class="{CssClass.EXPLANATION}"> 

550 FREQUENCY: 

551 1 {self.wxstring(req, "frequency_option1")}, 

552 2 {self.wxstring(req, "frequency_option2")}, 

553 3 {self.wxstring(req, "frequency_option3")}, 

554 4 {self.wxstring(req, "frequency_option4")}. 

555 DISTRESS: 

556 1 {self.wxstring(req, "distress_option1")}, 

557 2 {self.wxstring(req, "distress_option2")}, 

558 3 {self.wxstring(req, "distress_option3")}, 

559 4 {self.wxstring(req, "distress_option4")}. 

560 </div> 

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

562 <tr> 

563 <th width="70%"> 

564 Question (P positive, N negative, D depressive) 

565 </th> 

566 <th width="15%">Frequency 

567 ({MIN_SCORE_PER_Q}–{MAX_SCORE_PER_Q})</th> 

568 <th width="15%">Distress 

569 ({MIN_SCORE_PER_Q}–{MAX_SCORE_PER_Q}) 

570 <sup>[2]</sup></th> 

571 </tr> 

572 {q_a} 

573 </table> 

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

575 [1] “Total” score is the overall frequency score (the sum of 

576 frequency scores for all questions). 

577 [2] Distress coerced to 1 if frequency is 1. 

578 [3] Sum score per dimension divided by number of completed 

579 items. Shown to {DP} decimal places. Will be in the range 

580 {MIN_SCORE_PER_Q}–{MAX_SCORE_PER_Q}, or blank if not 

581 calculable. 

582 </div> 

583 """