Source code for camcops_server.tasks.bmi

"""
camcops_server/tasks/bmi.py

===============================================================================

    Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
    Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

    This file is part of CamCOPS.

    CamCOPS is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    CamCOPS is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.

===============================================================================

"""

from typing import Dict, List, Optional, TYPE_CHECKING

import cardinal_pythonlib.rnc_web as ws
from fhirclient.models.codeableconcept import CodeableConcept
from fhirclient.models.coding import Coding
from fhirclient.models.quantity import Quantity
from sqlalchemy.sql.schema import Column
from sqlalchemy.sql.sqltypes import Float, UnicodeText

from camcops_server.cc_modules.cc_constants import CssClass, FHIRConst as Fc
from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
from camcops_server.cc_modules.cc_fhir import make_fhir_bundle_entry
from camcops_server.cc_modules.cc_html import tr_qa
from camcops_server.cc_modules.cc_request import CamcopsRequest
from camcops_server.cc_modules.cc_snomed import (
    SnomedAttributeGroup,
    SnomedExpression,
    SnomedLookup,
)
from camcops_server.cc_modules.cc_summaryelement import SummaryElement
from camcops_server.cc_modules.cc_sqla_coltypes import (
    CamcopsColumn,
    PermittedValueChecker,
)
from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin
from camcops_server.cc_modules.cc_trackerhelpers import (
    LabelAlignment,
    TrackerInfo,
    TrackerLabel,
)

if TYPE_CHECKING:
    from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient


# =============================================================================
# BMI
# =============================================================================

BMI_DP = 2
KG_DP = 2
M_DP = 3
CM_DP = 1


[docs]class Bmi(TaskHasPatientMixin, Task): """ Server implementation of the BMI task. """ __tablename__ = "bmi" shortname = "BMI" provides_trackers = True height_m = CamcopsColumn( "height_m", Float, permitted_value_checker=PermittedValueChecker(minimum=0), comment="height (m)", ) mass_kg = CamcopsColumn( "mass_kg", Float, permitted_value_checker=PermittedValueChecker(minimum=0), comment="mass (kg)", ) waist_cm = CamcopsColumn( "waist_cm", Float, permitted_value_checker=PermittedValueChecker(minimum=0), comment="waist circumference (cm)", ) comment = Column("comment", UnicodeText, comment="Clinician's comment")
[docs] @staticmethod def longname(req: "CamcopsRequest") -> str: _ = req.gettext return _("Body mass index")
[docs] def is_complete(self) -> bool: return ( self.height_m is not None and self.mass_kg is not None and self.field_contents_valid() )
[docs] def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]: # $ signs enable TEX mode for matplotlib, e.g. "$BMI (kg/m^2)$" return [ TrackerInfo( value=self.bmi(), plot_label="Body mass index", axis_label="BMI (kg/m^2)", axis_min=10, axis_max=42, horizontal_lines=[13, 15, 16, 17, 17.5, 18.5, 25, 30, 35, 40], horizontal_labels=[ # positioned near the mid-range for some: TrackerLabel( 12.5, self.wxstring(req, "underweight_under_13"), LabelAlignment.top, ), TrackerLabel(14, self.wxstring(req, "underweight_13_15")), TrackerLabel( 15.5, self.wxstring(req, "underweight_15_16") ), TrackerLabel( 16.5, self.wxstring(req, "underweight_16_17") ), TrackerLabel( 17.25, self.wxstring(req, "underweight_17_17.5") ), TrackerLabel( 18, self.wxstring(req, "underweight_17.5_18.5") ), TrackerLabel(21.75, self.wxstring(req, "normal")), TrackerLabel(27.5, self.wxstring(req, "overweight")), TrackerLabel(32.5, self.wxstring(req, "obese_1")), TrackerLabel(37.6, self.wxstring(req, "obese_2")), TrackerLabel( 40.5, self.wxstring(req, "obese_3"), LabelAlignment.bottom, ), ], aspect_ratio=1.0, ), TrackerInfo( value=self.mass_kg, plot_label="Mass (kg)", axis_label="Mass (kg)", ), TrackerInfo( value=self.waist_cm, plot_label="Waist circumference (cm)", axis_label="Waist circumference (cm)", ), ]
[docs] def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]: if not self.is_complete(): return CTV_INCOMPLETE return [ CtvInfo( content=( f"BMI: {ws.number_to_dp(self.bmi(), BMI_DP)} " f"kg⋅m<sup>–2</sup>" f" [{self.category(req)}]." f" Mass: {ws.number_to_dp(self.mass_kg, KG_DP)} kg. " f" Height: {ws.number_to_dp(self.height_m, M_DP)} m." f" Waist circumference:" f" {ws.number_to_dp(self.waist_cm, CM_DP)} cm." ) ) ]
[docs] def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]: return self.standard_task_summary_fields() + [ SummaryElement( name="bmi", coltype=Float(), value=self.bmi(), comment="BMI (kg/m^2)", ) ]
def bmi(self) -> Optional[float]: if not self.is_complete(): return None try: return self.mass_kg / (self.height_m * self.height_m) except ZeroDivisionError: # The client can set height to 0 return None def category(self, req: CamcopsRequest) -> str: bmi = self.bmi() if bmi is None: return "?" elif bmi >= 40: return self.wxstring(req, "obese_3") elif bmi >= 35: return self.wxstring(req, "obese_2") elif bmi >= 30: return self.wxstring(req, "obese_1") elif bmi >= 25: return self.wxstring(req, "overweight") elif bmi >= 18.5: return self.wxstring(req, "normal") elif bmi >= 17.5: return self.wxstring(req, "underweight_17.5_18.5") elif bmi >= 17: return self.wxstring(req, "underweight_17_17.5") elif bmi >= 16: return self.wxstring(req, "underweight_16_17") elif bmi >= 15: return self.wxstring(req, "underweight_15_16") elif bmi >= 13: return self.wxstring(req, "underweight_13_15") else: return self.wxstring(req, "underweight_under_13")
[docs] def get_task_html(self, req: CamcopsRequest) -> str: return f""" <div class="{CssClass.SUMMARY}"> <table class="{CssClass.SUMMARY}"> {self.get_is_complete_tr(req)} {tr_qa("BMI (kg/m<sup>2</sup>)", ws.number_to_dp(self.bmi(), BMI_DP))} {tr_qa("Category <sup>[1]</sup>", self.category(req))} </table> </div> <table class="{CssClass.TASKDETAIL}"> {tr_qa("Mass (kg)", ws.number_to_dp(self.mass_kg, KG_DP))} {tr_qa("Height (m)", ws.number_to_dp(self.height_m, M_DP))} {tr_qa("Waist circumference (cm)", ws.number_to_dp(self.waist_cm, CM_DP))} {tr_qa("Comment", ws.webify(self.comment))} </table> <div class="{CssClass.FOOTNOTES}"> [1] Categorization <b>for adults</b> (square brackets inclusive, parentheses exclusive; AN anorexia nervosa): &lt;13 very severely underweight (WHO grade 3; RCPsych severe AN, high risk); [13, 15] very severely underweight (WHO grade 3; RCPsych severe AN, medium risk); [15, 16) severely underweight (WHO grade 3; AN); [16, 17) underweight (WHO grade 2; AN); [17, 17.5) underweight (WHO grade 1; below ICD-10/RCPsych AN cutoff); [17.5, 18.5) underweight (WHO grade 1); [18.5, 25) normal (healthy weight); [25, 30) overweight; [30, 35) obese class I (moderately obese); [35, 40) obese class II (severely obese); ≥40 obese class III (very severely obese). Sources: <ul> <li>WHO Expert Committee on Physical Status (1995, PMID 8594834) defined ranges as: &lt;16 grade 3 thinness, [16, 17) grade 2 thinness, [17, 18.5) grade 1 thinness, [18.5, 25) normal, [25, 30) grade 1 overweight, [30, 40) grade 2 overweight, ≥40 grade 3 overweight (sections 7.2.1 and 8.7.1 and p452).</li> <li>WHO (1998 “Obesity: preventing and managing the global epidemic”) use the categories [25, 30) “pre-obese”, [30, 35) obese class I, [35, 40) obese class II, ≥40 obese class III (p9).</li> <li>A large number of web sources that don’t cite a primary reference use: &lt;15 very severely underweight; [15, 16) severely underweight; [16, 18.5) underweight; [18.5, 25] normal (healthy weight); [25, 30) obese class I (moderately obese); [35, 40) obese class II (severely obese); ≥40 obese class III (very severely obese); <li>The WHO (2010 “Nutrition Landscape Information System (NILS) country profile indicators: interpretation guide”) use &lt;16 “severe thinness” (previously grade 3 thinness), (16, 17] “moderate thinness” (previously grade 2 thinness), [17, 18.5) “underweight” (previously grade 1 thinness). (p3).</li> <li>ICD-10 BMI threshold for anorexia nervosa is ≤17.5 (WHO, 1992). Subsequent references (e.g. RCPsych, below) use &lt;17.5.</li> <li>In anorexia nervosa: &lt;17.5 anorexia (threshold for diagnosis), &lt;15 severe anorexia; 13–15 medium risk, &lt;13 high risk (of death) (Royal College of Psychiatrists, 2010, report CR162, pp. 11, 15, 20, 56).</li> </ul> </div> """
[docs] def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]: expressions = [] # type: List[SnomedExpression] procedure_bmi = req.snomed(SnomedLookup.BMI_PROCEDURE_MEASUREMENT) unit = req.snomed(SnomedLookup.UNIT_OF_MEASURE) if self.is_complete(): kg = req.snomed(SnomedLookup.KILOGRAM) m = req.snomed(SnomedLookup.METRE) kg_per_sq_m = req.snomed(SnomedLookup.KG_PER_SQ_M) qty_bmi = req.snomed(SnomedLookup.BMI_OBSERVABLE) qty_height = req.snomed(SnomedLookup.BODY_HEIGHT_OBSERVABLE) qty_weight = req.snomed(SnomedLookup.BODY_WEIGHT_OBSERVABLE) expressions.append( SnomedExpression( procedure_bmi, [ SnomedAttributeGroup( {qty_bmi: self.bmi(), unit: kg_per_sq_m} ), SnomedAttributeGroup( {qty_weight: self.mass_kg, unit: kg} ), SnomedAttributeGroup( {qty_height: self.height_m, unit: m} ), ], ) ) else: expressions.append(SnomedExpression(procedure_bmi)) if self.waist_cm is not None: procedure_waist = req.snomed( SnomedLookup.WAIST_CIRCUMFERENCE_PROCEDURE_MEASUREMENT ) cm = req.snomed(SnomedLookup.CENTIMETRE) qty_waist_circum = req.snomed( SnomedLookup.WAIST_CIRCUMFERENCE_OBSERVABLE ) expressions.append( SnomedExpression( procedure_waist, [ SnomedAttributeGroup( {qty_waist_circum: self.waist_cm, unit: cm} ) ], ) ) return expressions
[docs] def get_fhir_extra_bundle_entries( self, req: CamcopsRequest, recipient: "ExportRecipient" ) -> List[Dict]: """ See https://www.hl7.org/fhir/bmi.html """ bundle_entries = [] # type: List[Dict] # Height if self.height_m: bundle_entries.append( make_fhir_bundle_entry( resource_type_url=Fc.RESOURCE_TYPE_OBSERVATION, identifier=self._get_fhir_observation_id( req, name="height_m" ), resource=self._get_fhir_observation( req, recipient, obs_dict={ Fc.CODE: CodeableConcept( jsondict={ Fc.CODING: [ Coding( jsondict={ Fc.SYSTEM: Fc.CODE_SYSTEM_LOINC, # noqa: E501 Fc.CODE: Fc.LOINC_HEIGHT_CODE, Fc.DISPLAY: Fc.LOINC_HEIGHT_TEXT, # noqa: E501 } ).as_json() ] } ).as_json(), Fc.VALUE_QUANTITY: Quantity( jsondict={ Fc.SYSTEM: Fc.CODE_SYSTEM_UCUM, Fc.CODE: Fc.UCUM_CODE_METRE, Fc.VALUE: self.height_m, } ).as_json(), }, ), ) ) # Mass if self.mass_kg: bundle_entries.append( make_fhir_bundle_entry( resource_type_url=Fc.RESOURCE_TYPE_OBSERVATION, identifier=self._get_fhir_observation_id( req, name="mass_kg" ), resource=self._get_fhir_observation( req, recipient, obs_dict={ Fc.CODE: CodeableConcept( jsondict={ Fc.CODING: [ Coding( jsondict={ Fc.SYSTEM: Fc.CODE_SYSTEM_LOINC, # noqa: E501 Fc.CODE: Fc.LOINC_BODY_WEIGHT_CODE, # noqa: E501 Fc.DISPLAY: Fc.LOINC_BODY_WEIGHT_TEXT, # noqa: E501 } ).as_json() ] } ).as_json(), Fc.VALUE_QUANTITY: Quantity( jsondict={ Fc.SYSTEM: Fc.CODE_SYSTEM_UCUM, Fc.CODE: Fc.UCUM_CODE_KG, Fc.VALUE: self.mass_kg, } ).as_json(), }, ), ) ) # BMI if self.is_complete(): bundle_entries.append( make_fhir_bundle_entry( resource_type_url=Fc.RESOURCE_TYPE_OBSERVATION, identifier=self._get_fhir_observation_id(req, name="bmi"), resource=self._get_fhir_observation( req, recipient, obs_dict={ Fc.CODE: CodeableConcept( jsondict={ Fc.CODING: [ Coding( jsondict={ Fc.SYSTEM: Fc.CODE_SYSTEM_LOINC, # noqa Fc.CODE: Fc.LOINC_BMI_CODE, Fc.DISPLAY: Fc.LOINC_BMI_TEXT, } ).as_json() ] } ).as_json(), Fc.VALUE_QUANTITY: Quantity( jsondict={ Fc.SYSTEM: Fc.CODE_SYSTEM_UCUM, Fc.CODE: Fc.UCUM_CODE_KG_PER_SQ_M, Fc.VALUE: self.bmi(), } ).as_json(), }, ), ) ) # Waist circumference if self.waist_cm: bundle_entries.append( make_fhir_bundle_entry( resource_type_url=Fc.RESOURCE_TYPE_OBSERVATION, identifier=self._get_fhir_observation_id( req, name="waist_cm" ), resource=self._get_fhir_observation( req, recipient, obs_dict={ Fc.CODE: CodeableConcept( jsondict={ Fc.CODING: [ Coding( jsondict={ Fc.SYSTEM: Fc.CODE_SYSTEM_LOINC, # noqa Fc.CODE: Fc.LOINC_WAIST_CIRCUMFERENCE_CODE, # noqa Fc.DISPLAY: Fc.LOINC_WAIST_CIRCUMFERENCE_TEXT, # noqa } ).as_json() ] } ).as_json(), Fc.VALUE_QUANTITY: Quantity( jsondict={ Fc.SYSTEM: Fc.CODE_SYSTEM_UCUM, Fc.CODE: Fc.UCUM_CODE_CENTIMETRE, Fc.VALUE: self.waist_cm, } ).as_json(), }, ), ) ) return bundle_entries