Coverage for tasks/rand36.py: 35%

170 statements  

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

1""" 

2camcops_server/tasks/rand36.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, List, Optional, Type 

29 

30from cardinal_pythonlib.maths_py import mean 

31from cardinal_pythonlib.stringfunc import strseq 

32from sqlalchemy.orm import Mapped 

33from sqlalchemy.sql.sqltypes import Float 

34 

35from camcops_server.cc_modules.cc_constants import CssClass 

36from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

37from camcops_server.cc_modules.cc_db import add_multiple_columns 

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

39from camcops_server.cc_modules.cc_request import CamcopsRequest 

40from camcops_server.cc_modules.cc_sqla_coltypes import ( 

41 mapped_camcops_column, 

42 ONE_TO_FIVE_CHECKER, 

43 ONE_TO_SIX_CHECKER, 

44) 

45from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

46from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin 

47from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

48 

49 

50# ============================================================================= 

51# RAND-36 

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

53 

54 

55class Rand36( # type: ignore[misc] 

56 TaskHasPatientMixin, 

57 Task, 

58): 

59 """ 

60 Server implementation of the RAND-36 task. 

61 """ 

62 

63 __tablename__ = "rand36" 

64 shortname = "RAND-36" 

65 provides_trackers = True 

66 

67 NQUESTIONS = 36 

68 

69 @classmethod 

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

71 add_multiple_columns( 

72 cls, 

73 "q", 

74 3, 

75 12, 

76 minimum=1, 

77 maximum=3, 

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

79 comment_strings=[ 

80 "Vigorous activities", 

81 "Moderate activities", 

82 "Lifting or carrying groceries", 

83 "Climbing several flights of stairs", 

84 "Climbing one flight of stairs", 

85 "Bending, kneeling, or stooping", 

86 "Walking more than a mile", 

87 "Walking several blocks", 

88 "Walking one block", 

89 "Bathing or dressing yourself", 

90 ], 

91 ) 

92 add_multiple_columns( 

93 cls, 

94 "q", 

95 13, 

96 16, 

97 minimum=1, 

98 maximum=2, 

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

100 comment_strings=[ 

101 "Cut down work/other activities", 

102 "Accomplished less than would like", 

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

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

105 ], 

106 ) 

107 add_multiple_columns( 

108 cls, 

109 "q", 

110 17, 

111 19, 

112 minimum=1, 

113 maximum=2, 

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

115 comment_strings=[ 

116 "Cut down work/other activities", 

117 "Accomplished less than would like", 

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

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

120 ], 

121 ) 

122 add_multiple_columns( 

123 cls, 

124 "q", 

125 23, 

126 31, 

127 minimum=1, 

128 maximum=6, 

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

130 "6 none of the time)", 

131 comment_strings=[ 

132 "Did you feel full of pep?", 

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

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

135 "you up?", 

136 "Have you felt calm and peaceful?", 

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

138 "Have you felt downhearted and blue?", 

139 "Did you feel worn out?", 

140 "Have you been a happy person?", 

141 "Did you feel tired?", 

142 ], 

143 ) 

144 add_multiple_columns( 

145 cls, 

146 "q", 

147 33, 

148 36, 

149 minimum=1, 

150 maximum=5, 

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

152 "5 definitely false)", 

153 comment_strings=[ 

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

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

156 "I expect my health to get worse", 

157 "My health is excellent", 

158 ], 

159 ) 

160 

161 q1: Mapped[Optional[int]] = mapped_camcops_column( 

162 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

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

164 ) 

165 q2: Mapped[Optional[int]] = mapped_camcops_column( 

166 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

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

168 ) 

169 

170 q20: Mapped[Optional[int]] = mapped_camcops_column( 

171 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

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

173 "emotional problems interfered with social activity) " 

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

175 ) 

176 q21: Mapped[Optional[int]] = mapped_camcops_column( 

177 permitted_value_checker=ONE_TO_SIX_CHECKER, 

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

179 ) 

180 q22: Mapped[Optional[int]] = mapped_camcops_column( 

181 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

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

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

184 ) 

185 

186 q32: Mapped[Optional[int]] = mapped_camcops_column( 

187 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

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

189 "health/emotional problems interfered with social activities " 

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

191 ) 

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

193 

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

195 

196 @staticmethod 

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

198 _ = req.gettext 

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

200 

201 def is_complete(self) -> bool: 

202 return ( 

203 self.all_fields_not_none(self.TASK_FIELDS) 

204 and self.field_contents_valid() 

205 ) 

206 

207 @classmethod 

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

209 return TrackerInfo( 

210 value=value, 

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

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

213 axis_min=-0.5, 

214 axis_max=100.5, 

215 ) 

216 

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

218 return [ 

219 self.tracker_element( 

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

221 ), 

222 self.tracker_element( 

223 self.score_physical_functioning(), 

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

225 ), 

226 self.tracker_element( 

227 self.score_role_limitations_physical(), 

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

229 ), 

230 self.tracker_element( 

231 self.score_role_limitations_emotional(), 

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

233 ), 

234 self.tracker_element( 

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

236 ), 

237 self.tracker_element( 

238 self.score_emotional_wellbeing(), 

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

240 ), 

241 self.tracker_element( 

242 self.score_social_functioning(), 

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

244 ), 

245 self.tracker_element( 

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

247 ), 

248 self.tracker_element( 

249 self.score_general_health(), 

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

251 ), 

252 ] 

253 

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

255 if not self.is_complete(): 

256 return CTV_INCOMPLETE 

257 return [ 

258 CtvInfo( 

259 content=( 

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

261 "physical functioning {pf}, physical role " 

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

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

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

265 ov=self.score_overall(), 

266 pf=self.score_physical_functioning(), 

267 prl=self.score_role_limitations_physical(), 

268 erl=self.score_role_limitations_emotional(), 

269 e=self.score_energy(), 

270 ew=self.score_emotional_wellbeing(), 

271 sf=self.score_social_functioning(), 

272 p=self.score_pain(), 

273 gh=self.score_general_health(), 

274 ) 

275 ) 

276 ) 

277 ] 

278 

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

280 return self.standard_task_summary_fields() + [ 

281 SummaryElement( 

282 name="overall", 

283 coltype=Float(), 

284 value=self.score_overall(), 

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

286 ), 

287 SummaryElement( 

288 name="physical_functioning", 

289 coltype=Float(), 

290 value=self.score_physical_functioning(), 

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

292 ), 

293 SummaryElement( 

294 name="role_limitations_physical", 

295 coltype=Float(), 

296 value=self.score_role_limitations_physical(), 

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

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

299 ), 

300 SummaryElement( 

301 name="role_limitations_emotional", 

302 coltype=Float(), 

303 value=self.score_role_limitations_emotional(), 

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

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

306 ), 

307 SummaryElement( 

308 name="energy", 

309 coltype=Float(), 

310 value=self.score_energy(), 

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

312 ), 

313 SummaryElement( 

314 name="emotional_wellbeing", 

315 coltype=Float(), 

316 value=self.score_emotional_wellbeing(), 

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

318 ), 

319 SummaryElement( 

320 name="social_functioning", 

321 coltype=Float(), 

322 value=self.score_social_functioning(), 

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

324 ), 

325 SummaryElement( 

326 name="pain", 

327 coltype=Float(), 

328 value=self.score_pain(), 

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

330 ), 

331 SummaryElement( 

332 name="general_health", 

333 coltype=Float(), 

334 value=self.score_general_health(), 

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

336 ), 

337 ] 

338 

339 # Scoring 

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

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

342 if x is None or x < 1: 

343 return None 

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

345 # surveys_tools/mos/mos_core_36item_scoring.pdf 

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

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

348 if x > 5: 

349 return None 

350 return 100 - 25 * (x - 1) 

351 elif 3 <= q <= 12: 

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

353 if x > 3: 

354 return None 

355 return 50 * (x - 1) 

356 elif 13 <= q <= 19: 

357 # 1 => 0, 2 => 100 

358 if x > 2: 

359 return None 

360 return 100 * (x - 1) 

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

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

363 if x > 6: 

364 return None 

365 return 100 - 20 * (x - 1) 

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

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

368 if x > 6: 

369 return None 

370 return 20 * (x - 1) 

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

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

373 if x > 5: 

374 return None 

375 return 25 * (x - 1) 

376 return None 

377 

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

379 return mean( 

380 [ 

381 self.recode(3), 

382 self.recode(4), 

383 self.recode(5), 

384 self.recode(6), 

385 self.recode(7), 

386 self.recode(8), 

387 self.recode(9), 

388 self.recode(10), 

389 self.recode(11), 

390 self.recode(12), 

391 ] 

392 ) 

393 

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

395 return mean( 

396 [ 

397 self.recode(13), 

398 self.recode(14), 

399 self.recode(15), 

400 self.recode(16), 

401 ] 

402 ) 

403 

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

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

406 

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

408 return mean( 

409 [ 

410 self.recode(23), 

411 self.recode(27), 

412 self.recode(29), 

413 self.recode(31), 

414 ] 

415 ) 

416 

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

418 return mean( 

419 [ 

420 self.recode(24), 

421 self.recode(25), 

422 self.recode(26), 

423 self.recode(28), 

424 self.recode(30), 

425 ] 

426 ) 

427 

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

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

430 

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

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

433 

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

435 return mean( 

436 [ 

437 self.recode(1), 

438 self.recode(33), 

439 self.recode(34), 

440 self.recode(35), 

441 self.recode(36), 

442 ] 

443 ) 

444 

445 @staticmethod 

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

447 if val is None: 

448 return None 

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

450 

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

452 values = [] 

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

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

455 return mean(values) 

456 

457 @staticmethod 

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

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

460 

461 def answer_text( 

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

463 ) -> Optional[str]: 

464 if v is None: 

465 return None 

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

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

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

469 elif 3 <= q <= 12: 

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

471 elif 13 <= q <= 19: 

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

473 elif 23 <= q <= 31: 

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

475 elif 33 <= q <= 36: 

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

477 else: 

478 return None 

479 

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

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

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

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

484 s = self.recode(q) 

485 return tr( 

486 qtext, 

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

488 answer(s, formatter_answer=identity), 

489 ) 

490 

491 @staticmethod 

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

493 return tr( 

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

495 ) 

496 

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

498 h = f""" 

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

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

501 {self.get_is_complete_tr(req)} 

502 """ 

503 h += self.scoreline( 

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

505 1, 

506 self.format_float_for_display(self.score_overall()), # type: ignore[arg-type] # noqa: E501 

507 ) 

508 h += self.scoreline( 

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

510 2, 

511 self.format_float_for_display(self.score_physical_functioning()), # type: ignore[arg-type] # noqa: E501 

512 ) 

513 h += self.scoreline( 

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

515 3, 

516 self.format_float_for_display( # type: ignore[arg-type] 

517 self.score_role_limitations_physical() 

518 ), 

519 ) 

520 h += self.scoreline( 

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

522 4, 

523 self.format_float_for_display( # type: ignore[arg-type] 

524 self.score_role_limitations_emotional() 

525 ), 

526 ) 

527 h += self.scoreline( 

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

529 5, 

530 self.format_float_for_display(self.score_energy()), # type: ignore[arg-type] # noqa: E501 

531 ) 

532 h += self.scoreline( 

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

534 6, 

535 self.format_float_for_display(self.score_emotional_wellbeing()), # type: ignore[arg-type] # noqa: E501 

536 ) 

537 h += self.scoreline( 

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

539 7, 

540 self.format_float_for_display(self.score_social_functioning()), # type: ignore[arg-type] # noqa: E501 

541 ) 

542 h += self.scoreline( 

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

544 8, 

545 self.format_float_for_display(self.score_pain()), # type: ignore[arg-type] # noqa: E501 

546 ) 

547 h += self.scoreline( 

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

549 9, 

550 self.format_float_for_display(self.score_general_health()), # type: ignore[arg-type] # noqa: E501 

551 ) 

552 h += f""" 

553 </table> 

554 </div> 

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

556 <tr> 

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

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

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

560 </tr> 

561 """ 

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

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

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

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

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

567 h += self.section_row_html( 

568 self.wxstring(req, "work_activities_physical_q") 

569 ) 

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

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

572 h += self.section_row_html( 

573 self.wxstring(req, "work_activities_emotional_q") 

574 ) 

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

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

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

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

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

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

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

582 h += self.section_row_html( 

583 self.wxstring(req, "last4weeks_q_a") 

584 + " " 

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

586 ) 

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

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

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

590 for q in (32,): 

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

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

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

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

595 h += f""" 

596 </table> 

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

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

599 as part of the Medical Outcomes Study. See 

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

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

602 </div> 

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

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

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

606 [1] Mean of all 36 questions. 

607 [2] Mean of Q3–12 inclusive. 

608 [3] Q13–16. 

609 [4] Q17–19. 

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

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

612 [7] Q20, 32. 

613 [8] Q21, 22. 

614 [9] Q1, 33–36. 

615 </div> 

616 """ # noqa 

617 return h