Coverage for tasks/bmi.py: 39%

109 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 15:51 +0100

1""" 

2camcops_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 

28from typing import Dict, List, Optional, TYPE_CHECKING 

29 

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 

36 

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) 

58 

59if TYPE_CHECKING: 

60 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient 

61 

62 

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

64# BMI 

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

66 

67BMI_DP = 2 

68KG_DP = 2 

69M_DP = 3 

70CM_DP = 1 

71 

72 

73class 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