Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/rand36.py 

5 

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

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

12 CamCOPS is free software: you can redistribute it and/or modify 

13 it under the terms of the GNU General Public License as published by 

14 the Free Software Foundation, either version 3 of the License, or 

15 (at your option) any later version. 

16 

17 CamCOPS is distributed in the hope that it will be useful, 

18 but WITHOUT ANY WARRANTY; without even the implied warranty of 

19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

20 GNU General Public License for more details. 

21 

22 You should have received a copy of the GNU General Public License 

23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

24 

25=============================================================================== 

26 

27""" 

28 

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

30 

31from cardinal_pythonlib.maths_py import mean 

32from cardinal_pythonlib.stringfunc import strseq 

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_ctvinfo import CTV_INCOMPLETE, CtvInfo 

38from camcops_server.cc_modules.cc_db import add_multiple_columns 

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

40from camcops_server.cc_modules.cc_request import CamcopsRequest 

41from camcops_server.cc_modules.cc_sqla_coltypes import ( 

42 CamcopsColumn, 

43 ONE_TO_FIVE_CHECKER, 

44 ONE_TO_SIX_CHECKER, 

45) 

46from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

47from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin 

48from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

49 

50 

51# ============================================================================= 

52# RAND-36 

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

54 

55class Rand36Metaclass(DeclarativeMeta): 

56 # noinspection PyInitNewSignature 

57 def __init__(cls: Type['Rand36'], 

58 name: str, 

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

60 classdict: Dict[str, Any]) -> None: 

61 add_multiple_columns( 

62 cls, "q", 3, 12, 

63 minimum=1, maximum=3, 

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

65 comment_strings=[ 

66 "Vigorous activities", 

67 "Moderate activities", 

68 "Lifting or carrying groceries", 

69 "Climbing several flights of stairs", 

70 "Climbing one flight of stairs", 

71 "Bending, kneeling, or stooping", 

72 "Walking more than a mile", 

73 "Walking several blocks", 

74 "Walking one block", 

75 "Bathing or dressing yourself", 

76 ] 

77 ) 

78 add_multiple_columns( 

79 cls, "q", 13, 16, 

80 minimum=1, maximum=2, 

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

82 comment_strings=[ 

83 "Cut down work/other activities", 

84 "Accomplished less than would like", 

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

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

87 ] 

88 ) 

89 add_multiple_columns( 

90 cls, "q", 17, 19, 

91 minimum=1, maximum=2, 

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

93 comment_strings=[ 

94 "Cut down work/other activities", 

95 "Accomplished less than would like", 

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

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

98 ] 

99 ) 

100 add_multiple_columns( 

101 cls, "q", 23, 31, 

102 minimum=1, maximum=6, 

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

104 "6 none of the time)", 

105 comment_strings=[ 

106 "Did you feel full of pep?", 

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

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

109 "you up?", 

110 "Have you felt calm and peaceful?", 

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

112 "Have you felt downhearted and blue?", 

113 "Did you feel worn out?", 

114 "Have you been a happy person?", 

115 "Did you feel tired?", 

116 ] 

117 ) 

118 add_multiple_columns( 

119 cls, "q", 33, 36, 

120 minimum=1, maximum=5, 

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

122 "5 definitely false)", 

123 comment_strings=[ 

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

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

126 "I expect my health to get worse", 

127 "My health is excellent", 

128 ] 

129 ) 

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

131 

132 

133class Rand36(TaskHasPatientMixin, Task, 

134 metaclass=Rand36Metaclass): 

135 """ 

136 Server implementation of the RAND-36 task. 

137 """ 

138 __tablename__ = "rand36" 

139 shortname = "RAND-36" 

140 provides_trackers = True 

141 

142 NQUESTIONS = 36 

143 

144 q1 = CamcopsColumn( 

145 "q1", Integer, 

146 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

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

148 ) 

149 q2 = CamcopsColumn( 

150 "q2", Integer, 

151 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

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

153 ) 

154 

155 q20 = CamcopsColumn( 

156 "q20", Integer, 

157 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

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

159 "emotional problems interfered with social activity) " 

160 "(1 not at all - 5 extremely)" 

161 ) 

162 q21 = CamcopsColumn( 

163 "q21", Integer, 

164 permitted_value_checker=ONE_TO_SIX_CHECKER, 

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

166 ) 

167 q22 = CamcopsColumn( 

168 "q22", Integer, 

169 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

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

171 "(1 not at all - 5 extremely)" 

172 ) 

173 

174 q32 = CamcopsColumn( 

175 "q32", Integer, 

176 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

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

178 "health/emotional problems interfered with social activities " 

179 "(1 all of the time - 5 none of the time)" 

180 ) 

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

182 

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

184 

185 @staticmethod 

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

187 _ = req.gettext 

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

189 

190 def is_complete(self) -> bool: 

191 return ( 

192 self.all_fields_not_none(self.TASK_FIELDS) and 

193 self.field_contents_valid() 

194 ) 

195 

196 @classmethod 

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

198 return TrackerInfo( 

199 value=value, 

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

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

202 axis_min=-0.5, 

203 axis_max=100.5 

204 ) 

205 

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

207 return [ 

208 self.tracker_element( 

209 self.score_overall(), 

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

211 self.tracker_element( 

212 self.score_physical_functioning(), 

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

214 self.tracker_element( 

215 self.score_role_limitations_physical(), 

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

217 self.tracker_element( 

218 self.score_role_limitations_emotional(), 

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

220 self.tracker_element( 

221 self.score_energy(), 

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

223 self.tracker_element( 

224 self.score_emotional_wellbeing(), 

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

226 self.tracker_element( 

227 self.score_social_functioning(), 

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

229 self.tracker_element( 

230 self.score_pain(), 

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

232 self.tracker_element( 

233 self.score_general_health(), 

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

235 ] 

236 

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

238 if not self.is_complete(): 

239 return CTV_INCOMPLETE 

240 return [CtvInfo( 

241 content=( 

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

243 "physical functioning {pf}, physical role " 

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

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

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

247 ov=self.score_overall(), 

248 pf=self.score_physical_functioning(), 

249 prl=self.score_role_limitations_physical(), 

250 erl=self.score_role_limitations_emotional(), 

251 e=self.score_energy(), 

252 ew=self.score_emotional_wellbeing(), 

253 sf=self.score_social_functioning(), 

254 p=self.score_pain(), 

255 gh=self.score_general_health(), 

256 ) 

257 ) 

258 )] 

259 

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

261 return self.standard_task_summary_fields() + [ 

262 SummaryElement( 

263 name="overall", coltype=Float(), 

264 value=self.score_overall(), 

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

266 SummaryElement( 

267 name="physical_functioning", coltype=Float(), 

268 value=self.score_physical_functioning(), 

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

270 SummaryElement( 

271 name="role_limitations_physical", coltype=Float(), 

272 value=self.score_role_limitations_physical(), 

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

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

275 SummaryElement( 

276 name="role_limitations_emotional", coltype=Float(), 

277 value=self.score_role_limitations_emotional(), 

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

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

280 SummaryElement( 

281 name="energy", coltype=Float(), 

282 value=self.score_energy(), 

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

284 SummaryElement( 

285 name="emotional_wellbeing", coltype=Float(), 

286 value=self.score_emotional_wellbeing(), 

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

288 SummaryElement( 

289 name="social_functioning", coltype=Float(), 

290 value=self.score_social_functioning(), 

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

292 SummaryElement( 

293 name="pain", coltype=Float(), 

294 value=self.score_pain(), 

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

296 SummaryElement( 

297 name="general_health", coltype=Float(), 

298 value=self.score_general_health(), 

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

300 ] 

301 

302 # Scoring 

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

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

305 if x is None or x < 1: 

306 return None 

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

308 # surveys_tools/mos/mos_core_36item_scoring.pdf 

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

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

311 if x > 5: 

312 return None 

313 return 100 - 25 * (x - 1) 

314 elif 3 <= q <= 12: 

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

316 if x > 3: 

317 return None 

318 return 50 * (x - 1) 

319 elif 13 <= q <= 19: 

320 # 1 => 0, 2 => 100 

321 if x > 2: 

322 return None 

323 return 100 * (x - 1) 

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

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

326 if x > 6: 

327 return None 

328 return 100 - 20 * (x - 1) 

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

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

331 if x > 6: 

332 return None 

333 return 20 * (x - 1) 

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

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

336 if x > 5: 

337 return None 

338 return 25 * (x - 1) 

339 return None 

340 

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

342 return mean([self.recode(3), self.recode(4), self.recode(5), 

343 self.recode(6), self.recode(7), self.recode(8), 

344 self.recode(9), self.recode(10), self.recode(11), 

345 self.recode(12)]) 

346 

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

348 return mean([self.recode(13), self.recode(14), self.recode(15), 

349 self.recode(16)]) 

350 

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

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

353 

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

355 return mean([self.recode(23), self.recode(27), self.recode(29), 

356 self.recode(31)]) 

357 

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

359 return mean([self.recode(24), self.recode(25), self.recode(26), 

360 self.recode(28), self.recode(30)]) 

361 

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

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

364 

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

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

367 

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

369 return mean([self.recode(1), self.recode(33), self.recode(34), 

370 self.recode(35), self.recode(36)]) 

371 

372 @staticmethod 

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

374 if val is None: 

375 return None 

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

377 

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

379 values = [] 

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

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

382 return mean(values) 

383 

384 @staticmethod 

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

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

387 

388 def answer_text(self, req: CamcopsRequest, 

389 q: int, v: Any) -> Optional[str]: 

390 if v is None: 

391 return None 

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

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

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

395 elif 3 <= q <= 12: 

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

397 elif 13 <= q <= 19: 

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

399 elif 23 <= q <= 31: 

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

401 elif 33 <= q <= 36: 

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

403 else: 

404 return None 

405 

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

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

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

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

410 s = self.recode(q) 

411 return tr( 

412 qtext, 

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

414 answer(s, formatter_answer=identity) 

415 ) 

416 

417 @staticmethod 

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

419 return tr( 

420 text + f" <sup>[{footnote_num}]</sup>", 

421 answer(score) + " / 100" 

422 ) 

423 

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

425 h = f""" 

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

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

428 {self.get_is_complete_tr(req)} 

429 """ 

430 h += self.scoreline( 

431 self.wxstring(req, "score_overall"), 1, 

432 self.format_float_for_display(self.score_overall())) 

433 h += self.scoreline( 

434 self.wxstring(req, "score_physical_functioning"), 2, 

435 self.format_float_for_display(self.score_physical_functioning())) 

436 h += self.scoreline( 

437 self.wxstring(req, "score_role_limitations_physical"), 3, 

438 self.format_float_for_display( 

439 self.score_role_limitations_physical())) 

440 h += self.scoreline( 

441 self.wxstring(req, "score_role_limitations_emotional"), 4, 

442 self.format_float_for_display( 

443 self.score_role_limitations_emotional())) 

444 h += self.scoreline( 

445 self.wxstring(req, "score_energy"), 5, 

446 self.format_float_for_display(self.score_energy())) 

447 h += self.scoreline( 

448 self.wxstring(req, "score_emotional_wellbeing"), 6, 

449 self.format_float_for_display(self.score_emotional_wellbeing())) 

450 h += self.scoreline( 

451 self.wxstring(req, "score_social_functioning"), 7, 

452 self.format_float_for_display(self.score_social_functioning())) 

453 h += self.scoreline( 

454 self.wxstring(req, "score_pain"), 8, 

455 self.format_float_for_display(self.score_pain())) 

456 h += self.scoreline( 

457 self.wxstring(req, "score_general_health"), 9, 

458 self.format_float_for_display(self.score_general_health())) 

459 h += f""" 

460 </table> 

461 </div> 

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

463 <tr> 

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

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

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

467 </tr> 

468 """ 

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

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

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

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

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

474 h += self.section_row_html( 

475 self.wxstring(req, "work_activities_physical_q")) 

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

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

478 h += self.section_row_html( 

479 self.wxstring(req, "work_activities_emotional_q")) 

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

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

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

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

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

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

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

487 h += self.section_row_html(self.wxstring(req, "last4weeks_q_a") + " " + 

488 self.wxstring(req, "last4weeks_q_b")) 

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

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

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

492 for q in [32]: 

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

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

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

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

497 h += f""" 

498 </table> 

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

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

501 as part of the Medical Outcomes Study. See 

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

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

504 </div> 

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

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

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

508 [1] Mean of all 36 questions. 

509 [2] Mean of Q3–12 inclusive. 

510 [3] Q13–16. 

511 [4] Q17–19. 

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

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

514 [7] Q20, 32. 

515 [8] Q21, 22. 

516 [9] Q1, 33–36. 

517 </div> 

518 """ # noqa 

519 return h