Coverage for tasks/rand36.py: 35%

1"""

2 camcops_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

28 from typing import Any, List, Optional, Type

29

30 from cardinal_pythonlib.maths_py import mean

31 from cardinal_pythonlib.stringfunc import strseq

32 from sqlalchemy.orm import Mapped

33 from sqlalchemy.sql.sqltypes import Float

34

35 from camcops_server.cc_modules.cc_constants import CssClass

36 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

37 from camcops_server.cc_modules.cc_db import add_multiple_columns

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

39 from camcops_server.cc_modules.cc_request import CamcopsRequest

40 from camcops_server.cc_modules.cc_sqla_coltypes import (

41 mapped_camcops_column,

42 ONE_TO_FIVE_CHECKER,

43 ONE_TO_SIX_CHECKER,

44)

45 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

46 from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin

47 from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo

48

49

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

51# RAND-36

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

53

54

55 class 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