Coverage for tasks/bmi.py: 39%

1"""

2 camcops_server/tasks/bmi.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 from typing import Dict, List, Optional, TYPE_CHECKING

29

30 import cardinal_pythonlib.rnc_web as ws

31 from fhirclient.models.codeableconcept import CodeableConcept

32 from fhirclient.models.coding import Coding

33 from fhirclient.models.quantity import Quantity

34 from sqlalchemy.orm import Mapped, mapped_column

35 from sqlalchemy.sql.sqltypes import Float, UnicodeText

36

37 from camcops_server.cc_modules.cc_constants import CssClass, FHIRConst as Fc

38 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

39 from camcops_server.cc_modules.cc_fhir import make_fhir_bundle_entry

40 from camcops_server.cc_modules.cc_html import tr_qa

41 from camcops_server.cc_modules.cc_request import CamcopsRequest

42 from camcops_server.cc_modules.cc_snomed import (

43 SnomedAttributeGroup,

44 SnomedExpression,

45 SnomedLookup,

46)

47 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

48 from camcops_server.cc_modules.cc_sqla_coltypes import (

49 mapped_camcops_column,

50 PermittedValueChecker,

51)

52 from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin

53 from camcops_server.cc_modules.cc_trackerhelpers import (

54 LabelAlignment,

55 TrackerInfo,

56 TrackerLabel,

57)

58

59 if TYPE_CHECKING:

60 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient

61

62

63# =============================================================================

64# BMI

65# =============================================================================

66

67 BMI_DP = 2

68 KG_DP = 2

69 M_DP = 3

70 CM_DP = 1

71

72

73 class Bmi(TaskHasPatientMixin, Task): # type: ignore[misc]

74 """

75 Server implementation of the BMI task.

76 """

77

78 __tablename__ = "bmi"

79 shortname = "BMI"

80 provides_trackers = True

81

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 )

97

98 @staticmethod

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

100 _ = req.gettext

101 return _("Body mass index")

102

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 )

109

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 ]

163

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 ]

180

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 ]

190

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

199

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")

226

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):

247

248 &lt;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).

262

263 Sources:

264 <ul>

265 <li>WHO Expert Committee on Physical Status (1995,

266 PMID 8594834) defined ranges as:

267

268 &lt;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

275

276 (sections 7.2.1 and 8.7.1 and p452).</li>

277

278 <li>WHO (1998 “Obesity: preventing and managing the global

279 epidemic”) use the

280 categories

281

282 [25, 30) “pre-obese”,

283 [30, 35) obese class I,

284 [35, 40) obese class II,

285 ≥40 obese class III

286

287 (p9).</li>

288

289 <li>A large number of web sources that don’t cite a primary

290 reference use:

291 &lt;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);

298

299 <li>The WHO (2010 “Nutrition Landscape Information System

300 (NILS) country profile indicators: interpretation guide”)

301 use

302 &lt;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>

306

307 <li>ICD-10 BMI threshold for anorexia nervosa is ≤17.5

308 (WHO, 1992). Subsequent references (e.g. RCPsych, below)

309 use &lt;17.5.</li>

310

311 <li>In anorexia nervosa:

312

313 &lt;17.5 anorexia (threshold for diagnosis),

314 &lt;15 severe anorexia;

315 13–15 medium risk,

316 &lt;13 high risk (of death)

317

318 (Royal College of Psychiatrists, 2010, report CR162,

319 pp. 11, 15, 20, 56).</li>

320 </ul>

321 </div>

322 """

323

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

372

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]

380

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 )

417

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 )

454

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 )

489

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 )

526

527 return bundle_entries