Coverage for tasks/rand36.py: 36%

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

32from cardinal_pythonlib.maths_py import mean 

33from cardinal_pythonlib.stringfunc import strseq 

34from sqlalchemy.ext.declarative import DeclarativeMeta 

35from sqlalchemy.sql.sqltypes import Float, Integer 

36 

37from camcops_server.cc_modules.cc_constants import CssClass 

38from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

39from camcops_server.cc_modules.cc_db import add_multiple_columns 

40from camcops_server.cc_modules.cc_html import answer, identity, tr, tr_span_col 

41from camcops_server.cc_modules.cc_request import CamcopsRequest 

42from camcops_server.cc_modules.cc_sqla_coltypes import ( 

43 CamcopsColumn, 

44 ONE_TO_FIVE_CHECKER, 

45 ONE_TO_SIX_CHECKER, 

46) 

47from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

48from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin 

49from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

50 

51 

52# ============================================================================= 

53# RAND-36 

54# ============================================================================= 

55 

56 

57class Rand36Metaclass(DeclarativeMeta): 

58 # noinspection PyInitNewSignature 

59 def __init__( 

60 cls: Type["Rand36"], 

61 name: str, 

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

63 classdict: Dict[str, Any], 

64 ) -> None: 

65 add_multiple_columns( 

66 cls, 

67 "q", 

68 3, 

69 12, 

70 minimum=1, 

71 maximum=3, 

72 comment_fmt="Q{n} ({s}) (1 limited a lot - 3 not limited at all)", 

73 comment_strings=[ 

74 "Vigorous activities", 

75 "Moderate activities", 

76 "Lifting or carrying groceries", 

77 "Climbing several flights of stairs", 

78 "Climbing one flight of stairs", 

79 "Bending, kneeling, or stooping", 

80 "Walking more than a mile", 

81 "Walking several blocks", 

82 "Walking one block", 

83 "Bathing or dressing yourself", 

84 ], 

85 ) 

86 add_multiple_columns( 

87 cls, 

88 "q", 

89 13, 

90 16, 

91 minimum=1, 

92 maximum=2, 

93 comment_fmt="Q{n} (physical health: {s}) (1 yes, 2 no)", 

94 comment_strings=[ 

95 "Cut down work/other activities", 

96 "Accomplished less than would like", 

97 "Were limited in the kind of work or other activities", 

98 "Had difficulty performing the work or other activities", 

99 ], 

100 ) 

101 add_multiple_columns( 

102 cls, 

103 "q", 

104 17, 

105 19, 

106 minimum=1, 

107 maximum=2, 

108 comment_fmt="Q{n} (emotional problems: {s}) (1 yes, 2 no)", 

109 comment_strings=[ 

110 "Cut down work/other activities", 

111 "Accomplished less than would like", 

112 "Didn't do work or other activities as carefully as usual", 

113 "Had difficulty performing the work or other activities", 

114 ], 

115 ) 

116 add_multiple_columns( 

117 cls, 

118 "q", 

119 23, 

120 31, 

121 minimum=1, 

122 maximum=6, 

123 comment_fmt="Q{n} (past 4 weeks: {s}) (1 all of the time - " 

124 "6 none of the time)", 

125 comment_strings=[ 

126 "Did you feel full of pep?", 

127 "Have you been a very nervous person?", 

128 "Have you felt so down in the dumps that nothing could cheer " 

129 "you up?", 

130 "Have you felt calm and peaceful?", 

131 "Did you have a lot of energy?", 

132 "Have you felt downhearted and blue?", 

133 "Did you feel worn out?", 

134 "Have you been a happy person?", 

135 "Did you feel tired?", 

136 ], 

137 ) 

138 add_multiple_columns( 

139 cls, 

140 "q", 

141 33, 

142 36, 

143 minimum=1, 

144 maximum=5, 

145 comment_fmt="Q{n} (how true/false: {s}) (1 definitely true - " 

146 "5 definitely false)", 

147 comment_strings=[ 

148 "I seem to get sick a little easier than other people", 

149 "I am as healthy as anybody I know", 

150 "I expect my health to get worse", 

151 "My health is excellent", 

152 ], 

153 ) 

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

155 

156 

157class Rand36(TaskHasPatientMixin, Task, metaclass=Rand36Metaclass): 

158 """ 

159 Server implementation of the RAND-36 task. 

160 """ 

161 

162 __tablename__ = "rand36" 

163 shortname = "RAND-36" 

164 provides_trackers = True 

165 

166 NQUESTIONS = 36 

167 

168 q1 = CamcopsColumn( 

169 "q1", 

170 Integer, 

171 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

172 comment="Q1 (general health) (1 excellent - 5 poor)", 

173 ) 

174 q2 = CamcopsColumn( 

175 "q2", 

176 Integer, 

177 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

178 comment="Q2 (health cf. 1y ago) (1 much better - 5 much worse)", 

179 ) 

180 

181 q20 = CamcopsColumn( 

182 "q20", 

183 Integer, 

184 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

185 comment="Q20 (past 4 weeks, to what extent physical health/" 

186 "emotional problems interfered with social activity) " 

187 "(1 not at all - 5 extremely)", 

188 ) 

189 q21 = CamcopsColumn( 

190 "q21", 

191 Integer, 

192 permitted_value_checker=ONE_TO_SIX_CHECKER, 

193 comment="Q21 (past 4 weeks, how much pain (1 none - 6 very severe)", 

194 ) 

195 q22 = CamcopsColumn( 

196 "q22", 

197 Integer, 

198 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

199 comment="Q22 (past 4 weeks, pain interfered with normal activity " 

200 "(1 not at all - 5 extremely)", 

201 ) 

202 

203 q32 = CamcopsColumn( 

204 "q32", 

205 Integer, 

206 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

207 comment="Q32 (past 4 weeks, how much of the time has physical " 

208 "health/emotional problems interfered with social activities " 

209 "(1 all of the time - 5 none of the time)", 

210 ) 

211 # ... note Q32 extremely similar to Q20. 

212 

213 TASK_FIELDS = strseq("q", 1, NQUESTIONS) 

214 

215 @staticmethod 

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

217 _ = req.gettext 

218 return _("RAND 36-Item Short Form Health Survey 1.0") 

219 

220 def is_complete(self) -> bool: 

221 return ( 

222 self.all_fields_not_none(self.TASK_FIELDS) 

223 and self.field_contents_valid() 

224 ) 

225 

226 @classmethod 

227 def tracker_element(cls, value: float, plot_label: str) -> TrackerInfo: 

228 return TrackerInfo( 

229 value=value, 

230 plot_label="RAND-36: " + plot_label, 

231 axis_label="Scale score (out of 100)", 

232 axis_min=-0.5, 

233 axis_max=100.5, 

234 ) 

235 

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

237 return [ 

238 self.tracker_element( 

239 self.score_overall(), self.wxstring(req, "score_overall") 

240 ), 

241 self.tracker_element( 

242 self.score_physical_functioning(), 

243 self.wxstring(req, "score_physical_functioning"), 

244 ), 

245 self.tracker_element( 

246 self.score_role_limitations_physical(), 

247 self.wxstring(req, "score_role_limitations_physical"), 

248 ), 

249 self.tracker_element( 

250 self.score_role_limitations_emotional(), 

251 self.wxstring(req, "score_role_limitations_emotional"), 

252 ), 

253 self.tracker_element( 

254 self.score_energy(), self.wxstring(req, "score_energy") 

255 ), 

256 self.tracker_element( 

257 self.score_emotional_wellbeing(), 

258 self.wxstring(req, "score_emotional_wellbeing"), 

259 ), 

260 self.tracker_element( 

261 self.score_social_functioning(), 

262 self.wxstring(req, "score_social_functioning"), 

263 ), 

264 self.tracker_element( 

265 self.score_pain(), self.wxstring(req, "score_pain") 

266 ), 

267 self.tracker_element( 

268 self.score_general_health(), 

269 self.wxstring(req, "score_general_health"), 

270 ), 

271 ] 

272 

273 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]: 

274 if not self.is_complete(): 

275 return CTV_INCOMPLETE 

276 return [ 

277 CtvInfo( 

278 content=( 

279 "RAND-36 (scores out of 100, 100 best): overall {ov}, " 

280 "physical functioning {pf}, physical role " 

281 "limitations {prl}, emotional role limitations {erl}, " 

282 "energy {e}, emotional wellbeing {ew}, social " 

283 "functioning {sf}, pain {p}, general health {gh}.".format( 

284 ov=self.score_overall(), 

285 pf=self.score_physical_functioning(), 

286 prl=self.score_role_limitations_physical(), 

287 erl=self.score_role_limitations_emotional(), 

288 e=self.score_energy(), 

289 ew=self.score_emotional_wellbeing(), 

290 sf=self.score_social_functioning(), 

291 p=self.score_pain(), 

292 gh=self.score_general_health(), 

293 ) 

294 ) 

295 ) 

296 ] 

297 

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

299 return self.standard_task_summary_fields() + [ 

300 SummaryElement( 

301 name="overall", 

302 coltype=Float(), 

303 value=self.score_overall(), 

304 comment="Overall mean score (0-100, higher better)", 

305 ), 

306 SummaryElement( 

307 name="physical_functioning", 

308 coltype=Float(), 

309 value=self.score_physical_functioning(), 

310 comment="Physical functioning score (0-100, higher better)", 

311 ), 

312 SummaryElement( 

313 name="role_limitations_physical", 

314 coltype=Float(), 

315 value=self.score_role_limitations_physical(), 

316 comment="Role limitations due to physical health score " 

317 "(0-100, higher better)", 

318 ), 

319 SummaryElement( 

320 name="role_limitations_emotional", 

321 coltype=Float(), 

322 value=self.score_role_limitations_emotional(), 

323 comment="Role limitations due to emotional problems score " 

324 "(0-100, higher better)", 

325 ), 

326 SummaryElement( 

327 name="energy", 

328 coltype=Float(), 

329 value=self.score_energy(), 

330 comment="Energy/fatigue score (0-100, higher better)", 

331 ), 

332 SummaryElement( 

333 name="emotional_wellbeing", 

334 coltype=Float(), 

335 value=self.score_emotional_wellbeing(), 

336 comment="Emotional well-being score (0-100, higher better)", 

337 ), 

338 SummaryElement( 

339 name="social_functioning", 

340 coltype=Float(), 

341 value=self.score_social_functioning(), 

342 comment="Social functioning score (0-100, higher better)", 

343 ), 

344 SummaryElement( 

345 name="pain", 

346 coltype=Float(), 

347 value=self.score_pain(), 

348 comment="Pain score (0-100, higher better)", 

349 ), 

350 SummaryElement( 

351 name="general_health", 

352 coltype=Float(), 

353 value=self.score_general_health(), 

354 comment="General health score (0-100, higher better)", 

355 ), 

356 ] 

357 

358 # Scoring 

359 def recode(self, q: int) -> Optional[float]: 

360 x = getattr(self, "q" + str(q)) # response 

361 if x is None or x < 1: 

362 return None 

363 # http://m.rand.org/content/dam/rand/www/external/health/ 

364 # surveys_tools/mos/mos_core_36item_scoring.pdf 

365 if q == 1 or q == 2 or q == 20 or q == 22 or q == 34 or q == 36: 

366 # 1 becomes 100, 2 => 75, 3 => 50, 4 =>25, 5 => 0 

367 if x > 5: 

368 return None 

369 return 100 - 25 * (x - 1) 

370 elif 3 <= q <= 12: 

371 # 1 => 0, 2 => 50, 3 => 100 

372 if x > 3: 

373 return None 

374 return 50 * (x - 1) 

375 elif 13 <= q <= 19: 

376 # 1 => 0, 2 => 100 

377 if x > 2: 

378 return None 

379 return 100 * (x - 1) 

380 elif q == 21 or q == 23 or q == 26 or q == 27 or q == 30: 

381 # 1 => 100, 2 => 80, 3 => 60, 4 => 40, 5 => 20, 6 => 0 

382 if x > 6: 

383 return None 

384 return 100 - 20 * (x - 1) 

385 elif q == 24 or q == 25 or q == 28 or q == 29 or q == 31: 

386 # 1 => 0, 2 => 20, 3 => 40, 4 => 60, 5 => 80, 6 => 100 

387 if x > 6: 

388 return None 

389 return 20 * (x - 1) 

390 elif q == 32 or q == 33 or q == 35: 

391 # 1 => 0, 2 => 25, 3 => 50, 4 => 75, 5 => 100 

392 if x > 5: 

393 return None 

394 return 25 * (x - 1) 

395 return None 

396 

397 def score_physical_functioning(self) -> Optional[float]: 

398 return mean( 

399 [ 

400 self.recode(3), 

401 self.recode(4), 

402 self.recode(5), 

403 self.recode(6), 

404 self.recode(7), 

405 self.recode(8), 

406 self.recode(9), 

407 self.recode(10), 

408 self.recode(11), 

409 self.recode(12), 

410 ] 

411 ) 

412 

413 def score_role_limitations_physical(self) -> Optional[float]: 

414 return mean( 

415 [ 

416 self.recode(13), 

417 self.recode(14), 

418 self.recode(15), 

419 self.recode(16), 

420 ] 

421 ) 

422 

423 def score_role_limitations_emotional(self) -> Optional[float]: 

424 return mean([self.recode(17), self.recode(18), self.recode(19)]) 

425 

426 def score_energy(self) -> Optional[float]: 

427 return mean( 

428 [ 

429 self.recode(23), 

430 self.recode(27), 

431 self.recode(29), 

432 self.recode(31), 

433 ] 

434 ) 

435 

436 def score_emotional_wellbeing(self) -> Optional[float]: 

437 return mean( 

438 [ 

439 self.recode(24), 

440 self.recode(25), 

441 self.recode(26), 

442 self.recode(28), 

443 self.recode(30), 

444 ] 

445 ) 

446 

447 def score_social_functioning(self) -> Optional[float]: 

448 return mean([self.recode(20), self.recode(32)]) 

449 

450 def score_pain(self) -> Optional[float]: 

451 return mean([self.recode(21), self.recode(22)]) 

452 

453 def score_general_health(self) -> Optional[float]: 

454 return mean( 

455 [ 

456 self.recode(1), 

457 self.recode(33), 

458 self.recode(34), 

459 self.recode(35), 

460 self.recode(36), 

461 ] 

462 ) 

463 

464 @staticmethod 

465 def format_float_for_display(val: Optional[float]) -> Optional[str]: 

466 if val is None: 

467 return None 

468 return f"{val:.1f}" 

469 

470 def score_overall(self) -> Optional[float]: 

471 values = [] 

472 for q in range(1, self.NQUESTIONS + 1): 

473 values.append(self.recode(q)) 

474 return mean(values) 

475 

476 @staticmethod 

477 def section_row_html(text: str) -> str: 

478 return tr_span_col(text, cols=3, tr_class=CssClass.SUBHEADING) 

479 

480 def answer_text( 

481 self, req: CamcopsRequest, q: int, v: Any 

482 ) -> Optional[str]: 

483 if v is None: 

484 return None 

485 # wxstring has its own validity checking, so we can do: 

486 if q == 1 or q == 2 or (20 <= q <= 22) or q == 32: 

487 return self.wxstring(req, "q" + str(q) + "_option" + str(v)) 

488 elif 3 <= q <= 12: 

489 return self.wxstring(req, "activities_option" + str(v)) 

490 elif 13 <= q <= 19: 

491 return self.wxstring(req, "yesno_option" + str(v)) 

492 elif 23 <= q <= 31: 

493 return self.wxstring(req, "last4weeks_option" + str(v)) 

494 elif 33 <= q <= 36: 

495 return self.wxstring(req, "q33to36_option" + str(v)) 

496 else: 

497 return None 

498 

499 def answer_row_html(self, req: CamcopsRequest, q: int) -> str: 

500 qtext = self.wxstring(req, "q" + str(q)) 

501 v = getattr(self, "q" + str(q)) 

502 atext = self.answer_text(req, q, v) 

503 s = self.recode(q) 

504 return tr( 

505 qtext, 

506 answer(v) + ": " + answer(atext), 

507 answer(s, formatter_answer=identity), 

508 ) 

509 

510 @staticmethod 

511 def scoreline(text: str, footnote_num: int, score: Optional[float]) -> str: 

512 return tr( 

513 text + f" <sup>[{footnote_num}]</sup>", answer(score) + " / 100" 

514 ) 

515 

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

517 h = f""" 

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

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

520 {self.get_is_complete_tr(req)} 

521 """ 

522 h += self.scoreline( 

523 self.wxstring(req, "score_overall"), 

524 1, 

525 self.format_float_for_display(self.score_overall()), 

526 ) 

527 h += self.scoreline( 

528 self.wxstring(req, "score_physical_functioning"), 

529 2, 

530 self.format_float_for_display(self.score_physical_functioning()), 

531 ) 

532 h += self.scoreline( 

533 self.wxstring(req, "score_role_limitations_physical"), 

534 3, 

535 self.format_float_for_display( 

536 self.score_role_limitations_physical() 

537 ), 

538 ) 

539 h += self.scoreline( 

540 self.wxstring(req, "score_role_limitations_emotional"), 

541 4, 

542 self.format_float_for_display( 

543 self.score_role_limitations_emotional() 

544 ), 

545 ) 

546 h += self.scoreline( 

547 self.wxstring(req, "score_energy"), 

548 5, 

549 self.format_float_for_display(self.score_energy()), 

550 ) 

551 h += self.scoreline( 

552 self.wxstring(req, "score_emotional_wellbeing"), 

553 6, 

554 self.format_float_for_display(self.score_emotional_wellbeing()), 

555 ) 

556 h += self.scoreline( 

557 self.wxstring(req, "score_social_functioning"), 

558 7, 

559 self.format_float_for_display(self.score_social_functioning()), 

560 ) 

561 h += self.scoreline( 

562 self.wxstring(req, "score_pain"), 

563 8, 

564 self.format_float_for_display(self.score_pain()), 

565 ) 

566 h += self.scoreline( 

567 self.wxstring(req, "score_general_health"), 

568 9, 

569 self.format_float_for_display(self.score_general_health()), 

570 ) 

571 h += f""" 

572 </table> 

573 </div> 

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

575 <tr> 

576 <th width="60%">Question</th> 

577 <th width="30%">Answer</th> 

578 <th width="10%">Score</th> 

579 </tr> 

580 """ 

581 for q in range(1, 2 + 1): 

582 h += self.answer_row_html(req, q) 

583 h += self.section_row_html(self.wxstring(req, "activities_q")) 

584 for q in range(3, 12 + 1): 

585 h += self.answer_row_html(req, q) 

586 h += self.section_row_html( 

587 self.wxstring(req, "work_activities_physical_q") 

588 ) 

589 for q in range(13, 16 + 1): 

590 h += self.answer_row_html(req, q) 

591 h += self.section_row_html( 

592 self.wxstring(req, "work_activities_emotional_q") 

593 ) 

594 for q in range(17, 19 + 1): 

595 h += self.answer_row_html(req, q) 

596 h += self.section_row_html("<br>") 

597 h += self.answer_row_html(req, 20) 

598 h += self.section_row_html("<br>") 

599 for q in range(21, 22 + 1): 

600 h += self.answer_row_html(req, q) 

601 h += self.section_row_html( 

602 self.wxstring(req, "last4weeks_q_a") 

603 + " " 

604 + self.wxstring(req, "last4weeks_q_b") 

605 ) 

606 for q in range(23, 31 + 1): 

607 h += self.answer_row_html(req, q) 

608 h += self.section_row_html("<br>") 

609 for q in (32,): 

610 h += self.answer_row_html(req, q) 

611 h += self.section_row_html(self.wxstring(req, "q33to36stem")) 

612 for q in range(33, 36 + 1): 

613 h += self.answer_row_html(req, q) 

614 h += f""" 

615 </table> 

616 <div class="{CssClass.COPYRIGHT}"> 

617 The RAND 36-Item Short Form Health Survey was developed at RAND 

618 as part of the Medical Outcomes Study. See 

619 <a href="https://www.rand.org/health/surveys_tools/mos/mos_core_36item.html"> 

620 https://www.rand.org/health/surveys_tools/mos/mos_core_36item.html</a> 

621 </div> 

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

623 All questions are first transformed to a score in the range 

624 0–100. Higher scores are always better. Then: 

625 [1] Mean of all 36 questions. 

626 [2] Mean of Q3–12 inclusive. 

627 [3] Q13–16. 

628 [4] Q17–19. 

629 [5] Q23, 27, 29, 31. 

630 [6] Q24, 25, 26, 28, 30. 

631 [7] Q20, 32. 

632 [8] Q21, 22. 

633 [9] Q1, 33–36. 

634 </div> 

635 """ # noqa 

636 return h