Coverage for tasks/icd10manic.py: 37%

1"""

2 camcops_server/tasks/icd10manic.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 import datetime

29 from typing import List, Optional

30

31 from cardinal_pythonlib.datetimefunc import format_datetime

32 from cardinal_pythonlib.typetests import is_false

33 import cardinal_pythonlib.rnc_web as ws

34 from sqlalchemy.orm import Mapped, mapped_column

35 from sqlalchemy.sql.sqltypes import Boolean, UnicodeText

36

37 from camcops_server.cc_modules.cc_constants import (

38 CssClass,

39 DateFormat,

40 ICD10_COPYRIGHT_DIV,

41)

42 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

43 from camcops_server.cc_modules.cc_html import (

44 get_present_absent_none,

45 heading_spanning_two_columns,

46 subheading_spanning_two_columns,

47 tr_qa,

48)

49 from camcops_server.cc_modules.cc_request import CamcopsRequest

50 from camcops_server.cc_modules.cc_sqla_coltypes import (

51 BIT_CHECKER,

52 mapped_camcops_column,

53 SummaryCategoryColType,

54)

55 from camcops_server.cc_modules.cc_string import AS

56 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

57 from camcops_server.cc_modules.cc_task import (

58 Task,

59 TaskHasClinicianMixin,

60 TaskHasPatientMixin,

61)

62 from camcops_server.cc_modules.cc_text import SS

63

64

65# =============================================================================

66# Icd10Manic

67# =============================================================================

68

69

70 class Icd10Manic(TaskHasClinicianMixin, TaskHasPatientMixin, Task): # type: ignore[misc] # noqa: E501

71 """

72 Server implementation of the ICD10-MANIC task.

73 """

74

75 __tablename__ = "icd10manic"

76 shortname = "ICD10-MANIC"

77 info_filename_stem = "icd"

78

79 mood_elevated: Mapped[Optional[bool]] = mapped_camcops_column(

80 permitted_value_checker=BIT_CHECKER,

81 comment="The mood is 'elevated' [hypomania] or 'predominantly "

82 "elevated [or] expansive' [mania] to a degree that is "

83 "definitely abnormal for the individual concerned.",

84 )

85 mood_irritable: Mapped[Optional[bool]] = mapped_camcops_column(

86 permitted_value_checker=BIT_CHECKER,

87 comment="The mood is 'irritable' [hypomania] or 'predominantly "

88 "irritable' [mania] to a degree that is definitely abnormal "

89 "for the individual concerned.",

90 )

91

92 distractible: Mapped[Optional[bool]] = mapped_camcops_column(

93 permitted_value_checker=BIT_CHECKER,

94 comment="Difficulty in concentration or distractibility [from "

95 "the criteria for hypomania]; distractibility or constant "

96 "changes in activity or plans [from the criteria for mania].",

97 )

98 activity: Mapped[Optional[bool]] = mapped_camcops_column(

99 permitted_value_checker=BIT_CHECKER,

100 comment="Increased activity or physical restlessness.",

101 )

102 sleep: Mapped[Optional[bool]] = mapped_camcops_column(

103 permitted_value_checker=BIT_CHECKER,

104 comment="Decreased need for sleep.",

105 )

106 talkativeness: Mapped[Optional[bool]] = mapped_camcops_column(

107 permitted_value_checker=BIT_CHECKER,

108 comment="Increased talkativeness (pressure of speech).",

109 )

110 recklessness: Mapped[Optional[bool]] = mapped_camcops_column(

111 permitted_value_checker=BIT_CHECKER,

112 comment="Mild spending sprees, or other types of reckless or "

113 "irresponsible behaviour [hypomania]; behaviour which is "

114 "foolhardy or reckless and whose risks the subject does not "

115 "recognize e.g. spending sprees, foolish enterprises, "

116 "reckless driving [mania].",

117 )

118 social_disinhibition: Mapped[Optional[bool]] = mapped_camcops_column(

119 permitted_value_checker=BIT_CHECKER,

120 comment="Increased sociability or over-familiarity [hypomania]; "

121 "loss of normal social inhibitions resulting in behaviour "

122 "which is inappropriate to the circumstances [mania].",

123 )

124 sexual: Mapped[Optional[bool]] = mapped_camcops_column(

125 permitted_value_checker=BIT_CHECKER,

126 comment="Increased sexual energy [hypomania]; marked sexual "

127 "energy or sexual indiscretions [mania].",

128 )

129

130 grandiosity: Mapped[Optional[bool]] = mapped_camcops_column(

131 permitted_value_checker=BIT_CHECKER,

132 comment="Inflated self-esteem or grandiosity.",

133 )

134 flight_of_ideas: Mapped[Optional[bool]] = mapped_camcops_column(

135 permitted_value_checker=BIT_CHECKER,

136 comment="Flight of ideas or the subjective experience of "

137 "thoughts racing.",

138 )

139

140 sustained4days: Mapped[Optional[bool]] = mapped_camcops_column(

141 permitted_value_checker=BIT_CHECKER,

142 comment="Elevated/irritable mood sustained for at least 4 days.",

143 )

144 sustained7days: Mapped[Optional[bool]] = mapped_camcops_column(

145 permitted_value_checker=BIT_CHECKER,

146 comment="Elevated/irritable mood sustained for at least 7 days.",

147 )

148 admission_required: Mapped[Optional[bool]] = mapped_camcops_column(

149 permitted_value_checker=BIT_CHECKER,

150 comment="Elevated/irritable mood severe enough to require "

151 "hospital admission.",

152 )

153 some_interference_functioning: Mapped[Optional[bool]] = (

154 mapped_camcops_column(

155 permitted_value_checker=BIT_CHECKER,

156 comment="Some interference with personal functioning "

157 "in daily living.",

158 )

159 )

160 severe_interference_functioning: Mapped[Optional[bool]] = (

161 mapped_camcops_column(

162 permitted_value_checker=BIT_CHECKER,

163 comment="Severe interference with personal "

164 "functioning in daily living.",

165 )

166 )

167

168 perceptual_alterations: Mapped[Optional[bool]] = mapped_camcops_column(

169 permitted_value_checker=BIT_CHECKER,

170 comment="Perceptual alterations (e.g. subjective hyperacusis, "

171 "appreciation of colours as specially vivid, etc.).",

172 ) # ... not psychotic

173 hallucinations_schizophrenic: Mapped[Optional[bool]] = (

174 mapped_camcops_column(

175 permitted_value_checker=BIT_CHECKER,

176 comment="Hallucinations that are 'typically schizophrenic' "

177 "(hallucinatory voices giving a running commentary on the "

178 "patient's behaviour, or discussing him between themselves, "

179 "or other types of hallucinatory voices coming from some part "

180 "of the body).",

181 )

182 )

183 hallucinations_other: Mapped[Optional[bool]] = mapped_camcops_column(

184 permitted_value_checker=BIT_CHECKER,

185 comment="Hallucinations (of any other kind).",

186 )

187 delusions_schizophrenic: Mapped[Optional[bool]] = mapped_camcops_column(

188 permitted_value_checker=BIT_CHECKER,

189 comment="Delusions that are 'typically schizophrenic' (delusions "

190 "of control, influence or passivity, clearly referred to body "

191 "or limb movements or specific thoughts, actions, or "

192 "sensations; delusional perception; persistent delusions of "

193 "other kinds that are culturally inappropriate and completely "

194 "impossible).",

195 )

196 delusions_other: Mapped[Optional[bool]] = mapped_camcops_column(

197 permitted_value_checker=BIT_CHECKER,

198 comment="Delusions (of any other kind).",

199 )

200

201 date_pertains_to: Mapped[Optional[datetime.date]] = mapped_column(

202 comment="Date the assessment pertains to"

203 )

204 comments: Mapped[Optional[str]] = mapped_column(

205 UnicodeText, comment="Clinician's comments"

206 )

207

208 CORE_NAMES = ["mood_elevated", "mood_irritable"]

209 HYPOMANIA_MANIA_NAMES = [

210 "distractible",

211 "activity",

212 "sleep",

213 "talkativeness",

214 "recklessness",

215 "social_disinhibition",

216 "sexual",

217 ]

218 MANIA_NAMES = ["grandiosity", "flight_of_ideas"]

219 OTHER_CRITERIA_NAMES = [

220 "sustained4days",

221 "sustained7days",

222 "admission_required",

223 "some_interference_functioning",

224 "severe_interference_functioning",

225 ]

226 PSYCHOSIS_NAMES = [

227 "perceptual_alterations", # not psychotic

228 "hallucinations_schizophrenic",

229 "hallucinations_other",

230 "delusions_schizophrenic",

231 "delusions_other",

232 ]

233

234 @staticmethod

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

236 _ = req.gettext

237 return _(

238 "ICD-10 symptomatic criteria for a manic/hypomanic episode "

239 "(as in e.g. F06.3, F25, F30, F31)"

240 )

241

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

243 if not self.is_complete():

244 return CTV_INCOMPLETE

245 infolist = [

246 CtvInfo(

247 content="Pertains to: {}. Category: {}.".format(

248 format_datetime(

249 self.date_pertains_to, DateFormat.LONG_DATE

250 ),

251 self.get_description(req),

252 )

253 )

254 ]

255 if self.comments:

256 infolist.append(CtvInfo(content=ws.webify(self.comments)))

257 return infolist

258

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

260 return self.standard_task_summary_fields() + [

261 SummaryElement(

262 name="category",

263 coltype=SummaryCategoryColType,

264 value=self.get_description(req),

265 comment="Diagnostic category",

266 ),

267 SummaryElement(

268 name="psychotic_symptoms",

269 coltype=Boolean(),

270 value=self.psychosis_present(),

271 comment="Psychotic symptoms present?",

272 ),

273 ]

274

275 # Meets criteria? These also return null for unknown.

276 def meets_criteria_mania_psychotic_schizophrenic(self) -> Optional[bool]:

277 x = self.meets_criteria_mania_ignoring_psychosis()

278 if not x:

279 return x

280 if self.hallucinations_other or self.delusions_other:

281 return False # that counts as manic psychosis

282 if self.hallucinations_other is None or self.delusions_other is None:

283 return None # might be manic psychosis

284 if self.hallucinations_schizophrenic or self.delusions_schizophrenic:

285 return True

286 if (

287 self.hallucinations_schizophrenic is None

288 or self.delusions_schizophrenic is None

289 ):

290 return None

291 return False

292

293 def meets_criteria_mania_psychotic_icd(self) -> Optional[bool]:

294 x = self.meets_criteria_mania_ignoring_psychosis()

295 if not x:

296 return x

297 if self.hallucinations_other or self.delusions_other:

298 return True

299 if self.hallucinations_other is None or self.delusions_other is None:

300 return None

301 return False

302

303 def meets_criteria_mania_nonpsychotic(self) -> Optional[bool]:

304 x = self.meets_criteria_mania_ignoring_psychosis()

305 if not x:

306 return x

307 if (

308 self.hallucinations_schizophrenic is None

309 or self.delusions_schizophrenic is None

310 or self.hallucinations_other is None

311 or self.delusions_other is None

312 ):

313 return None

314 if (

315 self.hallucinations_schizophrenic

316 or self.delusions_schizophrenic

317 or self.hallucinations_other

318 or self.delusions_other

319 ):

320 return False

321 return True

322

323 def meets_criteria_mania_ignoring_psychosis(self) -> Optional[bool]:

324 # When can we say "definitely not"?

325 if is_false(self.mood_elevated) and is_false(self.mood_irritable):

326 return False

327 if is_false(self.sustained7days) and is_false(self.admission_required):

328 return False

329 t = self.count_booleans(

330 self.HYPOMANIA_MANIA_NAMES

331 ) + self.count_booleans(self.MANIA_NAMES)

332 u = self.n_fields_none(

333 self.HYPOMANIA_MANIA_NAMES

334 ) + self.n_fields_none(self.MANIA_NAMES)

335 if self.mood_elevated and (t + u < 3):

336 # With elevated mood, need at least 3 symptoms

337 return False

338 if is_false(self.mood_elevated) and (t + u < 4):

339 # With only irritable mood, need at least 4 symptoms

340 return False

341 if is_false(self.severe_interference_functioning):

342 return False

343 # OK. When can we say "yes"?

344 if (

345 (self.mood_elevated or self.mood_irritable)

346 and (self.sustained7days or self.admission_required)

347 and (

348 (self.mood_elevated and t >= 3)

349 or (self.mood_irritable and t >= 4)

350 )

351 and self.severe_interference_functioning

352 ):

353 return True

354 return None

355

356 def meets_criteria_hypomania(self) -> Optional[bool]:

357 # When can we say "definitely not"?

358 if self.meets_criteria_mania_ignoring_psychosis():

359 return False # silly to call it hypomania if it's mania

360 if is_false(self.mood_elevated) and is_false(self.mood_irritable):

361 return False

362 if is_false(self.sustained4days):

363 return False

364 t = self.count_booleans(self.HYPOMANIA_MANIA_NAMES)

365 u = self.n_fields_none(self.HYPOMANIA_MANIA_NAMES)

366 if t + u < 3:

367 # Need at least 3 symptoms

368 return False

369 if is_false(self.some_interference_functioning):

370 return False

371 # OK. When can we say "yes"?

372 if (

373 (self.mood_elevated or self.mood_irritable)

374 and self.sustained4days

375 and t >= 3

376 and self.some_interference_functioning

377 ):

378 return True

379 return None

380

381 def meets_criteria_none(self) -> Optional[bool]:

382 h = self.meets_criteria_hypomania()

383 m = self.meets_criteria_mania_ignoring_psychosis()

384 if h or m:

385 return False

386 if is_false(h) and is_false(m):

387 return True

388 return None

389

390 def psychosis_present(self) -> Optional[bool]:

391 if (

392 self.hallucinations_other

393 or self.hallucinations_schizophrenic

394 or self.delusions_other

395 or self.delusions_schizophrenic

396 ):

397 return True

398 if (

399 self.hallucinations_other is None

400 or self.hallucinations_schizophrenic is None

401 or self.delusions_other is None

402 or self.delusions_schizophrenic is None

403 ):

404 return None

405 return False

406

407 def get_description(self, req: CamcopsRequest) -> str:

408 if self.meets_criteria_mania_psychotic_schizophrenic():

409 return self.wxstring(req, "category_manic_psychotic_schizophrenic")

410 elif self.meets_criteria_mania_psychotic_icd():

411 return self.wxstring(req, "category_manic_psychotic")

412 elif self.meets_criteria_mania_nonpsychotic():

413 return self.wxstring(req, "category_manic_nonpsychotic")

414 elif self.meets_criteria_hypomania():

415 return self.wxstring(req, "category_hypomanic")

416 elif self.meets_criteria_none():

417 return self.wxstring(req, "category_none")

418 else:

419 return req.sstring(SS.UNKNOWN)

420

421 def is_complete(self) -> bool:

422 return (

423 self.date_pertains_to is not None

424 and self.meets_criteria_none() is not None

425 and self.field_contents_valid()

426 )

427

428 def text_row(self, req: CamcopsRequest, wstringname: str) -> str:

429 return heading_spanning_two_columns(self.wxstring(req, wstringname))

430

431 def row_true_false(self, req: CamcopsRequest, fieldname: str) -> str:

432 return self.get_twocol_bool_row_true_false(

433 req, fieldname, self.wxstring(req, "" + fieldname)

434 )

435

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

437 h = """

438 {clinician_comments}

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

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

441 {tr_is_complete}

442 {date_pertains_to}

443 {category}

444 {psychotic_symptoms}

445 </table>

446 </div>

447 <div class="{CssClass.EXPLANATION}">

448 {icd10_symptomatic_disclaimer}

449 </div>

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

451 <tr>

452 <th width="80%">Question</th>

453 <th width="20%">Answer</th>

454 </tr>

455 """.format(

456 clinician_comments=self.get_standard_clinician_comments_block(

457 req, self.comments

458 ),

459 CssClass=CssClass,

460 tr_is_complete=self.get_is_complete_tr(req),

461 date_pertains_to=tr_qa(

462 req.wappstring(AS.DATE_PERTAINS_TO),

463 format_datetime(

464 self.date_pertains_to, DateFormat.LONG_DATE, default=None

465 ),

466 ),

467 category=tr_qa(

468 req.sstring(SS.CATEGORY) + " <sup>[1,2]</sup>",

469 self.get_description(req),

470 ),

471 psychotic_symptoms=tr_qa(

472 self.wxstring(req, "psychotic_symptoms") + " <sup>[2]</sup>",

473 get_present_absent_none(req, self.psychosis_present()),

474 ),

475 icd10_symptomatic_disclaimer=req.wappstring(

476 AS.ICD10_SYMPTOMATIC_DISCLAIMER

477 ),

478 )

479

480 h += self.text_row(req, "core")

481 for x in self.CORE_NAMES:

482 h += self.row_true_false(req, x)

483

484 h += self.text_row(req, "hypomania_mania")

485 for x in self.HYPOMANIA_MANIA_NAMES:

486 h += self.row_true_false(req, x)

487

488 h += self.text_row(req, "other_mania")

489 for x in self.MANIA_NAMES:

490 h += self.row_true_false(req, x)

491

492 h += self.text_row(req, "other_criteria")

493 for x in self.OTHER_CRITERIA_NAMES:

494 h += self.row_true_false(req, x)

495

496 h += subheading_spanning_two_columns(self.wxstring(req, "psychosis"))

497 for x in self.PSYCHOSIS_NAMES:

498 h += self.row_true_false(req, x)

499

500 h += f"""

501 </table>

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

503 [1] Hypomania:

504 elevated/irritable mood

505 + sustained for ≥4 days

506 + at least 3 of the “other hypomania” symptoms

507 + some interference with functioning.

508 Mania:

509 elevated/irritable mood

510 + sustained for ≥7 days or hospital admission required

511 + at least 3 of the “other mania/hypomania” symptoms

512 (4 if mood only irritable)

513 + severe interference with functioning.

514 [2] ICD-10 nonpsychotic mania requires mania without

515 hallucinations/delusions.

516 ICD-10 psychotic mania requires mania plus

517 hallucinations/delusions other than those that are

518 “typically schizophrenic”.

519 ICD-10 does not clearly categorize mania with only

520 schizophreniform psychotic symptoms; however, Schneiderian

521 first-rank symptoms can occur in manic psychosis

522 (e.g. Conus P et al., 2004, PMID 15337330.).

523 </div>

524 {ICD10_COPYRIGHT_DIV}

525 """

526 return h