Coverage for tasks/bmi.py: 39%
109 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 15:51 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 15:51 +0100
1"""
2camcops_server/tasks/bmi.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"""
28from typing import Dict, List, Optional, TYPE_CHECKING
30import cardinal_pythonlib.rnc_web as ws
31from fhirclient.models.codeableconcept import CodeableConcept
32from fhirclient.models.coding import Coding
33from fhirclient.models.quantity import Quantity
34from sqlalchemy.orm import Mapped, mapped_column
35from sqlalchemy.sql.sqltypes import Float, UnicodeText
37from camcops_server.cc_modules.cc_constants import CssClass, FHIRConst as Fc
38from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
39from camcops_server.cc_modules.cc_fhir import make_fhir_bundle_entry
40from camcops_server.cc_modules.cc_html import tr_qa
41from camcops_server.cc_modules.cc_request import CamcopsRequest
42from camcops_server.cc_modules.cc_snomed import (
43 SnomedAttributeGroup,
44 SnomedExpression,
45 SnomedLookup,
46)
47from camcops_server.cc_modules.cc_summaryelement import SummaryElement
48from camcops_server.cc_modules.cc_sqla_coltypes import (
49 mapped_camcops_column,
50 PermittedValueChecker,
51)
52from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin
53from camcops_server.cc_modules.cc_trackerhelpers import (
54 LabelAlignment,
55 TrackerInfo,
56 TrackerLabel,
57)
59if TYPE_CHECKING:
60 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient
63# =============================================================================
64# BMI
65# =============================================================================
67BMI_DP = 2
68KG_DP = 2
69M_DP = 3
70CM_DP = 1
73class Bmi(TaskHasPatientMixin, Task): # type: ignore[misc]
74 """
75 Server implementation of the BMI task.
76 """
78 __tablename__ = "bmi"
79 shortname = "BMI"
80 provides_trackers = True
82 height_m: Mapped[Optional[float]] = mapped_camcops_column(
83 permitted_value_checker=PermittedValueChecker(minimum=0),
84 comment="height (m)",
85 )
86 mass_kg: Mapped[Optional[float]] = mapped_camcops_column(
87 permitted_value_checker=PermittedValueChecker(minimum=0),
88 comment="mass (kg)",
89 )
90 waist_cm: Mapped[Optional[float]] = mapped_camcops_column(
91 permitted_value_checker=PermittedValueChecker(minimum=0),
92 comment="waist circumference (cm)",
93 )
94 comment: Mapped[Optional[str]] = mapped_column(
95 UnicodeText, comment="Clinician's comment"
96 )
98 @staticmethod
99 def longname(req: "CamcopsRequest") -> str:
100 _ = req.gettext
101 return _("Body mass index")
103 def is_complete(self) -> bool:
104 return (
105 self.height_m is not None
106 and self.mass_kg is not None
107 and self.field_contents_valid()
108 )
110 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
111 # $ signs enable TEX mode for matplotlib, e.g. "$BMI (kg/m^2)$"
112 return [
113 TrackerInfo(
114 value=self.bmi(),
115 plot_label="Body mass index",
116 axis_label="BMI (kg/m^2)",
117 axis_min=10,
118 axis_max=42,
119 horizontal_lines=[13, 15, 16, 17, 17.5, 18.5, 25, 30, 35, 40],
120 horizontal_labels=[
121 # positioned near the mid-range for some:
122 TrackerLabel(
123 12.5,
124 self.wxstring(req, "underweight_under_13"),
125 LabelAlignment.top,
126 ),
127 TrackerLabel(14, self.wxstring(req, "underweight_13_15")),
128 TrackerLabel(
129 15.5, self.wxstring(req, "underweight_15_16")
130 ),
131 TrackerLabel(
132 16.5, self.wxstring(req, "underweight_16_17")
133 ),
134 TrackerLabel(
135 17.25, self.wxstring(req, "underweight_17_17.5")
136 ),
137 TrackerLabel(
138 18, self.wxstring(req, "underweight_17.5_18.5")
139 ),
140 TrackerLabel(21.75, self.wxstring(req, "normal")),
141 TrackerLabel(27.5, self.wxstring(req, "overweight")),
142 TrackerLabel(32.5, self.wxstring(req, "obese_1")),
143 TrackerLabel(37.6, self.wxstring(req, "obese_2")),
144 TrackerLabel(
145 40.5,
146 self.wxstring(req, "obese_3"),
147 LabelAlignment.bottom,
148 ),
149 ],
150 aspect_ratio=1.0,
151 ),
152 TrackerInfo(
153 value=self.mass_kg,
154 plot_label="Mass (kg)",
155 axis_label="Mass (kg)",
156 ),
157 TrackerInfo(
158 value=self.waist_cm,
159 plot_label="Waist circumference (cm)",
160 axis_label="Waist circumference (cm)",
161 ),
162 ]
164 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
165 if not self.is_complete():
166 return CTV_INCOMPLETE
167 return [
168 CtvInfo(
169 content=(
170 f"BMI: {ws.number_to_dp(self.bmi(), BMI_DP)} "
171 f"kg⋅m<sup>–2</sup>"
172 f" [{self.category(req)}]."
173 f" Mass: {ws.number_to_dp(self.mass_kg, KG_DP)} kg. "
174 f" Height: {ws.number_to_dp(self.height_m, M_DP)} m."
175 f" Waist circumference:"
176 f" {ws.number_to_dp(self.waist_cm, CM_DP)} cm."
177 )
178 )
179 ]
181 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
182 return self.standard_task_summary_fields() + [
183 SummaryElement(
184 name="bmi",
185 coltype=Float(),
186 value=self.bmi(),
187 comment="BMI (kg/m^2)",
188 )
189 ]
191 def bmi(self) -> Optional[float]:
192 if not self.is_complete():
193 return None
194 try:
195 return self.mass_kg / (self.height_m * self.height_m)
196 except ZeroDivisionError:
197 # The client can set height to 0
198 return None
200 def category(self, req: CamcopsRequest) -> str:
201 bmi = self.bmi()
202 if bmi is None:
203 return "?"
204 elif bmi >= 40:
205 return self.wxstring(req, "obese_3")
206 elif bmi >= 35:
207 return self.wxstring(req, "obese_2")
208 elif bmi >= 30:
209 return self.wxstring(req, "obese_1")
210 elif bmi >= 25:
211 return self.wxstring(req, "overweight")
212 elif bmi >= 18.5:
213 return self.wxstring(req, "normal")
214 elif bmi >= 17.5:
215 return self.wxstring(req, "underweight_17.5_18.5")
216 elif bmi >= 17:
217 return self.wxstring(req, "underweight_17_17.5")
218 elif bmi >= 16:
219 return self.wxstring(req, "underweight_16_17")
220 elif bmi >= 15:
221 return self.wxstring(req, "underweight_15_16")
222 elif bmi >= 13:
223 return self.wxstring(req, "underweight_13_15")
224 else:
225 return self.wxstring(req, "underweight_under_13")
227 def get_task_html(self, req: CamcopsRequest) -> str:
228 return f"""
229 <div class="{CssClass.SUMMARY}">
230 <table class="{CssClass.SUMMARY}">
231 {self.get_is_complete_tr(req)}
232 {tr_qa("BMI (kg/m<sup>2</sup>)",
233 ws.number_to_dp(self.bmi(), BMI_DP))}
234 {tr_qa("Category <sup>[1]</sup>", self.category(req))}
235 </table>
236 </div>
237 <table class="{CssClass.TASKDETAIL}">
238 {tr_qa("Mass (kg)", ws.number_to_dp(self.mass_kg, KG_DP))}
239 {tr_qa("Height (m)", ws.number_to_dp(self.height_m, M_DP))}
240 {tr_qa("Waist circumference (cm)",
241 ws.number_to_dp(self.waist_cm, CM_DP))}
242 {tr_qa("Comment", ws.webify(self.comment))}
243 </table>
244 <div class="{CssClass.FOOTNOTES}">
245 [1] Categorization <b>for adults</b> (square brackets
246 inclusive, parentheses exclusive; AN anorexia nervosa):
248 <13 very severely underweight (WHO grade 3; RCPsych severe
249 AN, high risk);
250 [13, 15] very severely underweight (WHO grade 3; RCPsych severe
251 AN, medium risk);
252 [15, 16) severely underweight (WHO grade 3; AN);
253 [16, 17) underweight (WHO grade 2; AN);
254 [17, 17.5) underweight (WHO grade 1; below ICD-10/RCPsych AN
255 cutoff);
256 [17.5, 18.5) underweight (WHO grade 1);
257 [18.5, 25) normal (healthy weight);
258 [25, 30) overweight;
259 [30, 35) obese class I (moderately obese);
260 [35, 40) obese class II (severely obese);
261 ≥40 obese class III (very severely obese).
263 Sources:
264 <ul>
265 <li>WHO Expert Committee on Physical Status (1995,
266 PMID 8594834) defined ranges as:
268 <16 grade 3 thinness,
269 [16, 17) grade 2 thinness,
270 [17, 18.5) grade 1 thinness,
271 [18.5, 25) normal,
272 [25, 30) grade 1 overweight,
273 [30, 40) grade 2 overweight,
274 ≥40 grade 3 overweight
276 (sections 7.2.1 and 8.7.1 and p452).</li>
278 <li>WHO (1998 “Obesity: preventing and managing the global
279 epidemic”) use the
280 categories
282 [25, 30) “pre-obese”,
283 [30, 35) obese class I,
284 [35, 40) obese class II,
285 ≥40 obese class III
287 (p9).</li>
289 <li>A large number of web sources that don’t cite a primary
290 reference use:
291 <15 very severely underweight;
292 [15, 16) severely underweight;
293 [16, 18.5) underweight;
294 [18.5, 25] normal (healthy weight);
295 [25, 30) obese class I (moderately obese);
296 [35, 40) obese class II (severely obese);
297 ≥40 obese class III (very severely obese);
299 <li>The WHO (2010 “Nutrition Landscape Information System
300 (NILS) country profile indicators: interpretation guide”)
301 use
302 <16 “severe thinness” (previously grade 3 thinness),
303 (16, 17] “moderate thinness” (previously grade 2 thinness),
304 [17, 18.5) “underweight” (previously grade 1 thinness).
305 (p3).</li>
307 <li>ICD-10 BMI threshold for anorexia nervosa is ≤17.5
308 (WHO, 1992). Subsequent references (e.g. RCPsych, below)
309 use <17.5.</li>
311 <li>In anorexia nervosa:
313 <17.5 anorexia (threshold for diagnosis),
314 <15 severe anorexia;
315 13–15 medium risk,
316 <13 high risk (of death)
318 (Royal College of Psychiatrists, 2010, report CR162,
319 pp. 11, 15, 20, 56).</li>
320 </ul>
321 </div>
322 """
324 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
325 expressions = [] # type: List[SnomedExpression]
326 procedure_bmi = req.snomed(SnomedLookup.BMI_PROCEDURE_MEASUREMENT)
327 unit = req.snomed(SnomedLookup.UNIT_OF_MEASURE)
328 if self.is_complete() and self.bmi() is not None:
329 kg = req.snomed(SnomedLookup.KILOGRAM)
330 m = req.snomed(SnomedLookup.METRE)
331 kg_per_sq_m = req.snomed(SnomedLookup.KG_PER_SQ_M)
332 qty_bmi = req.snomed(SnomedLookup.BMI_OBSERVABLE)
333 qty_height = req.snomed(SnomedLookup.BODY_HEIGHT_OBSERVABLE)
334 qty_weight = req.snomed(SnomedLookup.BODY_WEIGHT_OBSERVABLE)
335 expressions.append(
336 SnomedExpression(
337 procedure_bmi,
338 [
339 SnomedAttributeGroup(
340 {qty_bmi: self.bmi(), unit: kg_per_sq_m}
341 ),
342 SnomedAttributeGroup(
343 {qty_weight: self.mass_kg, unit: kg}
344 ),
345 SnomedAttributeGroup(
346 {qty_height: self.height_m, unit: m}
347 ),
348 ],
349 )
350 )
351 else:
352 expressions.append(SnomedExpression(procedure_bmi))
353 if self.waist_cm is not None:
354 procedure_waist = req.snomed(
355 SnomedLookup.WAIST_CIRCUMFERENCE_PROCEDURE_MEASUREMENT
356 )
357 cm = req.snomed(SnomedLookup.CENTIMETRE)
358 qty_waist_circum = req.snomed(
359 SnomedLookup.WAIST_CIRCUMFERENCE_OBSERVABLE
360 )
361 expressions.append(
362 SnomedExpression(
363 procedure_waist,
364 [
365 SnomedAttributeGroup(
366 {qty_waist_circum: self.waist_cm, unit: cm}
367 )
368 ],
369 )
370 )
371 return expressions
373 def get_fhir_extra_bundle_entries(
374 self, req: CamcopsRequest, recipient: "ExportRecipient"
375 ) -> List[Dict]:
376 """
377 See https://www.hl7.org/fhir/bmi.html
378 """
379 bundle_entries = [] # type: List[Dict]
381 # Height
382 if self.height_m:
383 bundle_entries.append(
384 make_fhir_bundle_entry(
385 resource_type_url=Fc.RESOURCE_TYPE_OBSERVATION,
386 identifier=self._get_fhir_observation_id(
387 req, name="height_m"
388 ),
389 resource=self._get_fhir_observation(
390 req,
391 recipient,
392 obs_dict={
393 Fc.CODE: CodeableConcept(
394 jsondict={
395 Fc.CODING: [
396 Coding(
397 jsondict={
398 Fc.SYSTEM: Fc.CODE_SYSTEM_LOINC, # noqa: E501
399 Fc.CODE: Fc.LOINC_HEIGHT_CODE,
400 Fc.DISPLAY: Fc.LOINC_HEIGHT_TEXT, # noqa: E501
401 }
402 ).as_json()
403 ]
404 }
405 ).as_json(),
406 Fc.VALUE_QUANTITY: Quantity(
407 jsondict={
408 Fc.SYSTEM: Fc.CODE_SYSTEM_UCUM,
409 Fc.CODE: Fc.UCUM_CODE_METRE,
410 Fc.VALUE: self.height_m,
411 }
412 ).as_json(),
413 },
414 ),
415 )
416 )
418 # Mass
419 if self.mass_kg:
420 bundle_entries.append(
421 make_fhir_bundle_entry(
422 resource_type_url=Fc.RESOURCE_TYPE_OBSERVATION,
423 identifier=self._get_fhir_observation_id(
424 req, name="mass_kg"
425 ),
426 resource=self._get_fhir_observation(
427 req,
428 recipient,
429 obs_dict={
430 Fc.CODE: CodeableConcept(
431 jsondict={
432 Fc.CODING: [
433 Coding(
434 jsondict={
435 Fc.SYSTEM: Fc.CODE_SYSTEM_LOINC, # noqa: E501
436 Fc.CODE: Fc.LOINC_BODY_WEIGHT_CODE, # noqa: E501
437 Fc.DISPLAY: Fc.LOINC_BODY_WEIGHT_TEXT, # noqa: E501
438 }
439 ).as_json()
440 ]
441 }
442 ).as_json(),
443 Fc.VALUE_QUANTITY: Quantity(
444 jsondict={
445 Fc.SYSTEM: Fc.CODE_SYSTEM_UCUM,
446 Fc.CODE: Fc.UCUM_CODE_KG,
447 Fc.VALUE: self.mass_kg,
448 }
449 ).as_json(),
450 },
451 ),
452 )
453 )
455 # BMI
456 if self.is_complete():
457 bundle_entries.append(
458 make_fhir_bundle_entry(
459 resource_type_url=Fc.RESOURCE_TYPE_OBSERVATION,
460 identifier=self._get_fhir_observation_id(req, name="bmi"),
461 resource=self._get_fhir_observation(
462 req,
463 recipient,
464 obs_dict={
465 Fc.CODE: CodeableConcept(
466 jsondict={
467 Fc.CODING: [
468 Coding(
469 jsondict={
470 Fc.SYSTEM: Fc.CODE_SYSTEM_LOINC, # noqa
471 Fc.CODE: Fc.LOINC_BMI_CODE,
472 Fc.DISPLAY: Fc.LOINC_BMI_TEXT,
473 }
474 ).as_json()
475 ]
476 }
477 ).as_json(),
478 Fc.VALUE_QUANTITY: Quantity(
479 jsondict={
480 Fc.SYSTEM: Fc.CODE_SYSTEM_UCUM,
481 Fc.CODE: Fc.UCUM_CODE_KG_PER_SQ_M,
482 Fc.VALUE: self.bmi(),
483 }
484 ).as_json(),
485 },
486 ),
487 )
488 )
490 # Waist circumference
491 if self.waist_cm:
492 bundle_entries.append(
493 make_fhir_bundle_entry(
494 resource_type_url=Fc.RESOURCE_TYPE_OBSERVATION,
495 identifier=self._get_fhir_observation_id(
496 req, name="waist_cm"
497 ),
498 resource=self._get_fhir_observation(
499 req,
500 recipient,
501 obs_dict={
502 Fc.CODE: CodeableConcept(
503 jsondict={
504 Fc.CODING: [
505 Coding(
506 jsondict={
507 Fc.SYSTEM: Fc.CODE_SYSTEM_LOINC, # noqa
508 Fc.CODE: Fc.LOINC_WAIST_CIRCUMFERENCE_CODE, # noqa
509 Fc.DISPLAY: Fc.LOINC_WAIST_CIRCUMFERENCE_TEXT, # noqa
510 }
511 ).as_json()
512 ]
513 }
514 ).as_json(),
515 Fc.VALUE_QUANTITY: Quantity(
516 jsondict={
517 Fc.SYSTEM: Fc.CODE_SYSTEM_UCUM,
518 Fc.CODE: Fc.UCUM_CODE_CENTIMETRE,
519 Fc.VALUE: self.waist_cm,
520 }
521 ).as_json(),
522 },
523 ),
524 )
525 )
527 return bundle_entries