Coverage for tasks/cape42.py: 39%

1"""

2 camcops_server/tasks/cape42.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

28 from typing import Any, List, Optional, Type

29

30 import cardinal_pythonlib.rnc_web as ws

31 from sqlalchemy.sql.sqltypes import Float, Integer

32

33 from camcops_server.cc_modules.cc_constants import CssClass

34 from camcops_server.cc_modules.cc_db import add_multiple_columns

35 from camcops_server.cc_modules.cc_html import answer, tr

36 from camcops_server.cc_modules.cc_request import CamcopsRequest

37 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

38 from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin

39 from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo

40

41

42# =============================================================================

43# CAPE-42

44# =============================================================================

45

46 QUESTION_SNIPPETS = [

47 # 1-10

48 "sad",

49 "double meaning",

50 "not very animated",

51 "not a talker",

52 "magazines/TV personal",

53 "some people not what they seem",

54 "persecuted",

55 "few/no emotions",

56 "pessimistic",

57 "conspiracy",

58 # 11-20

59 "destined for importance",

60 "no future",

61 "special/unusual person",

62 "no longer want to live",

63 "telepathy",

64 "no interest being with others",

65 "electrical devices influence thinking",

66 "lacking motivation",

67 "cry about nothing",

68 "occult",

69 # 21-30

70 "lack energy",

71 "people look oddly because of appearance",

72 "mind empty",

73 "thoughts removed",

74 "do nothing",

75 "thoughts not own",

76 "feelings lacking intensity",

77 "others might hear thoughts",

78 "lack spontaneity",

79 "thought echo",

80 # 31-40

81 "controlled by other force",

82 "emotions blunted",

83 "hear voices",

84 "hear voices conversing",

85 "neglecting appearance/hygiene",

86 "never get things done",

87 "few hobbies/interests",

88 "feel guilty",

89 "feel a failure",

90 "tense",

91 # 41-42

92 "Capgras",

93 "see things others cannot",

94]

95 NQUESTIONS = 42

96 POSITIVE = [

97 2,

98 5,

99 6,

100 7,

101 10,

102 11,

103 13,

104 15,

105 17,

106 20,

107 22,

108 24,

109 26,

110 28,

111 30,

112 31,

113 33,

114 34,

115 41,

116 42,

117]

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

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

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

121 MIN_SCORE_PER_Q = 1

122 MAX_SCORE_PER_Q = 4

123

124 ALL_MIN = MIN_SCORE_PER_Q * NQUESTIONS

125 ALL_MAX = MAX_SCORE_PER_Q * NQUESTIONS

126 POS_MIN = MIN_SCORE_PER_Q * len(POSITIVE)

127 POS_MAX = MAX_SCORE_PER_Q * len(POSITIVE)

128 NEG_MIN = MIN_SCORE_PER_Q * len(NEGATIVE)

129 NEG_MAX = MAX_SCORE_PER_Q * len(NEGATIVE)

130 DEP_MIN = MIN_SCORE_PER_Q * len(DEPRESSIVE)

131 DEP_MAX = MAX_SCORE_PER_Q * len(DEPRESSIVE)

132

133 DP = 2

134

135

136 class Cape42( # type: ignore[misc]

137 TaskHasPatientMixin,

138 Task,

139):

140 """

141 Server implementation of the CAPE-42 task.

142 """

143

144 __tablename__ = "cape42"

145 shortname = "CAPE-42"

146 provides_trackers = True

147 info_filename_stem = "cape"

148

149 @classmethod

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

151 add_multiple_columns(

152 cls,

153 "frequency",

154 1,

155 NQUESTIONS,

156 minimum=MIN_SCORE_PER_Q,

157 maximum=MAX_SCORE_PER_Q,

158 comment_fmt=(

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

160 "4 nearly always)"

161 ),

162 comment_strings=QUESTION_SNIPPETS,

163 )

164 add_multiple_columns(

165 cls,

166 "distress",

167 1,

168 NQUESTIONS,

169 minimum=MIN_SCORE_PER_Q,

170 maximum=MAX_SCORE_PER_Q,

171 comment_fmt=(

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

173 "frequency > 1"

174 ),

175 comment_strings=QUESTION_SNIPPETS,

176 )

177

178 @staticmethod

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

180 _ = req.gettext

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

182

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

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

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

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

187 fstr2 = " weighted freq. score" + wtr

188 dstr2 = " weighted distress score" + wtr

189 axis_min = MIN_SCORE_PER_Q - 0.2

190 axis_max = MAX_SCORE_PER_Q + 0.2

191 return [

192 TrackerInfo(

193 value=self.weighted_frequency_score(ALL),

194 plot_label=fstr1 + "overall",

195 axis_label="Overall" + fstr2,

196 axis_min=axis_min,

197 axis_max=axis_max,

198 ),

199 TrackerInfo(

200 value=self.weighted_distress_score(ALL),

201 plot_label=dstr1 + "overall",

202 axis_label="Overall" + dstr2,

203 axis_min=axis_min,

204 axis_max=axis_max,

205 ),

206 TrackerInfo(

207 value=self.weighted_frequency_score(POSITIVE),

208 plot_label=fstr1 + "positive symptoms",

209 axis_label="Positive Sx" + fstr2,

210 axis_min=axis_min,

211 axis_max=axis_max,

212 ),

213 TrackerInfo(

214 value=self.weighted_distress_score(POSITIVE),

215 plot_label=dstr1 + "positive symptoms",

216 axis_label="Positive Sx" + dstr2,

217 axis_min=axis_min,

218 axis_max=axis_max,

219 ),

220 TrackerInfo(

221 value=self.weighted_frequency_score(NEGATIVE),

222 plot_label=fstr1 + "negative symptoms",

223 axis_label="Negative Sx" + fstr2,

224 axis_min=axis_min,

225 axis_max=axis_max,

226 ),

227 TrackerInfo(

228 value=self.weighted_distress_score(NEGATIVE),

229 plot_label=dstr1 + "negative symptoms",

230 axis_label="Negative Sx" + dstr2,

231 axis_min=axis_min,

232 axis_max=axis_max,

233 ),

234 TrackerInfo(

235 value=self.weighted_frequency_score(DEPRESSIVE),

236 plot_label=fstr1 + "depressive symptoms",

237 axis_label="Depressive Sx" + fstr2,

238 axis_min=axis_min,

239 axis_max=axis_max,

240 ),

241 TrackerInfo(

242 value=self.weighted_distress_score(DEPRESSIVE),

243 plot_label=dstr1 + "depressive symptoms",

244 axis_label="Depressive Sx" + dstr2,

245 axis_min=axis_min,

246 axis_max=axis_max,

247 ),

248 ]

249

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

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

252 return self.standard_task_summary_fields() + [

253 SummaryElement(

254 name="all_freq",

255 coltype=Integer(),

256 value=self.frequency_score(ALL),

257 comment=(

258 "Total score = frequency score for all questions "

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

260 ),

261 ),

262 SummaryElement(

263 name="all_distress",

264 coltype=Integer(),

265 value=self.distress_score(ALL),

266 comment=(

267 "Distress score for all questions "

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

269 ),

270 ),

271 SummaryElement(

272 name="positive_frequency",

273 coltype=Integer(),

274 value=self.frequency_score(POSITIVE),

275 comment=(

276 "Frequency score for positive symptom questions "

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

278 ),

279 ),

280 SummaryElement(

281 name="positive_distress",

282 coltype=Integer(),

283 value=self.distress_score(POSITIVE),

284 comment=(

285 "Distress score for positive symptom questions "

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

287 ),

288 ),

289 SummaryElement(

290 name="negative_frequency",

291 coltype=Integer(),

292 value=self.frequency_score(NEGATIVE),

293 comment=(

294 "Frequency score for negative symptom questions "

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

296 ),

297 ),

298 SummaryElement(

299 name="negative_distress",

300 coltype=Integer(),

301 value=self.distress_score(NEGATIVE),

302 comment=(

303 "Distress score for negative symptom questions "

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

305 ),

306 ),

307 SummaryElement(

308 name="depressive_frequency",

309 coltype=Integer(),

310 value=self.frequency_score(DEPRESSIVE),

311 comment=(

312 "Frequency score for depressive symptom questions "

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

314 ),

315 ),

316 SummaryElement(

317 name="depressive_distress",

318 coltype=Integer(),

319 value=self.distress_score(DEPRESSIVE),

320 comment=(

321 "Distress score for depressive symptom questions "

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

323 ),

324 ),

325 SummaryElement(

326 name="wt_all_freq",

327 coltype=Float(),

328 value=self.weighted_frequency_score(ALL),

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

330 ),

331 SummaryElement(

332 name="wt_all_distress",

333 coltype=Float(),

334 value=self.weighted_distress_score(ALL),

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

336 ),

337 SummaryElement(

338 name="wt_pos_freq",

339 coltype=Float(),

340 value=self.weighted_frequency_score(POSITIVE),

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

342 ),

343 SummaryElement(

344 name="wt_pos_distress",

345 coltype=Float(),

346 value=self.weighted_distress_score(POSITIVE),

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

348 ),

349 SummaryElement(

350 name="wt_neg_freq",

351 coltype=Float(),

352 value=self.weighted_frequency_score(NEGATIVE),

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

354 ),

355 SummaryElement(

356 name="wt_neg_distress",

357 coltype=Float(),

358 value=self.weighted_distress_score(NEGATIVE),

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

360 ),

361 SummaryElement(

362 name="wt_dep_freq",

363 coltype=Float(),

364 value=self.weighted_frequency_score(DEPRESSIVE),

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

366 ),

367 SummaryElement(

368 name="wt_dep_distress",

369 coltype=Float(),

370 value=self.weighted_distress_score(DEPRESSIVE),

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

372 ),

373 ]

374

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

376 f = self.get_frequency(q)

377 if f is None:

378 return False

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

380 return False

381 return True

382

383 def is_complete(self) -> bool:

384 if not self.field_contents_valid():

385 return False

386 for q in ALL:

387 if not self.is_question_complete(q):

388 return False

389 return True

390

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

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

393

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

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

396

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

398 if not self.endorsed(q):

399 return MIN_SCORE_PER_Q

400 return self.get_distress(q)

401

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

403 f = self.get_frequency(q)

404 return f is not None and f > MIN_SCORE_PER_Q

405

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

407 score = 0

408 for q in qlist:

409 d = self.get_distress_score(q)

410 if d is not None:

411 score += d

412 return score

413

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

415 score = 0

416 for q in qlist:

417 f = self.get_frequency(q)

418 if f is not None:

419 score += f

420 return score

421

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

423 score = 0

424 n = 0

425 for q in qlist:

426 f = self.get_frequency(q)

427 if f is not None:

428 score += f

429 n += 1

430 if n == 0:

431 return None

432 return score / n

433

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

435 score = 0

436 n = 0

437 for q in qlist:

438 f = self.get_frequency(q)

439 d = self.get_distress_score(q)

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

441 score += d

442 n += 1

443 if n == 0:

444 return None

445 return score / n

446

447 @staticmethod

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

449 if q in POSITIVE:

450 return "P"

451 if q in NEGATIVE:

452 return "N"

453 if q in DEPRESSIVE:

454 return "D"

455 return "?"

456

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

458 q_a = ""

459 for q in ALL:

460 q_a += tr(

461 f"{q}. "

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

463 + " (<i>"

464 + self.question_category(q)

465 + "</i>)",

466 answer(self.get_frequency(q)),

467 answer(

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

469 default=str(MIN_SCORE_PER_Q),

470 ),

471 )

472

473 raw_overall = tr(

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

475 self.frequency_score(ALL),

476 self.distress_score(ALL),

477 )

478 raw_positive = tr(

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

480 self.frequency_score(POSITIVE),

481 self.distress_score(POSITIVE),

482 )

483 raw_negative = tr(

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

485 self.frequency_score(NEGATIVE),

486 self.distress_score(NEGATIVE),

487 )

488 raw_depressive = tr(

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

490 self.frequency_score(DEPRESSIVE),

491 self.distress_score(DEPRESSIVE),

492 )

493 weighted_overall = tr(

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

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

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

497 )

498 weighted_positive = tr(

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

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

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

502 )

503 weighted_negative = tr(

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

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

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

507 )

508 weighted_depressive = tr(

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

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

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

512 )

513 return f"""

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

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

516 {self.get_is_complete_tr(req)}

517 </table>

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

519 <tr>

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

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

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

523 </tr>

524 {raw_overall}

525 {raw_positive}

526 {raw_negative}

527 {raw_depressive}

528 </table>

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

530 <tr>

531 <th>Domain</th>

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

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

534 </tr>

535 {weighted_overall}

536 {weighted_positive}

537 {weighted_negative}

538 {weighted_depressive}

539 </table>

540 </div>

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

542 FREQUENCY:

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

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

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

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

547 DISTRESS:

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

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

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

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

552 </div>

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

554 <tr>

555 <th width="70%">

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

557 </th>

558 <th width="15%">Frequency

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

560 <th width="15%">Distress

561 ({MIN_SCORE_PER_Q}–{MAX_SCORE_PER_Q})

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

563 </tr>

564 {q_a}

565 </table>

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

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

568 frequency scores for all questions).

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

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

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

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

573 calculable.

574 </div>

575 """