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

32from cardinal_pythonlib.stringfunc import strseq 

33import cardinal_pythonlib.rnc_web as ws 

34from sqlalchemy.ext.declarative import DeclarativeMeta 

35from sqlalchemy.sql.schema import Column 

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

37 

38from camcops_server.cc_modules.cc_constants import CssClass 

39from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

40from camcops_server.cc_modules.cc_html import tr_qa 

41from camcops_server.cc_modules.cc_request import CamcopsRequest 

42from camcops_server.cc_modules.cc_sqla_coltypes import ( 

43 CamcopsColumn, 

44 PermittedValueChecker, 

45 SummaryCategoryColType, 

46) 

47from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

48from camcops_server.cc_modules.cc_task import ( 

49 Task, 

50 TaskHasClinicianMixin, 

51 TaskHasPatientMixin, 

52 TaskHasRespondentMixin, 

53) 

54from camcops_server.cc_modules.cc_text import SS 

55 

56 

57# ============================================================================= 

58# FRS 

59# ============================================================================= 

60 

61SCORING_NOTES = """ 

62 

63SCORING 

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

65 

66LOGIT 

67 

68Quick R definitions: 

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

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

71 

72See comparison file published_calculated_FRS_scoring.ods 

73and correspondence with Eneida 2015-01-20. 

74 

75""" 

76 

77NEVER = 0 

78SOMETIMES = 1 

79ALWAYS = 2 

80NA = -99 

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

82SPECIAL_NA_TEXT_QUESTIONS = [27] 

83NO_SOMETIMES_QUESTIONS = [30] 

84SCORE = { 

85 NEVER: 1, 

86 SOMETIMES: 0, 

87 ALWAYS: 0 

88} 

89NQUESTIONS = 30 

90QUESTION_SNIPPETS = [ 

91 "behaviour / lacks interest", # 1 

92 "behaviour / lacks affection", 

93 "behaviour / uncooperative", 

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

95 "behaviour / restless", # 5 

96 "behaviour / impulsive", 

97 "behaviour / forgets day", 

98 "outings / transportation", 

99 "outings / shopping", 

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

101 "household / difficulty completing chores", 

102 "household / telephoning", 

103 "finances / lacks interest", 

104 "finances / problems organizing finances", 

105 "finances / problems organizing correspondence", # 15 

106 "finances / difficulty with cash", 

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

108 "medication / problems taking medication as prescribed", 

109 "mealprep / lacks interest/motivation", 

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

111 "mealprep / problems preparing meal on own", 

112 "mealprep / lacks initiative to eat", 

113 "mealprep / difficulty choosing utensils/seasoning", 

114 "mealprep / problems eating", 

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

116 "mealprep / prefers sweet foods more", 

117 "selfcare / problems choosing appropriate clothing", 

118 "selfcare / incontinent", 

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

120 "selfcare / bedbound", # 30 

121] 

122DP = 3 

123 

124TABULAR_LOGIT_BETWEENDICT = BetweenDict({ 

125 # tests a <= x < b 

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

127 (97, 100): 4.12, 

128 (93, 97): 3.35, 

129 (90, 93): 2.86, 

130 (87, 90): 2.49, 

131 (83, 87): 2.19, 

132 (80, 83): 1.92, 

133 (77, 80): 1.68, 

134 (73, 77): 1.47, 

135 (70, 73): 1.26, 

136 (67, 70): 1.07, 

137 (63, 67): 0.88, 

138 (60, 63): 0.7, 

139 (57, 60): 0.52, 

140 (53, 57): 0.34, 

141 (50, 53): 0.16, 

142 (47, 50): -0.02, 

143 (43, 47): -0.2, 

144 (40, 43): -0.4, 

145 (37, 40): -0.59, 

146 (33, 37): -0.8, 

147 (30, 33): -1.03, 

148 (27, 30): -1.27, 

149 (23, 27): -1.54, 

150 (20, 23): -1.84, 

151 (17, 20): -2.18, 

152 (13, 17): -2.58, 

153 (10, 13): -3.09, 

154 (6, 10): -3.8, 

155 (3, 6): -4.99, 

156 (0, 3): -6.66, 

157}) 

158 

159 

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

161 # p1593 of Mioshi et al. (2010) 

162 # Copes with Infinity comparisons 

163 if logit >= 4.12: 

164 return "very mild" 

165 if logit >= 1.92: 

166 return "mild" 

167 if logit >= -0.40: 

168 return "moderate" 

169 if logit >= -2.58: 

170 return "severe" 

171 if logit >= -4.99: 

172 return "very severe" 

173 return "profound" 

174 

175 

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

177 """ 

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

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

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

181 a Rasch analysis) is a bit obscure. 

182 """ 

183 pct_score = 100 * score 

184 return TABULAR_LOGIT_BETWEENDICT[pct_score] 

185 

186 

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

188# score = x / 100 

189# logit = get_tabular_logit(score) 

190# severity = get_severity(logit) 

191# print(",".join(str(q) for q in [x, logit, severity])) 

192 

193 

194class FrsMetaclass(DeclarativeMeta): 

195 # noinspection PyInitNewSignature 

196 def __init__(cls: Type['Frs'], 

197 name: str, 

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

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

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

201 pv = [NEVER, ALWAYS] 

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

203 if n not in NO_SOMETIMES_QUESTIONS: 

204 pv.append(SOMETIMES) 

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

206 if n in NA_QUESTIONS: 

207 pv.append(NA) 

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

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

210 colname = f"q{n}" 

211 setattr( 

212 cls, 

213 colname, 

214 CamcopsColumn( 

215 colname, Integer, 

216 permitted_value_checker=PermittedValueChecker( 

217 permitted_values=pv), 

218 comment=comment 

219 ) 

220 ) 

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

222 

223 

224class Frs(TaskHasPatientMixin, TaskHasRespondentMixin, TaskHasClinicianMixin, 

225 Task, 

226 metaclass=FrsMetaclass): 

227 """ 

228 Server implementation of the FRS task. 

229 """ 

230 __tablename__ = "frs" 

231 shortname = "FRS" 

232 

233 comments = Column( 

234 "comments", UnicodeText, 

235 comment="Clinician's comments" 

236 ) 

237 

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

239 

240 @staticmethod 

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

242 _ = req.gettext 

243 return _("Frontotemporal Dementia Rating Scale") 

244 

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

246 scoredict = self.get_score() 

247 return self.standard_task_summary_fields() + [ 

248 SummaryElement(name="total", 

249 coltype=Integer(), 

250 value=scoredict['total'], 

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

252 SummaryElement(name="n", 

253 coltype=Integer(), 

254 value=scoredict['n'], 

255 comment="Number of applicable questions"), 

256 SummaryElement(name="score", 

257 coltype=Float(), 

258 value=scoredict['score'], 

259 comment="tcore / n"), 

260 SummaryElement(name="logit", 

261 coltype=Float(), 

262 value=scoredict['logit'], 

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

264 SummaryElement(name="severity", 

265 coltype=SummaryCategoryColType, 

266 value=scoredict['severity'], 

267 comment="Severity"), 

268 ] 

269 

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

271 if not self.is_complete(): 

272 return CTV_INCOMPLETE 

273 scoredict = self.get_score() 

274 return [CtvInfo( 

275 content=( 

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

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

278 total=scoredict['total'], 

279 n=scoredict['n'], 

280 score=ws.number_to_dp(scoredict['score'], DP), 

281 logit=ws.number_to_dp(scoredict['logit'], DP), 

282 severity=scoredict['severity'], 

283 ) 

284 ) 

285 )] 

286 

287 def get_score(self) -> Dict: 

288 total = 0 

289 n = 0 

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

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

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

293 n += 1 

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

295 if n > 0: 

296 score = total / n 

297 # logit = safe_logit(score) 

298 logit = get_tabular_logit(score) 

299 severity = get_severity(logit) 

300 else: 

301 score = None 

302 logit = None 

303 severity = "" 

304 return dict(total=total, n=n, score=score, logit=logit, 

305 severity=severity) 

306 

307 def is_complete(self) -> bool: 

308 return ( 

309 self.field_contents_valid() and 

310 self.is_respondent_complete() and 

311 self.all_fields_not_none(self.TASK_FIELDS) 

312 ) 

313 

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

315 qstr = str(q) 

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

317 if value is None: 

318 return None 

319 prefix = "q" + qstr + "_a_" 

320 if value == ALWAYS: 

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

322 if value == SOMETIMES: 

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

324 if value == NEVER: 

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

326 if value == NA: 

327 if q in SPECIAL_NA_TEXT_QUESTIONS: 

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

329 return req.sstring(SS.NA) 

330 return None 

331 

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

333 scoredict = self.get_score() 

334 q_a = "" 

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

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

337 atext = self.get_answer(req, q) 

338 q_a += tr_qa(qtext, atext) 

339 return f""" 

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

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

342 {self.get_is_complete_tr(req)} 

343 <tr> 

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

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

346 </td> 

347 <tr> 

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

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

350 </td> 

351 <tr> 

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

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

354 </td> 

355 <tr> 

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

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

358 </td> 

359 <tr> 

360 <td>Severity <sup>3</sup></td> 

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

362 </td> 

363 </table> 

364 </div> 

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

366 <tr> 

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

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

369 </tr> 

370 {q_a} 

371 </table> 

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

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

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

375 ‘always’. 

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

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

378 <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>. 

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

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

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

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

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

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

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

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

387 20479357, with sharp cutoffs). 

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

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

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

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

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

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

394 </div> 

395 """ # noqa