Coverage for tasks/icd10depressive.py: 36%
213 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/tasks/icd10depressive.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
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.
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.
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/>.
24===============================================================================
26"""
28import datetime
29from typing import List, Optional
31from cardinal_pythonlib.datetimefunc import format_datetime
32import cardinal_pythonlib.rnc_web as ws
33from sqlalchemy.orm import Mapped, mapped_column
34from sqlalchemy.sql.sqltypes import Boolean, Integer, UnicodeText
36from camcops_server.cc_modules.cc_constants import (
37 CssClass,
38 DateFormat,
39 ICD10_COPYRIGHT_DIV,
40)
41from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
42from 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)
49from camcops_server.cc_modules.cc_request import CamcopsRequest
50from camcops_server.cc_modules.cc_sqla_coltypes import (
51 BIT_CHECKER,
52 mapped_camcops_column,
53 SummaryCategoryColType,
54)
55from camcops_server.cc_modules.cc_string import AS
56from camcops_server.cc_modules.cc_summaryelement import SummaryElement
57from camcops_server.cc_modules.cc_task import (
58 Task,
59 TaskHasClinicianMixin,
60 TaskHasPatientMixin,
61)
62from camcops_server.cc_modules.cc_text import SS
65# =============================================================================
66# Icd10Depressive
67# =============================================================================
70class Icd10Depressive(TaskHasClinicianMixin, TaskHasPatientMixin, Task): # type: ignore[misc] # noqa: E501
71 """
72 Server implementation of the ICD10-DEPR task.
73 """
75 __tablename__ = "icd10depressive"
76 shortname = "ICD10-DEPR"
77 info_filename_stem = "icd"
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 )
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 )
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 )
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 )
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 )
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 ]
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 )
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
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 ]
316 # Scoring
317 def n_core(self) -> int:
318 return self.count_booleans(self.CORE_NAMES)
320 def n_additional(self) -> int:
321 return self.count_booleans(self.ADDITIONAL_NAMES)
323 def n_total(self) -> int:
324 return self.n_core() + self.n_additional()
326 def n_somatic(self) -> int:
327 return self.count_booleans(self.SOMATIC_NAMES)
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)
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
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
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
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
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
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
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
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
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")
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 )
465 elif self.meets_criteria_severe_psychotic_icd():
466 return self.wxstring(req, "category_severe_psychotic")
468 elif self.meets_criteria_severe_nonpsychotic():
469 return self.wxstring(req, "category_severe_nonpsychotic")
471 elif self.meets_criteria_moderate():
472 return self.wxstring(req, "category_moderate")
474 elif self.meets_criteria_mild():
475 return self.wxstring(req, "category_mild")
477 elif self.meets_criteria_none():
478 return self.wxstring(req, "category_none")
480 else:
481 return req.sstring(SS.UNKNOWN)
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 )
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
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 )
504 def text_row(self, req: CamcopsRequest, wstringname: str) -> str:
505 return heading_spanning_two_columns(self.wxstring(req, wstringname))
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 )
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 )
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 )
577 h += self.text_row(req, "duration_text")
578 h += self.row_true_false(req, "duration_at_least_2_weeks")
580 h += self.text_row(req, "core")
581 for x in self.CORE_NAMES:
582 h += self.row_present_absent(req, x)
584 h += self.text_row(req, "additional")
585 for x in self.ADDITIONAL_NAMES:
586 h += self.row_present_absent(req, x)
588 h += self.text_row(req, "clinical_text")
589 h += self.row_true_false(req, "severe_clinically")
591 h += self.text_row(req, "somatic")
592 for x in self.SOMATIC_NAMES:
593 h += self.row_present_absent(req, x)
595 h += self.text_row(req, "psychotic")
596 for x in self.PSYCHOSIS_NAMES:
597 h += self.row_present_absent(req, x)
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 ]
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 & 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