Coverage for tasks/icd10depressive.py : 35%

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
3"""
4camcops_server/tasks/icd10depressive.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
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.
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.
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/>.
25===============================================================================
27"""
29from typing import List, Optional
31from cardinal_pythonlib.datetimefunc import format_datetime
32import cardinal_pythonlib.rnc_web as ws
33from sqlalchemy.sql.schema import Column
34from sqlalchemy.sql.sqltypes import Boolean, Date, 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 CamcopsColumn,
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# =============================================================================
69class Icd10Depressive(TaskHasClinicianMixin, TaskHasPatientMixin, Task):
70 """
71 Server implementation of the ICD10-DEPR task.
72 """
73 __tablename__ = "icd10depressive"
74 shortname = "ICD10-DEPR"
76 mood = CamcopsColumn(
77 "mood", Boolean,
78 permitted_value_checker=BIT_CHECKER,
79 comment="Depressed mood to a degree that is definitely abnormal "
80 "for the individual, present for most of the day and almost "
81 "every day, largely uninfluenced by circumstances, and "
82 "sustained for at least 2 weeks."
83 )
84 anhedonia = CamcopsColumn(
85 "anhedonia", Boolean,
86 permitted_value_checker=BIT_CHECKER,
87 comment="Loss of interest or pleasure in activities that are "
88 "normally pleasurable."
89 )
90 energy = CamcopsColumn(
91 "energy", Boolean,
92 permitted_value_checker=BIT_CHECKER,
93 comment="Decreased energy or increased fatiguability."
94 )
96 sleep = CamcopsColumn(
97 "sleep", Boolean,
98 permitted_value_checker=BIT_CHECKER,
99 comment="Sleep disturbance of any type."
100 )
101 worth = CamcopsColumn(
102 "worth", Boolean,
103 permitted_value_checker=BIT_CHECKER,
104 comment="Loss of confidence and self-esteem."
105 )
106 appetite = CamcopsColumn(
107 "appetite", Boolean,
108 permitted_value_checker=BIT_CHECKER,
109 comment="Change in appetite (decrease or increase) with "
110 "corresponding weight change."
111 )
112 guilt = CamcopsColumn(
113 "guilt", Boolean,
114 permitted_value_checker=BIT_CHECKER,
115 comment="Unreasonable feelings of self-reproach or excessive and "
116 "inappropriate guilt."
117 )
118 concentration = CamcopsColumn(
119 "concentration", Boolean,
120 permitted_value_checker=BIT_CHECKER,
121 comment="Complaints or evidence of diminished ability to think "
122 "or concentrate, such as indecisiveness or vacillation."
123 )
124 activity = CamcopsColumn(
125 "activity", Boolean,
126 permitted_value_checker=BIT_CHECKER,
127 comment="Change in psychomotor activity, with agitation or "
128 "retardation (either subjective or objective)."
129 )
130 death = CamcopsColumn(
131 "death", Boolean,
132 permitted_value_checker=BIT_CHECKER,
133 comment="Recurrent thoughts of death or suicide, or any "
134 "suicidal behaviour."
135 )
137 somatic_anhedonia = CamcopsColumn(
138 "somatic_anhedonia", Boolean,
139 permitted_value_checker=BIT_CHECKER,
140 comment="Marked loss of interest or pleasure in activities that "
141 "are normally pleasurable"
142 )
143 somatic_emotional_unreactivity = CamcopsColumn(
144 "somatic_emotional_unreactivity", Boolean,
145 permitted_value_checker=BIT_CHECKER,
146 comment="Lack of emotional reactions to events or "
147 "activities that normally produce an emotional response"
148 )
149 somatic_early_morning_waking = CamcopsColumn(
150 "somatic_early_morning_waking", Boolean,
151 permitted_value_checker=BIT_CHECKER,
152 comment="Waking in the morning 2 hours or more before "
153 "the usual time"
154 )
155 somatic_mood_worse_morning = CamcopsColumn(
156 "somatic_mood_worse_morning", Boolean,
157 permitted_value_checker=BIT_CHECKER,
158 comment="Depression worse in the morning"
159 )
160 somatic_psychomotor = CamcopsColumn(
161 "somatic_psychomotor", Boolean,
162 permitted_value_checker=BIT_CHECKER,
163 comment="Objective evidence of marked psychomotor retardation or "
164 "agitation (remarked on or reported by other people)"
165 )
166 somatic_appetite = CamcopsColumn(
167 "somatic_appetite", Boolean,
168 permitted_value_checker=BIT_CHECKER,
169 comment="Marked loss of appetite"
170 )
171 somatic_weight = CamcopsColumn(
172 "somatic_weight", Boolean,
173 permitted_value_checker=BIT_CHECKER,
174 comment="Weight loss (5 percent or more of body weight in the past "
175 "month)"
176 # 2017-08-24: AVOID A PERCENT SYMBOL (%) FOR NOW; SEE THIS BUG:
177 # https://bitbucket.org/zzzeek/sqlalchemy/issues/4052/comment-attribute-causes-crash-during # noqa
178 )
179 somatic_libido = CamcopsColumn(
180 "somatic_libido", Boolean,
181 permitted_value_checker=BIT_CHECKER,
182 comment="Marked loss of libido"
183 )
185 hallucinations_schizophrenic = CamcopsColumn(
186 "hallucinations_schizophrenic", Boolean,
187 permitted_value_checker=BIT_CHECKER,
188 comment="Hallucinations that are 'typically schizophrenic' "
189 "(hallucinatory voices giving a running commentary on the "
190 "patient's behaviour, or discussing him between themselves, "
191 "or other types of hallucinatory voices coming from some part "
192 "of the body)."
193 )
194 hallucinations_other = CamcopsColumn(
195 "hallucinations_other", Boolean,
196 permitted_value_checker=BIT_CHECKER,
197 comment="Hallucinations (of any other kind)."
198 )
199 delusions_schizophrenic = CamcopsColumn(
200 "delusions_schizophrenic", Boolean,
201 permitted_value_checker=BIT_CHECKER,
202 comment="Delusions that are 'typically schizophrenic' (delusions "
203 "of control, influence or passivity, clearly referred to body "
204 "or limb movements or specific thoughts, actions, or "
205 "sensations; delusional perception; persistent delusions of "
206 "other kinds that are culturally inappropriate and completely "
207 "impossible).")
208 delusions_other = CamcopsColumn(
209 "delusions_other", Boolean,
210 permitted_value_checker=BIT_CHECKER,
211 comment="Delusions (of any other kind)."
212 )
213 stupor = CamcopsColumn(
214 "stupor", Boolean,
215 permitted_value_checker=BIT_CHECKER,
216 comment="Depressive stupor."
217 )
219 date_pertains_to = CamcopsColumn(
220 "date_pertains_to", Date,
221 comment="Date the assessment pertains to"
222 )
223 comments = Column(
224 "comments", UnicodeText,
225 comment="Clinician's comments"
226 )
227 duration_at_least_2_weeks = CamcopsColumn(
228 "duration_at_least_2_weeks", Boolean,
229 permitted_value_checker=BIT_CHECKER,
230 comment="Depressive episode lasts at least 2 weeks?"
231 )
232 severe_clinically = CamcopsColumn(
233 "severe_clinically", Boolean,
234 permitted_value_checker=BIT_CHECKER,
235 comment="Clinical impression of severe depression, in a "
236 "patient unwilling or unable to describe many symptoms in "
237 "detail"
238 )
240 CORE_NAMES = ["mood", "anhedonia", "energy"]
241 ADDITIONAL_NAMES = ["sleep", "worth", "appetite", "guilt", "concentration",
242 "activity", "death"]
243 SOMATIC_NAMES = [
244 "somatic_anhedonia", "somatic_emotional_unreactivity",
245 "somatic_early_morning_waking", "somatic_mood_worse_morning",
246 "somatic_psychomotor", "somatic_appetite",
247 "somatic_weight", "somatic_libido"
248 ]
249 PSYCHOSIS_NAMES = [
250 "hallucinations_schizophrenic", "hallucinations_other",
251 "delusions_schizophrenic", "delusions_other",
252 "stupor"
253 ]
255 @staticmethod
256 def longname(req: "CamcopsRequest") -> str:
257 _ = req.gettext
258 return _(
259 "ICD-10 symptomatic criteria for a depressive episode "
260 "(as in e.g. F06.3, F25, F31, F32, F33)"
261 )
263 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
264 if not self.is_complete():
265 return CTV_INCOMPLETE
266 infolist = [CtvInfo(
267 content="Pertains to: {}. Category: {}.".format(
268 format_datetime(self.date_pertains_to, DateFormat.LONG_DATE),
269 self.get_full_description(req)
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 SummaryElement(
284 name="n_additional",
285 coltype=Integer(),
286 value=self.n_additional(),
287 comment="Number of additional diagnostic symptoms (/7)"),
288 SummaryElement(
289 name="n_total",
290 coltype=Integer(),
291 value=self.n_total(),
292 comment="Total number of diagnostic symptoms (/10)"),
293 SummaryElement(
294 name="n_somatic",
295 coltype=Integer(),
296 value=self.n_somatic(),
297 comment="Number of somatic syndrome symptoms (/8)"),
298 SummaryElement(
299 name="category",
300 coltype=SummaryCategoryColType,
301 value=self.get_full_description(req),
302 comment="Diagnostic category"),
303 SummaryElement(
304 name="psychosis_or_stupor",
305 coltype=Boolean(),
306 value=self.is_psychotic_or_stupor(),
307 comment="Psychotic symptoms or stupor present?"),
308 ]
310 # Scoring
311 def n_core(self) -> int:
312 return self.count_booleans(self.CORE_NAMES)
314 def n_additional(self) -> int:
315 return self.count_booleans(self.ADDITIONAL_NAMES)
317 def n_total(self) -> int:
318 return self.n_core() + self.n_additional()
320 def n_somatic(self) -> int:
321 return self.count_booleans(self.SOMATIC_NAMES)
323 def main_complete(self) -> bool:
324 return (
325 self.duration_at_least_2_weeks is not None and
326 self.all_fields_not_none(self.CORE_NAMES) and
327 self.all_fields_not_none(self.ADDITIONAL_NAMES)
328 ) or bool(
329 self.severe_clinically
330 )
332 # Meets criteria? These also return null for unknown.
333 def meets_criteria_severe_psychotic_schizophrenic(self) -> Optional[bool]:
334 x = self.meets_criteria_severe_ignoring_psychosis()
335 if not x:
336 return x
337 if self.stupor or self.hallucinations_other or self.delusions_other:
338 return False # that counts as F32.3
339 if (self.stupor is None or self.hallucinations_other is None or
340 self.delusions_other is None):
341 return None # might be F32.3
342 if self.hallucinations_schizophrenic or self.delusions_schizophrenic:
343 return True
344 if (self.hallucinations_schizophrenic is None or
345 self.delusions_schizophrenic is None):
346 return None
347 return False
349 def meets_criteria_severe_psychotic_icd(self) -> Optional[bool]:
350 x = self.meets_criteria_severe_ignoring_psychosis()
351 if not x:
352 return x
353 if self.stupor or self.hallucinations_other or self.delusions_other:
354 return True
355 if (self.stupor is None or
356 self.hallucinations_other is None or
357 self.delusions_other is None):
358 return None
359 return False
361 def meets_criteria_severe_nonpsychotic(self) -> Optional[bool]:
362 x = self.meets_criteria_severe_ignoring_psychosis()
363 if not x:
364 return x
365 if self.any_fields_none(self.PSYCHOSIS_NAMES):
366 return None
367 return self.count_booleans(self.PSYCHOSIS_NAMES) == 0
369 def meets_criteria_severe_ignoring_psychosis(self) -> Optional[bool]:
370 if self.severe_clinically:
371 return True
372 if self.duration_at_least_2_weeks is not None \
373 and not self.duration_at_least_2_weeks:
374 return False # too short
375 if self.n_core() >= 3 and self.n_total() >= 8:
376 return True
377 if not self.main_complete():
378 return None # addition of more information might increase severity
379 return False
381 def meets_criteria_moderate(self) -> Optional[bool]:
382 if self.severe_clinically:
383 return False # too severe
384 if self.duration_at_least_2_weeks is not None \
385 and not self.duration_at_least_2_weeks:
386 return False # too short
387 if self.n_core() >= 3 and self.n_total() >= 8:
388 return False # too severe; that's severe
389 if not self.main_complete():
390 return None # addition of more information might increase severity
391 if self.n_core() >= 2 and self.n_total() >= 6:
392 return True
393 return False
395 def meets_criteria_mild(self) -> Optional[bool]:
396 if self.severe_clinically:
397 return False # too severe
398 if self.duration_at_least_2_weeks is not None \
399 and not self.duration_at_least_2_weeks:
400 return False # too short
401 if self.n_core() >= 2 and self.n_total() >= 6:
402 return False # too severe; that's moderate
403 if not self.main_complete():
404 return None # addition of more information might increase severity
405 if self.n_core() >= 2 and self.n_total() >= 4:
406 return True
407 return False
409 def meets_criteria_none(self) -> Optional[bool]:
410 if self.severe_clinically:
411 return False # too severe
412 if self.duration_at_least_2_weeks is not None \
413 and not self.duration_at_least_2_weeks:
414 return True # too short for depression
415 if self.n_core() >= 2 and self.n_total() >= 4:
416 return False # too severe
417 if not self.main_complete():
418 return None # addition of more information might increase severity
419 return True
421 def meets_criteria_somatic(self) -> Optional[bool]:
422 t = self.n_somatic()
423 u = self.n_fields_none(self.SOMATIC_NAMES)
424 if t >= 4:
425 return True
426 elif t + u < 4:
427 return False
428 else:
429 return None
431 def get_somatic_description(self, req: CamcopsRequest) -> str:
432 s = self.meets_criteria_somatic()
433 if s is None:
434 return self.wxstring(req, "category_somatic_unknown")
435 elif s:
436 return self.wxstring(req, "category_with_somatic")
437 else:
438 return self.wxstring(req, "category_without_somatic")
440 def get_main_description(self, req: CamcopsRequest) -> str:
441 if self.meets_criteria_severe_psychotic_schizophrenic():
442 return self.wxstring(req, "category_severe_psychotic_schizophrenic")
444 elif self.meets_criteria_severe_psychotic_icd():
445 return self.wxstring(req, "category_severe_psychotic")
447 elif self.meets_criteria_severe_nonpsychotic():
448 return self.wxstring(req, "category_severe_nonpsychotic")
450 elif self.meets_criteria_moderate():
451 return self.wxstring(req, "category_moderate")
453 elif self.meets_criteria_mild():
454 return self.wxstring(req, "category_mild")
456 elif self.meets_criteria_none():
457 return self.wxstring(req, "category_none")
459 else:
460 return req.sstring(SS.UNKNOWN)
462 def get_full_description(self, req: CamcopsRequest) -> str:
463 skip_somatic = self.main_complete() and self.meets_criteria_none()
464 return (
465 self.get_main_description(req) +
466 ("" if skip_somatic else " " + self.get_somatic_description(req))
467 )
469 def is_psychotic_or_stupor(self) -> Optional[bool]:
470 if self.count_booleans(self.PSYCHOSIS_NAMES) > 0:
471 return True
472 elif self.all_fields_not_none(self.PSYCHOSIS_NAMES) > 0:
473 return False
474 else:
475 return None
477 def is_complete(self) -> bool:
478 return (
479 self.date_pertains_to is not None and
480 self.main_complete() and
481 self.field_contents_valid()
482 )
484 def text_row(self, req: CamcopsRequest, wstringname: str) -> str:
485 return heading_spanning_two_columns(self.wxstring(req, wstringname))
487 def row_true_false(self, req: CamcopsRequest, fieldname: str) -> str:
488 return self.get_twocol_bool_row_true_false(
489 req, fieldname, self.wxstring(req, "" + fieldname))
491 def row_present_absent(self, req: CamcopsRequest, fieldname: str) -> str:
492 return self.get_twocol_bool_row_present_absent(
493 req, fieldname, self.wxstring(req, "" + fieldname))
495 def get_task_html(self, req: CamcopsRequest) -> str:
496 h = """
497 {clinician_comments}
498 <div class="{CssClass.SUMMARY}">
499 <table class="{CssClass.SUMMARY}">
500 {tr_is_complete}
501 {date_pertains_to}
502 {category}
503 {n_core}
504 {n_total}
505 {n_somatic}
506 {psychotic_symptoms_or_stupor}
507 </table>
508 </div>
509 <div class="{CssClass.EXPLANATION}">
510 {icd10_symptomatic_disclaimer}
511 </div>
512 <table class="{CssClass.TASKDETAIL}">
513 <tr>
514 <th width="80%">Question</th>
515 <th width="20%">Answer</th>
516 </tr>
517 """.format(
518 clinician_comments=self.get_standard_clinician_comments_block(
519 req, self.comments),
520 CssClass=CssClass,
521 tr_is_complete=self.get_is_complete_tr(req),
522 date_pertains_to=tr_qa(
523 req.wappstring(AS.DATE_PERTAINS_TO),
524 format_datetime(self.date_pertains_to, DateFormat.LONG_DATE,
525 default=None)
526 ),
527 category=tr_qa(
528 req.sstring(SS.CATEGORY) + " <sup>[1,2]</sup>",
529 self.get_full_description(req)
530 ),
531 n_core=tr(
532 self.wxstring(req, "n_core"),
533 answer(self.n_core()) + " / 3"
534 ),
535 n_total=tr(
536 self.wxstring(req, "n_total"),
537 answer(self.n_total()) + " / 10"
538 ),
539 n_somatic=tr(
540 self.wxstring(req, "n_somatic"),
541 answer(self.n_somatic()) + " / 8"
542 ),
543 psychotic_symptoms_or_stupor=tr(
544 self.wxstring(req, "psychotic_symptoms_or_stupor") +
545 " <sup>[2]</sup>",
546 answer(get_present_absent_none(
547 req, self.is_psychotic_or_stupor()))
548 ),
549 icd10_symptomatic_disclaimer=req.wappstring(
550 AS.ICD10_SYMPTOMATIC_DISCLAIMER),
551 )
553 h += self.text_row(req, "duration_text")
554 h += self.row_true_false(req, "duration_at_least_2_weeks")
556 h += self.text_row(req, "core")
557 for x in self.CORE_NAMES:
558 h += self.row_present_absent(req, x)
560 h += self.text_row(req, "additional")
561 for x in self.ADDITIONAL_NAMES:
562 h += self.row_present_absent(req, x)
564 h += self.text_row(req, "clinical_text")
565 h += self.row_true_false(req, "severe_clinically")
567 h += self.text_row(req, "somatic")
568 for x in self.SOMATIC_NAMES:
569 h += self.row_present_absent(req, x)
571 h += self.text_row(req, "psychotic")
572 for x in self.PSYCHOSIS_NAMES:
573 h += self.row_present_absent(req, x)
575 extradetail = [
576 f"n_core() = {self.n_core()}",
577 f"n_additional() = {self.n_additional()}",
578 f"n_total() = {self.n_total()}",
579 f"n_somatic() = {self.n_somatic()}",
580 f"main_complete() = {self.main_complete()}",
581 f"meets_criteria_severe_psychotic_schizophrenic() = {self.meets_criteria_severe_psychotic_schizophrenic()}", # noqa
582 f"meets_criteria_severe_psychotic_icd() = {self.meets_criteria_severe_psychotic_icd()}", # noqa
583 f"meets_criteria_severe_nonpsychotic() = {self.meets_criteria_severe_nonpsychotic()}", # noqa
584 f"meets_criteria_severe_ignoring_psychosis() = {self.meets_criteria_severe_ignoring_psychosis()}", # noqa
585 f"meets_criteria_moderate() = {self.meets_criteria_moderate()}",
586 f"meets_criteria_mild() = {self.meets_criteria_mild()}",
587 f"meets_criteria_none() = {self.meets_criteria_none()}",
588 f"meets_criteria_somatic() = {self.meets_criteria_somatic()}",
589 ]
591 h += f"""
592 </table>
593 <div class="{CssClass.HEADING}">Working</div>
594 <div class="{CssClass.EXTRADETAIL2}">
595 <pre>{"<br>".join(ws.webify(f"‣ {x}") for x in extradetail)}</pre>
596 </div>
597 <div class="{CssClass.FOOTNOTES}">
598 [1] Mild depression requires ≥2 core symptoms and ≥4 total
599 diagnostic symptoms.
600 Moderate depression requires ≥2 core and ≥6 total.
601 Severe depression requires 3 core and ≥8 total.
602 All three require a duration of ≥2 weeks.
603 In addition, the diagnosis of severe depression is allowed with
604 a clinical impression of “severe” in a patient unable/unwilling
605 to describe symptoms in detail.
606 [2] ICD-10 nonpsychotic severe depression requires severe
607 depression without hallucinations/delusions/depressive stupor.
608 ICD-10 psychotic depression requires severe depression plus
609 hallucinations/delusions other than those that are “typically
610 schizophrenic”, or stupor.
611 ICD-10 does not clearly categorize severe depression with only
612 schizophreniform psychotic symptoms;
613 however, such symptoms can occur in severe depression with
614 psychosis (e.g. Tandon R & Greden JF, 1987, PMID 2884810).
615 Moreover, psychotic symptoms can occur in mild/moderate
616 depression (Maj M et al., 2007, PMID 17915981).
617 </div>
618 {ICD10_COPYRIGHT_DIV}
619 """ # noqa
620 return h