Coverage for tasks/frs.py: 50%

119 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/frs.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.betweendict import BetweenDict 

33from cardinal_pythonlib.stringfunc import strseq 

34import cardinal_pythonlib.rnc_web as ws 

35from sqlalchemy.ext.declarative import DeclarativeMeta 

36from sqlalchemy.sql.schema import Column 

37from sqlalchemy.sql.sqltypes import Float, Integer, UnicodeText 

38 

39from camcops_server.cc_modules.cc_constants import CssClass 

40from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

41from camcops_server.cc_modules.cc_html import tr_qa 

42from camcops_server.cc_modules.cc_request import CamcopsRequest 

43from camcops_server.cc_modules.cc_sqla_coltypes import ( 

44 CamcopsColumn, 

45 PermittedValueChecker, 

46 SummaryCategoryColType, 

47) 

48from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

49from camcops_server.cc_modules.cc_task import ( 

50 Task, 

51 TaskHasClinicianMixin, 

52 TaskHasPatientMixin, 

53 TaskHasRespondentMixin, 

54) 

55from camcops_server.cc_modules.cc_text import SS 

56 

57 

58# ============================================================================= 

59# FRS 

60# ============================================================================= 

61 

62SCORING_NOTES = """ 

63 

64SCORING 

65Confirmed by Eneida Mioshi 2015-01-20; "sometimes" and "always" score the same. 

66 

67LOGIT 

68 

69Quick R definitions: 

70 logit <- function(x) log(x / (1 - x)) 

71 invlogit <- function(x) exp(x) / (exp(x) + 1) 

72 

73See comparison file published_calculated_FRS_scoring.ods 

74and correspondence with Eneida 2015-01-20. 

75 

76""" 

77 

78NEVER = 0 

79SOMETIMES = 1 

80ALWAYS = 2 

81NA = -99 

82NA_QUESTIONS = [9, 10, 11, 13, 14, 15, 17, 18, 19, 20, 21, 27] 

83SPECIAL_NA_TEXT_QUESTIONS = [27] 

84NO_SOMETIMES_QUESTIONS = [30] 

85SCORE = {NEVER: 1, SOMETIMES: 0, ALWAYS: 0} 

86NQUESTIONS = 30 

87QUESTION_SNIPPETS = [ 

88 "behaviour / lacks interest", # 1 

89 "behaviour / lacks affection", 

90 "behaviour / uncooperative", 

91 "behaviour / confused/muddled in unusual surroundings", 

92 "behaviour / restless", # 5 

93 "behaviour / impulsive", 

94 "behaviour / forgets day", 

95 "outings / transportation", 

96 "outings / shopping", 

97 "household / lacks interest/motivation", # 10 

98 "household / difficulty completing chores", 

99 "household / telephoning", 

100 "finances / lacks interest", 

101 "finances / problems organizing finances", 

102 "finances / problems organizing correspondence", # 15 

103 "finances / difficulty with cash", 

104 "medication / problems taking medication at correct time", 

105 "medication / problems taking medication as prescribed", 

106 "mealprep / lacks interest/motivation", 

107 "mealprep / difficulty organizing meal prep", # 20 

108 "mealprep / problems preparing meal on own", 

109 "mealprep / lacks initiative to eat", 

110 "mealprep / difficulty choosing utensils/seasoning", 

111 "mealprep / problems eating", 

112 "mealprep / wants to eat same foods repeatedly", # 25 

113 "mealprep / prefers sweet foods more", 

114 "selfcare / problems choosing appropriate clothing", 

115 "selfcare / incontinent", 

116 "selfcare / cannot be left at home safely", 

117 "selfcare / bedbound", # 30 

118] 

119DP = 3 

120 

121TABULAR_LOGIT_BETWEENDICT = BetweenDict( 

122 { 

123 # tests a <= x < b 

124 (100, float("inf")): 5.39, # from Python 3.5, can use math.inf 

125 (97, 100): 4.12, 

126 (93, 97): 3.35, 

127 (90, 93): 2.86, 

128 (87, 90): 2.49, 

129 (83, 87): 2.19, 

130 (80, 83): 1.92, 

131 (77, 80): 1.68, 

132 (73, 77): 1.47, 

133 (70, 73): 1.26, 

134 (67, 70): 1.07, 

135 (63, 67): 0.88, 

136 (60, 63): 0.7, 

137 (57, 60): 0.52, 

138 (53, 57): 0.34, 

139 (50, 53): 0.16, 

140 (47, 50): -0.02, 

141 (43, 47): -0.2, 

142 (40, 43): -0.4, 

143 (37, 40): -0.59, 

144 (33, 37): -0.8, 

145 (30, 33): -1.03, 

146 (27, 30): -1.27, 

147 (23, 27): -1.54, 

148 (20, 23): -1.84, 

149 (17, 20): -2.18, 

150 (13, 17): -2.58, 

151 (10, 13): -3.09, 

152 (6, 10): -3.8, 

153 (3, 6): -4.99, 

154 (0, 3): -6.66, 

155 } 

156) 

157 

158 

159def get_severity(logit: float) -> str: 

160 # p1593 of Mioshi et al. (2010) 

161 # Copes with Infinity comparisons 

162 if logit >= 4.12: 

163 return "very mild" 

164 if logit >= 1.92: 

165 return "mild" 

166 if logit >= -0.40: 

167 return "moderate" 

168 if logit >= -2.58: 

169 return "severe" 

170 if logit >= -4.99: 

171 return "very severe" 

172 return "profound" 

173 

174 

175def get_tabular_logit(score: float) -> float: 

176 """ 

177 Implements the scoring table accompanying Mioshi et al. (2010). 

178 Converts a score (in the table, a percentage; here, a number in the 

179 range 0-1) to a logit score of some description, whose true basis (in 

180 a Rasch analysis) is a bit obscure. 

181 """ 

182 pct_score = 100 * score 

183 return TABULAR_LOGIT_BETWEENDICT[pct_score] 

184 

185 

186# for x in range(100, 0 - 1, -1): 

187# score = x / 100 

188# logit = get_tabular_logit(score) 

189# severity = get_severity(logit) 

190# print(",".join(str(q) for q in (x, logit, severity))) 

191 

192 

193class FrsMetaclass(DeclarativeMeta): 

194 # noinspection PyInitNewSignature 

195 def __init__( 

196 cls: Type["Frs"], 

197 name: str, 

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

199 classdict: Dict[str, Any], 

200 ) -> None: 

201 for n in range(1, NQUESTIONS + 1): 

202 pv = [NEVER, ALWAYS] 

203 pc = [f"{NEVER} = never", f"{ALWAYS} = always"] 

204 if n not in NO_SOMETIMES_QUESTIONS: 

205 pv.append(SOMETIMES) 

206 pc.append(f"{SOMETIMES} = sometimes") 

207 if n in NA_QUESTIONS: 

208 pv.append(NA) 

209 pc.append(f"{NA} = N/A") 

210 comment = f"Q{n}, {QUESTION_SNIPPETS[n - 1]} ({', '.join(pc)})" 

211 colname = f"q{n}" 

212 setattr( 

213 cls, 

214 colname, 

215 CamcopsColumn( 

216 colname, 

217 Integer, 

218 permitted_value_checker=PermittedValueChecker( 

219 permitted_values=pv 

220 ), 

221 comment=comment, 

222 ), 

223 ) 

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

225 

226 

227class Frs( 

228 TaskHasPatientMixin, 

229 TaskHasRespondentMixin, 

230 TaskHasClinicianMixin, 

231 Task, 

232 metaclass=FrsMetaclass, 

233): 

234 """ 

235 Server implementation of the FRS task. 

236 """ 

237 

238 __tablename__ = "frs" 

239 shortname = "FRS" 

240 

241 comments = Column("comments", UnicodeText, comment="Clinician's comments") 

242 

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

244 

245 @staticmethod 

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

247 _ = req.gettext 

248 return _("Frontotemporal Dementia Rating Scale") 

249 

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

251 scoredict = self.get_score() 

252 return self.standard_task_summary_fields() + [ 

253 SummaryElement( 

254 name="total", 

255 coltype=Integer(), 

256 value=scoredict["total"], 

257 comment="Total (0-n, higher better)", 

258 ), 

259 SummaryElement( 

260 name="n", 

261 coltype=Integer(), 

262 value=scoredict["n"], 

263 comment="Number of applicable questions", 

264 ), 

265 SummaryElement( 

266 name="score", 

267 coltype=Float(), 

268 value=scoredict["score"], 

269 comment="tcore / n", 

270 ), 

271 SummaryElement( 

272 name="logit", 

273 coltype=Float(), 

274 value=scoredict["logit"], 

275 comment="log(score / (1 - score))", 

276 ), 

277 SummaryElement( 

278 name="severity", 

279 coltype=SummaryCategoryColType, 

280 value=scoredict["severity"], 

281 comment="Severity", 

282 ), 

283 ] 

284 

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

286 if not self.is_complete(): 

287 return CTV_INCOMPLETE 

288 scoredict = self.get_score() 

289 return [ 

290 CtvInfo( 

291 content=( 

292 "Total {total}/n, n = {n}, score = {score}, " 

293 "logit score = {logit}, severity = {severity}".format( 

294 total=scoredict["total"], 

295 n=scoredict["n"], 

296 score=ws.number_to_dp(scoredict["score"], DP), 

297 logit=ws.number_to_dp(scoredict["logit"], DP), 

298 severity=scoredict["severity"], 

299 ) 

300 ) 

301 ) 

302 ] 

303 

304 def get_score(self) -> Dict: 

305 total = 0 

306 n = 0 

307 for q in range(1, NQUESTIONS + 1): 

308 value = getattr(self, "q" + str(q)) 

309 if value is not None and value != NA: 

310 n += 1 

311 total += SCORE.get(value, 0) 

312 if n > 0: 

313 score = total / n 

314 # logit = safe_logit(score) 

315 logit = get_tabular_logit(score) 

316 severity = get_severity(logit) 

317 else: 

318 score = None 

319 logit = None 

320 severity = "" 

321 return dict( 

322 total=total, n=n, score=score, logit=logit, severity=severity 

323 ) 

324 

325 def is_complete(self) -> bool: 

326 return ( 

327 self.field_contents_valid() 

328 and self.is_respondent_complete() 

329 and self.all_fields_not_none(self.TASK_FIELDS) 

330 ) 

331 

332 def get_answer(self, req: CamcopsRequest, q: int) -> Optional[str]: 

333 qstr = str(q) 

334 value = getattr(self, "q" + qstr) 

335 if value is None: 

336 return None 

337 prefix = "q" + qstr + "_a_" 

338 if value == ALWAYS: 

339 return self.wxstring(req, prefix + "always") 

340 if value == SOMETIMES: 

341 return self.wxstring(req, prefix + "sometimes") 

342 if value == NEVER: 

343 return self.wxstring(req, prefix + "never") 

344 if value == NA: 

345 if q in SPECIAL_NA_TEXT_QUESTIONS: 

346 return self.wxstring(req, prefix + "na") 

347 return req.sstring(SS.NA) 

348 return None 

349 

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

351 scoredict = self.get_score() 

352 q_a = "" 

353 for q in range(1, NQUESTIONS + 1): 

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

355 atext = self.get_answer(req, q) 

356 q_a += tr_qa(qtext, atext) 

357 return f""" 

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

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

360 {self.get_is_complete_tr(req)} 

361 <tr> 

362 <td>Total (0–n, higher better) <sup>1</sup></td> 

363 <td>{scoredict['total']}</td> 

364 </td> 

365 <tr> 

366 <td>n (applicable questions)</td> 

367 <td>{scoredict['n']}</td> 

368 </td> 

369 <tr> 

370 <td>Score (total / n; 0–1)</td> 

371 <td>{ws.number_to_dp(scoredict['score'], DP)}</td> 

372 </td> 

373 <tr> 

374 <td>logit score <sup>2</sup></td> 

375 <td>{ws.number_to_dp(scoredict['logit'], DP)}</td> 

376 </td> 

377 <tr> 

378 <td>Severity <sup>3</sup></td> 

379 <td>{scoredict['severity']}</td> 

380 </td> 

381 </table> 

382 </div> 

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

384 <tr> 

385 <th width="50%">Question</th> 

386 <th width="50%">Answer</th> 

387 </tr> 

388 {q_a} 

389 </table> 

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

391 [1] ‘Never’ scores 1 and ‘sometimes’/‘always’ both score 0, 

392 i.e. there is no scoring difference between ‘sometimes’ and 

393 ‘always’. 

394 [2] This is not the simple logit, log(score/[1 – score]). 

395 Instead, it is determined by a lookup table, as per 

396 <a href="http://www.ftdrg.org/wp-content/uploads/FRS-Score-conversion.pdf">http://www.ftdrg.org/wp-content/uploads/FRS-Score-conversion.pdf</a>. 

397 The logit score that is looked up is very close to the logit 

398 of the raw score (on a 0–1 scale); however, it differs in that 

399 firstly it is banded rather than continuous, and secondly it 

400 is subtly different near the lower scores and at the extremes. 

401 The original is based on a Rasch analysis but the raw method of 

402 converting the score to the tabulated logit is not given. 

403 [3] Where <i>x</i> is the logit score, severity is determined 

404 as follows (after Mioshi et al. 2010, Neurology 74: 1591, PMID 

405 20479357, with sharp cutoffs). 

406 <i>Very mild:</i> <i>x</i> ≥ 4.12. 

407 <i>Mild:</i> 1.92 ≤ <i>x</i> &lt; 4.12. 

408 <i>Moderate:</i> –0.40 ≤ <i>x</i> &lt; 1.92. 

409 <i>Severe:</i> –2.58 ≤ <i>x</i> &lt; –0.40. 

410 <i>Very severe:</i> –4.99 ≤ <i>x</i> &lt; –2.58. 

411 <i>Profound:</i> <i>x</i> &lt; –4.99. 

412 </div> 

413 """ # noqa