Coverage for tasks/icd10depressive.py: 36%

1"""

2 camcops_server/tasks/icd10depressive.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 import cardinal_pythonlib.rnc_web as ws

33 from sqlalchemy.orm import Mapped, mapped_column

34 from sqlalchemy.sql.sqltypes import Boolean, Integer, UnicodeText

35

36 from camcops_server.cc_modules.cc_constants import (

37 CssClass,

38 DateFormat,

39 ICD10_COPYRIGHT_DIV,

40)

41 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

42 from camcops_server.cc_modules.cc_html import (

43 answer,

44 get_present_absent_none,

45 heading_spanning_two_columns,

46 tr,

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# Icd10Depressive

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

68

69

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

71 """

72 Server implementation of the ICD10-DEPR task.

73 """

74

75 __tablename__ = "icd10depressive"

76 shortname = "ICD10-DEPR"

77 info_filename_stem = "icd"

78

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

80 permitted_value_checker=BIT_CHECKER,

81 comment="Depressed mood to a degree that is definitely abnormal "

82 "for the individual, present for most of the day and almost "

83 "every day, largely uninfluenced by circumstances, and "

84 "sustained for at least 2 weeks.",

85 )

86 anhedonia: Mapped[Optional[bool]] = mapped_camcops_column(

87 permitted_value_checker=BIT_CHECKER,

88 comment="Loss of interest or pleasure in activities that are "

89 "normally pleasurable.",

90 )

91 energy: Mapped[Optional[bool]] = mapped_camcops_column(

92 permitted_value_checker=BIT_CHECKER,

93 comment="Decreased energy or increased fatiguability.",

94 )

95

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

97 permitted_value_checker=BIT_CHECKER,

98 comment="Sleep disturbance of any type.",

99 )

100 worth: Mapped[Optional[bool]] = mapped_camcops_column(

101 permitted_value_checker=BIT_CHECKER,

102 comment="Loss of confidence and self-esteem.",

103 )

104 appetite: Mapped[Optional[bool]] = mapped_camcops_column(

105 permitted_value_checker=BIT_CHECKER,

106 comment="Change in appetite (decrease or increase) with "

107 "corresponding weight change.",

108 )

109 guilt: Mapped[Optional[bool]] = mapped_camcops_column(

110 permitted_value_checker=BIT_CHECKER,

111 comment="Unreasonable feelings of self-reproach or excessive and "

112 "inappropriate guilt.",

113 )

114 concentration: Mapped[Optional[bool]] = mapped_camcops_column(

115 permitted_value_checker=BIT_CHECKER,

116 comment="Complaints or evidence of diminished ability to think "

117 "or concentrate, such as indecisiveness or vacillation.",

118 )

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

120 permitted_value_checker=BIT_CHECKER,

121 comment="Change in psychomotor activity, with agitation or "

122 "retardation (either subjective or objective).",

123 )

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

125 permitted_value_checker=BIT_CHECKER,

126 comment="Recurrent thoughts of death or suicide, or any "

127 "suicidal behaviour.",

128 )

129

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

131 permitted_value_checker=BIT_CHECKER,

132 comment="Marked loss of interest or pleasure in activities that "

133 "are normally pleasurable",

134 )

135 somatic_emotional_unreactivity: Mapped[Optional[bool]] = (

136 mapped_camcops_column(

137 permitted_value_checker=BIT_CHECKER,

138 comment="Lack of emotional reactions to events or "

139 "activities that normally produce an emotional response",

140 )

141 )

142 somatic_early_morning_waking: Mapped[Optional[bool]] = (

143 mapped_camcops_column(

144 permitted_value_checker=BIT_CHECKER,

145 comment="Waking in the morning 2 hours or more before "

146 "the usual time",

147 )

148 )

149 somatic_mood_worse_morning: Mapped[Optional[bool]] = mapped_camcops_column(

150 permitted_value_checker=BIT_CHECKER,

151 comment="Depression worse in the morning",

152 )

153 somatic_psychomotor: Mapped[Optional[bool]] = mapped_camcops_column(

154 permitted_value_checker=BIT_CHECKER,

155 comment="Objective evidence of marked psychomotor retardation or "

156 "agitation (remarked on or reported by other people)",

157 )

158 somatic_appetite: Mapped[Optional[bool]] = mapped_camcops_column(

159 permitted_value_checker=BIT_CHECKER,

160 comment="Marked loss of appetite",

161 )

162 somatic_weight: Mapped[Optional[bool]] = mapped_camcops_column(

163 permitted_value_checker=BIT_CHECKER,

164 comment="Weight loss (5 percent or more of body weight in the past "

165 "month)",

166 # 2017-08-24: AVOID A PERCENT SYMBOL (%) FOR NOW; SEE THIS BUG:

167 # https://bitbucket.org/zzzeek/sqlalchemy/issues/4052/comment-attribute-causes-crash-during # noqa

168 )

169 somatic_libido: Mapped[Optional[bool]] = mapped_camcops_column(

170 permitted_value_checker=BIT_CHECKER,

171 comment="Marked loss of libido",

172 )

173

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

175 mapped_camcops_column(

176 permitted_value_checker=BIT_CHECKER,

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

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

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

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

181 "of the body).",

182 )

183 )

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

185 permitted_value_checker=BIT_CHECKER,

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

187 )

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

189 permitted_value_checker=BIT_CHECKER,

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

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

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

193 "sensations; delusional perception; persistent delusions of "

194 "other kinds that are culturally inappropriate and completely "

195 "impossible).",

196 )

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

198 permitted_value_checker=BIT_CHECKER,

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

200 )

201 stupor: Mapped[Optional[bool]] = mapped_camcops_column(

202 permitted_value_checker=BIT_CHECKER,

203 comment="Depressive stupor.",

204 )

205

206 date_pertains_to: Mapped[Optional[datetime.date]] = mapped_camcops_column(

207 comment="Date the assessment pertains to"

208 )

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

210 UnicodeText, comment="Clinician's comments"

211 )

212 duration_at_least_2_weeks: Mapped[Optional[bool]] = mapped_camcops_column(

213 permitted_value_checker=BIT_CHECKER,

214 comment="Depressive episode lasts at least 2 weeks?",

215 )

216 severe_clinically: Mapped[Optional[bool]] = mapped_camcops_column(

217 permitted_value_checker=BIT_CHECKER,

218 comment="Clinical impression of severe depression, in a "

219 "patient unwilling or unable to describe many symptoms in "

220 "detail",

221 )

222

223 CORE_NAMES = ["mood", "anhedonia", "energy"]

224 ADDITIONAL_NAMES = [

225 "sleep",

226 "worth",

227 "appetite",

228 "guilt",

229 "concentration",

230 "activity",

231 "death",

232 ]

233 SOMATIC_NAMES = [

234 "somatic_anhedonia",

235 "somatic_emotional_unreactivity",

236 "somatic_early_morning_waking",

237 "somatic_mood_worse_morning",

238 "somatic_psychomotor",

239 "somatic_appetite",

240 "somatic_weight",

241 "somatic_libido",

242 ]

243 PSYCHOSIS_NAMES = [

244 "hallucinations_schizophrenic",

245 "hallucinations_other",

246 "delusions_schizophrenic",

247 "delusions_other",

248 "stupor",

249 ]

250

251 @staticmethod

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

253 _ = req.gettext

254 return _(

255 "ICD-10 symptomatic criteria for a depressive episode "

256 "(as in e.g. F06.3, F25, F31, F32, F33)"

257 )

258

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

260 if not self.is_complete():

261 return CTV_INCOMPLETE

262 infolist = [

263 CtvInfo(

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

265 format_datetime(

266 self.date_pertains_to, DateFormat.LONG_DATE

267 ),

268 self.get_full_description(req),

269 )

270 )

271 ]

272 if self.comments:

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

274 return infolist

275

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

277 return self.standard_task_summary_fields() + [

278 SummaryElement(

279 name="n_core",

280 coltype=Integer(),

281 value=self.n_core(),

282 comment="Number of core diagnostic symptoms (/3)",

283 ),

284 SummaryElement(

285 name="n_additional",

286 coltype=Integer(),

287 value=self.n_additional(),

288 comment="Number of additional diagnostic symptoms (/7)",

289 ),

290 SummaryElement(

291 name="n_total",

292 coltype=Integer(),

293 value=self.n_total(),

294 comment="Total number of diagnostic symptoms (/10)",

295 ),

296 SummaryElement(

297 name="n_somatic",

298 coltype=Integer(),

299 value=self.n_somatic(),

300 comment="Number of somatic syndrome symptoms (/8)",

301 ),

302 SummaryElement(

303 name="category",

304 coltype=SummaryCategoryColType,

305 value=self.get_full_description(req),

306 comment="Diagnostic category",

307 ),

308 SummaryElement(

309 name="psychosis_or_stupor",

310 coltype=Boolean(),

311 value=self.is_psychotic_or_stupor(),

312 comment="Psychotic symptoms or stupor present?",

313 ),

314 ]

315

316 # Scoring

317 def n_core(self) -> int:

318 return self.count_booleans(self.CORE_NAMES)

319

320 def n_additional(self) -> int:

321 return self.count_booleans(self.ADDITIONAL_NAMES)

322

323 def n_total(self) -> int:

324 return self.n_core() + self.n_additional()

325

326 def n_somatic(self) -> int:

327 return self.count_booleans(self.SOMATIC_NAMES)

328

329 def main_complete(self) -> bool:

330 return (

331 self.duration_at_least_2_weeks is not None

332 and self.all_fields_not_none(self.CORE_NAMES)

333 and self.all_fields_not_none(self.ADDITIONAL_NAMES)

334 ) or bool(self.severe_clinically)

335

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

337 def meets_criteria_severe_psychotic_schizophrenic(self) -> Optional[bool]:

338 x = self.meets_criteria_severe_ignoring_psychosis()

339 if not x:

340 return x

341 if self.stupor or self.hallucinations_other or self.delusions_other:

342 return False # that counts as F32.3

343 if (

344 self.stupor is None

345 or self.hallucinations_other is None

346 or self.delusions_other is None

347 ):

348 return None # might be F32.3

349 if self.hallucinations_schizophrenic or self.delusions_schizophrenic:

350 return True

351 if (

352 self.hallucinations_schizophrenic is None

353 or self.delusions_schizophrenic is None

354 ):

355 return None

356 return False

357

358 def meets_criteria_severe_psychotic_icd(self) -> Optional[bool]:

359 x = self.meets_criteria_severe_ignoring_psychosis()

360 if not x:

361 return x

362 if self.stupor or self.hallucinations_other or self.delusions_other:

363 return True

364 if (

365 self.stupor is None

366 or self.hallucinations_other is None

367 or self.delusions_other is None

368 ):

369 return None

370 return False

371

372 def meets_criteria_severe_nonpsychotic(self) -> Optional[bool]:

373 x = self.meets_criteria_severe_ignoring_psychosis()

374 if not x:

375 return x

376 if self.any_fields_none(self.PSYCHOSIS_NAMES):

377 return None

378 return self.count_booleans(self.PSYCHOSIS_NAMES) == 0

379

380 def meets_criteria_severe_ignoring_psychosis(self) -> Optional[bool]:

381 if self.severe_clinically:

382 return True

383 if (

384 self.duration_at_least_2_weeks is not None

385 and not self.duration_at_least_2_weeks

386 ):

387 return False # too short

388 if self.n_core() >= 3 and self.n_total() >= 8:

389 return True

390 if not self.main_complete():

391 return None # addition of more information might increase severity

392 return False

393

394 def meets_criteria_moderate(self) -> Optional[bool]:

395 if self.severe_clinically:

396 return False # too severe

397 if (

398 self.duration_at_least_2_weeks is not None

399 and not self.duration_at_least_2_weeks

400 ):

401 return False # too short

402 if self.n_core() >= 3 and self.n_total() >= 8:

403 return False # too severe; that's severe

404 if not self.main_complete():

405 return None # addition of more information might increase severity

406 if self.n_core() >= 2 and self.n_total() >= 6:

407 return True

408 return False

409

410 def meets_criteria_mild(self) -> Optional[bool]:

411 if self.severe_clinically:

412 return False # too severe

413 if (

414 self.duration_at_least_2_weeks is not None

415 and not self.duration_at_least_2_weeks

416 ):

417 return False # too short

418 if self.n_core() >= 2 and self.n_total() >= 6:

419 return False # too severe; that's moderate

420 if not self.main_complete():

421 return None # addition of more information might increase severity

422 if self.n_core() >= 2 and self.n_total() >= 4:

423 return True

424 return False

425

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

427 if self.severe_clinically:

428 return False # too severe

429 if (

430 self.duration_at_least_2_weeks is not None

431 and not self.duration_at_least_2_weeks

432 ):

433 return True # too short for depression

434 if self.n_core() >= 2 and self.n_total() >= 4:

435 return False # too severe

436 if not self.main_complete():

437 return None # addition of more information might increase severity

438 return True

439

440 def meets_criteria_somatic(self) -> Optional[bool]:

441 t = self.n_somatic()

442 u = self.n_fields_none(self.SOMATIC_NAMES)

443 if t >= 4:

444 return True

445 elif t + u < 4:

446 return False

447 else:

448 return None

449

450 def get_somatic_description(self, req: CamcopsRequest) -> str:

451 s = self.meets_criteria_somatic()

452 if s is None:

453 return self.wxstring(req, "category_somatic_unknown")

454 elif s:

455 return self.wxstring(req, "category_with_somatic")

456 else:

457 return self.wxstring(req, "category_without_somatic")

458

459 def get_main_description(self, req: CamcopsRequest) -> str:

460 if self.meets_criteria_severe_psychotic_schizophrenic():

461 return self.wxstring(

462 req, "category_severe_psychotic_schizophrenic"

463 )

464

465 elif self.meets_criteria_severe_psychotic_icd():

466 return self.wxstring(req, "category_severe_psychotic")

467

468 elif self.meets_criteria_severe_nonpsychotic():

469 return self.wxstring(req, "category_severe_nonpsychotic")

470

471 elif self.meets_criteria_moderate():

472 return self.wxstring(req, "category_moderate")

473

474 elif self.meets_criteria_mild():

475 return self.wxstring(req, "category_mild")

476

477 elif self.meets_criteria_none():

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

479

480 else:

481 return req.sstring(SS.UNKNOWN)

482

483 def get_full_description(self, req: CamcopsRequest) -> str:

484 skip_somatic = self.main_complete() and self.meets_criteria_none()

485 return self.get_main_description(req) + (

486 "" if skip_somatic else " " + self.get_somatic_description(req)

487 )

488

489 def is_psychotic_or_stupor(self) -> Optional[bool]:

490 if self.count_booleans(self.PSYCHOSIS_NAMES) > 0:

491 return True

492 elif self.all_fields_not_none(self.PSYCHOSIS_NAMES) > 0:

493 return False

494 else:

495 return None

496

497 def is_complete(self) -> bool:

498 return (

499 self.date_pertains_to is not None

500 and self.main_complete()

501 and self.field_contents_valid()

502 )

503

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

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

506

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

508 return self.get_twocol_bool_row_true_false(

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

510 )

511

512 def row_present_absent(self, req: CamcopsRequest, fieldname: str) -> str:

513 return self.get_twocol_bool_row_present_absent(

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

515 )

516

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

518 h = """

519 {clinician_comments}

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

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

522 {tr_is_complete}

523 {date_pertains_to}

524 {category}

525 {n_core}

526 {n_total}

527 {n_somatic}

528 {psychotic_symptoms_or_stupor}

529 </table>

530 </div>

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

532 {icd10_symptomatic_disclaimer}

533 </div>

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

535 <tr>

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

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

538 </tr>

539 """.format(

540 clinician_comments=self.get_standard_clinician_comments_block(

541 req, self.comments

542 ),

543 CssClass=CssClass,

544 tr_is_complete=self.get_is_complete_tr(req),

545 date_pertains_to=tr_qa(

546 req.wappstring(AS.DATE_PERTAINS_TO),

547 format_datetime(

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

549 ),

550 ),

551 category=tr_qa(

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

553 self.get_full_description(req),

554 ),

555 n_core=tr(

556 self.wxstring(req, "n_core"), answer(self.n_core()) + " / 3"

557 ),

558 n_total=tr(

559 self.wxstring(req, "n_total"), answer(self.n_total()) + " / 10"

560 ),

561 n_somatic=tr(

562 self.wxstring(req, "n_somatic"),

563 answer(self.n_somatic()) + " / 8",

564 ),

565 psychotic_symptoms_or_stupor=tr(

566 self.wxstring(req, "psychotic_symptoms_or_stupor")

567 + " <sup>[2]</sup>",

568 answer(

569 get_present_absent_none(req, self.is_psychotic_or_stupor())

570 ),

571 ),

572 icd10_symptomatic_disclaimer=req.wappstring(

573 AS.ICD10_SYMPTOMATIC_DISCLAIMER

574 ),

575 )

576

577 h += self.text_row(req, "duration_text")

578 h += self.row_true_false(req, "duration_at_least_2_weeks")

579

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

581 for x in self.CORE_NAMES:

582 h += self.row_present_absent(req, x)

583

584 h += self.text_row(req, "additional")

585 for x in self.ADDITIONAL_NAMES:

586 h += self.row_present_absent(req, x)

587

588 h += self.text_row(req, "clinical_text")

589 h += self.row_true_false(req, "severe_clinically")

590

591 h += self.text_row(req, "somatic")

592 for x in self.SOMATIC_NAMES:

593 h += self.row_present_absent(req, x)

594

595 h += self.text_row(req, "psychotic")

596 for x in self.PSYCHOSIS_NAMES:

597 h += self.row_present_absent(req, x)

598

599 extradetail = [

600 f"n_core() = {self.n_core()}",

601 f"n_additional() = {self.n_additional()}",

602 f"n_total() = {self.n_total()}",

603 f"n_somatic() = {self.n_somatic()}",

604 f"main_complete() = {self.main_complete()}",

605 f"meets_criteria_severe_psychotic_schizophrenic() = {self.meets_criteria_severe_psychotic_schizophrenic()}", # noqa

606 f"meets_criteria_severe_psychotic_icd() = {self.meets_criteria_severe_psychotic_icd()}", # noqa

607 f"meets_criteria_severe_nonpsychotic() = {self.meets_criteria_severe_nonpsychotic()}", # noqa

608 f"meets_criteria_severe_ignoring_psychosis() = {self.meets_criteria_severe_ignoring_psychosis()}", # noqa

609 f"meets_criteria_moderate() = {self.meets_criteria_moderate()}",

610 f"meets_criteria_mild() = {self.meets_criteria_mild()}",

611 f"meets_criteria_none() = {self.meets_criteria_none()}",

612 f"meets_criteria_somatic() = {self.meets_criteria_somatic()}",

613 ]

614

615 h += f"""

616 </table>

617 <div class="{CssClass.HEADING}">Working</div>

618 <div class="{CssClass.EXTRADETAIL2}">

619 <pre>{"<br>".join(ws.webify(f"‣ {x}") for x in extradetail)}</pre>

620 </div>

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

622 [1] Mild depression requires ≥2 core symptoms and ≥4 total

623 diagnostic symptoms.

624 Moderate depression requires ≥2 core and ≥6 total.

625 Severe depression requires 3 core and ≥8 total.

626 All three require a duration of ≥2 weeks.

627 In addition, the diagnosis of severe depression is allowed with

628 a clinical impression of “severe” in a patient unable/unwilling

629 to describe symptoms in detail.

630 [2] ICD-10 nonpsychotic severe depression requires severe

631 depression without hallucinations/delusions/depressive stupor.

632 ICD-10 psychotic depression requires severe depression plus

633 hallucinations/delusions other than those that are “typically

634 schizophrenic”, or stupor.

635 ICD-10 does not clearly categorize severe depression with only

636 schizophreniform psychotic symptoms;

637 however, such symptoms can occur in severe depression with

638 psychosis (e.g. Tandon R &amp; Greden JF, 1987, PMID 2884810).

639 Moreover, psychotic symptoms can occur in mild/moderate

640 depression (Maj M et al., 2007, PMID 17915981).

641 </div>

642 {ICD10_COPYRIGHT_DIV}

643 """ # noqa

644 return h