Coverage for src / bluetooth_sig / gatt / characteristics / body_composition_measurement.py: 83%
287 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +0000
1"""Body Composition Measurement characteristic implementation."""
3from __future__ import annotations
5from datetime import datetime
6from enum import IntFlag
8import msgspec
10from bluetooth_sig.types.units import MeasurementSystem, WeightUnit
12from ..constants import PERCENTAGE_MAX
13from ..context import CharacteristicContext
14from .base import BaseCharacteristic
15from .body_composition_feature import BodyCompositionFeatureCharacteristic, BodyCompositionFeatureData
16from .utils import DataParser, IEEE11073Parser
18BODY_FAT_PERCENTAGE_RESOLUTION = 0.1 # 0.1% resolution
19MUSCLE_PERCENTAGE_RESOLUTION = 0.1 # 0.1% resolution
20IMPEDANCE_RESOLUTION = 0.1 # 0.1 ohm resolution
21MASS_RESOLUTION_KG = 0.005 # 0.005 kg resolution
22MASS_RESOLUTION_LB = 0.01 # 0.01 lb resolution
23HEIGHT_RESOLUTION_METRIC = 0.001 # 0.001 m resolution
24HEIGHT_RESOLUTION_IMPERIAL = 0.1 # 0.1 inch resolution
27class FlagsAndBodyFat(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
28 """Flags and body fat percentage with parsing offset."""
30 flags: BodyCompositionFlags
31 body_fat_percentage: float
32 offset: int
35class BasicOptionalFields(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
36 """Basic optional fields: timestamp, user ID, and basal metabolism."""
38 timestamp: datetime | None
39 user_id: int | None
40 basal_metabolism: int | None
41 offset: int
44class MassFields(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
45 """Mass-related optional fields."""
47 muscle_mass: float | None
48 muscle_mass_unit: WeightUnit | None
49 muscle_percentage: float | None
50 fat_free_mass: float | None
51 soft_lean_mass: float | None
52 body_water_mass: float | None
53 offset: int
56class OtherMeasurements(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
57 """Impedance, weight, and height measurements."""
59 impedance: float | None
60 weight: float | None
61 height: float | None
64class MassValue(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
65 """Single mass field with unit."""
67 value: float
68 unit: WeightUnit
71class BodyCompositionFlags(IntFlag):
72 """Body Composition Measurement flags as per Bluetooth SIG specification."""
74 IMPERIAL_UNITS = 0x001
75 TIMESTAMP_PRESENT = 0x002
76 USER_ID_PRESENT = 0x004
77 BASAL_METABOLISM_PRESENT = 0x008
78 MUSCLE_MASS_PRESENT = 0x010
79 MUSCLE_PERCENTAGE_PRESENT = 0x020
80 FAT_FREE_MASS_PRESENT = 0x040
81 SOFT_LEAN_MASS_PRESENT = 0x080
82 BODY_WATER_MASS_PRESENT = 0x100
83 IMPEDANCE_PRESENT = 0x200
84 WEIGHT_PRESENT = 0x400
85 HEIGHT_PRESENT = 0x800
88class BodyCompositionMeasurementData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes
89 """Parsed data from Body Composition Measurement characteristic."""
91 body_fat_percentage: float
92 flags: BodyCompositionFlags
93 measurement_units: MeasurementSystem
94 timestamp: datetime | None = None
95 user_id: int | None = None
96 basal_metabolism: int | None = None
97 muscle_mass: float | None = None
98 muscle_mass_unit: WeightUnit | None = None
99 muscle_percentage: float | None = None
100 fat_free_mass: float | None = None
101 soft_lean_mass: float | None = None
102 body_water_mass: float | None = None
103 impedance: float | None = None
104 weight: float | None = None
105 height: float | None = None
107 def __post_init__(self) -> None: # pylint: disable=too-many-branches
108 """Validate body composition measurement data."""
109 if not 0.0 <= self.body_fat_percentage <= PERCENTAGE_MAX:
110 raise ValueError("Body fat percentage must be between 0-100%")
111 if not 0 <= self.flags <= 0xFFFF:
112 raise ValueError("Flags must be a 16-bit value")
114 # Validate measurement_units
115 if not isinstance(self.measurement_units, MeasurementSystem):
116 raise ValueError(f"Invalid measurement_units: {self.measurement_units!r}")
118 # Validate mass fields units and ranges
119 mass_fields = [
120 ("muscle_mass", self.muscle_mass),
121 ("fat_free_mass", self.fat_free_mass),
122 ("soft_lean_mass", self.soft_lean_mass),
123 ("body_water_mass", self.body_water_mass),
124 ("weight", self.weight),
125 ]
126 for field_name, value in mass_fields:
127 if value is not None:
128 if not 0 <= value:
129 raise ValueError(f"{field_name} must be non-negative")
131 # Validate muscle_mass_unit consistency
132 if self.muscle_mass is not None:
133 expected_unit = WeightUnit.KG if self.measurement_units == MeasurementSystem.METRIC else WeightUnit.LB
134 if self.muscle_mass_unit != expected_unit:
135 raise ValueError(f"muscle_mass_unit must be {expected_unit!r}, got {self.muscle_mass_unit!r}")
137 # Validate muscle_percentage
138 if self.muscle_percentage is not None:
139 if not 0 <= self.muscle_percentage:
140 raise ValueError("Muscle percentage must be non-negative")
142 # Validate impedance
143 if self.impedance is not None:
144 if not 0 <= self.impedance:
145 raise ValueError("Impedance must be non-negative")
147 # Validate height
148 if self.height is not None:
149 if not 0 <= self.height:
150 raise ValueError("Height must be non-negative")
152 # Validate basal_metabolism
153 if self.basal_metabolism is not None:
154 if not 0 <= self.basal_metabolism:
155 raise ValueError("Basal metabolism must be non-negative")
157 # Validate user_id
158 if self.user_id is not None:
159 if not 0 <= self.user_id <= 255:
160 raise ValueError(f"User ID must be 0-255, got {self.user_id}")
163class BodyCompositionMeasurementCharacteristic(BaseCharacteristic[BodyCompositionMeasurementData]):
164 """Body Composition Measurement characteristic (0x2A9C).
166 Used to transmit body composition measurement data including body
167 fat percentage, muscle mass, bone mass, water percentage, and other
168 body metrics.
169 """
171 _manual_unit: str = "various" # Multiple units in measurement
173 _optional_dependencies = [BodyCompositionFeatureCharacteristic]
175 min_length: int = 4 # Flags(2) + BodyFat(2) minimum
176 max_length: int = 50 # + Timestamp(7) + UserID(1) + Multiple measurements maximum
177 allow_variable_length: bool = True # Variable optional fields
179 def _decode_value(
180 self, data: bytearray, ctx: CharacteristicContext | None = None
181 ) -> BodyCompositionMeasurementData:
182 """Parse body composition measurement data according to Bluetooth specification.
184 Format: Flags(2) + Body Fat %(2) + [Timestamp(7)] + [User ID(1)] +
185 [Basal Metabolism(2)] + [Muscle Mass(2)] + [etc...]
187 Args:
188 data: Raw bytearray from BLE characteristic.
189 ctx: Optional CharacteristicContext providing surrounding context (may be None).
191 Returns:
192 BodyCompositionMeasurementData containing parsed body composition data.
194 Raises:
195 ValueError: If data format is invalid.
197 """
198 if len(data) < 4:
199 raise ValueError("Body Composition Measurement data must be at least 4 bytes")
201 # Parse flags and required body fat percentage
202 header = self._parse_flags_and_body_fat(data)
203 flags_enum = BodyCompositionFlags(header.flags)
204 measurement_units = (
205 MeasurementSystem.IMPERIAL
206 if BodyCompositionFlags.IMPERIAL_UNITS in flags_enum
207 else MeasurementSystem.METRIC
208 )
210 # Parse optional fields based on flags
211 basic = self._parse_basic_optional_fields(data, flags_enum, header.offset)
212 mass = self._parse_mass_fields(data, flags_enum, basic.offset)
213 other = self._parse_other_measurements(data, flags_enum, mass.offset)
215 # Validate against Body Composition Feature if context is available
216 if ctx is not None:
217 feature_value = self.get_context_characteristic(ctx, BodyCompositionFeatureCharacteristic)
218 if feature_value is not None:
219 self._validate_against_feature_characteristic(basic, mass, other, feature_value)
221 # Create struct with all parsed values
222 return BodyCompositionMeasurementData(
223 body_fat_percentage=header.body_fat_percentage,
224 flags=flags_enum,
225 measurement_units=measurement_units,
226 timestamp=basic.timestamp,
227 user_id=basic.user_id,
228 basal_metabolism=basic.basal_metabolism,
229 muscle_mass=mass.muscle_mass,
230 muscle_mass_unit=mass.muscle_mass_unit,
231 muscle_percentage=mass.muscle_percentage,
232 fat_free_mass=mass.fat_free_mass,
233 soft_lean_mass=mass.soft_lean_mass,
234 body_water_mass=mass.body_water_mass,
235 impedance=other.impedance,
236 weight=other.weight,
237 height=other.height,
238 )
240 def _encode_value(self, data: BodyCompositionMeasurementData) -> bytearray:
241 """Encode body composition measurement value back to bytes.
243 Args:
244 data: BodyCompositionMeasurementData containing body composition measurement data
246 Returns:
247 Encoded bytes representing the measurement
249 """
250 result = bytearray()
252 # Encode flags and body fat percentage
253 self._encode_flags_and_body_fat(result, data)
255 # Encode optional fields based on flags
256 self._encode_optional_fields(result, data)
258 return result
260 def _encode_flags_and_body_fat(self, result: bytearray, data: BodyCompositionMeasurementData) -> None:
261 """Encode flags and body fat percentage."""
262 # Encode flags (16-bit)
263 flags = int(data.flags)
264 result.extend(DataParser.encode_int16(flags, signed=False))
266 # Encode body fat percentage (uint16 with 0.1% resolution)
267 body_fat_raw = round(data.body_fat_percentage / BODY_FAT_PERCENTAGE_RESOLUTION)
268 if not 0 <= body_fat_raw <= 0xFFFF:
269 raise ValueError(f"Body fat percentage {body_fat_raw} exceeds uint16 range")
270 result.extend(DataParser.encode_int16(body_fat_raw, signed=False))
272 def _encode_optional_fields(self, result: bytearray, data: BodyCompositionMeasurementData) -> None:
273 """Encode optional fields based on measurement data."""
274 # Encode optional timestamp if present
275 if data.timestamp is not None:
276 result.extend(IEEE11073Parser.encode_timestamp(data.timestamp))
278 # Encode optional user ID if present
279 if data.user_id is not None:
280 if not 0 <= data.user_id <= 0xFF:
281 raise ValueError(f"User ID {data.user_id} exceeds uint8 range")
282 result.append(data.user_id)
284 # Encode optional basal metabolism if present
285 if data.basal_metabolism is not None:
286 if not 0 <= data.basal_metabolism <= 0xFFFF:
287 raise ValueError(f"Basal metabolism {data.basal_metabolism} exceeds uint16 range")
288 result.extend(DataParser.encode_int16(data.basal_metabolism, signed=False))
290 # Encode mass-related fields
291 self._encode_mass_fields(result, data)
293 # Encode other measurements
294 self._encode_other_measurements(result, data)
296 def _encode_mass_fields(self, result: bytearray, data: BodyCompositionMeasurementData) -> None:
297 """Encode mass-related optional fields."""
298 # Encode optional muscle mass if present
299 if data.muscle_mass is not None:
300 mass_raw = round(data.muscle_mass / MASS_RESOLUTION_KG)
301 if not 0 <= mass_raw <= 0xFFFF:
302 raise ValueError(f"Muscle mass raw value {mass_raw} exceeds uint16 range")
303 result.extend(DataParser.encode_int16(mass_raw, signed=False))
305 # Encode optional muscle percentage if present
306 if data.muscle_percentage is not None:
307 muscle_pct_raw = round(data.muscle_percentage / MUSCLE_PERCENTAGE_RESOLUTION)
308 if not 0 <= muscle_pct_raw <= 0xFFFF:
309 raise ValueError(f"Muscle percentage raw value {muscle_pct_raw} exceeds uint16 range")
310 result.extend(DataParser.encode_int16(muscle_pct_raw, signed=False))
312 # Encode optional fat free mass if present
313 if data.fat_free_mass is not None:
314 mass_raw = round(data.fat_free_mass / MASS_RESOLUTION_KG)
315 if not 0 <= mass_raw <= 0xFFFF:
316 raise ValueError(f"Fat free mass raw value {mass_raw} exceeds uint16 range")
317 result.extend(DataParser.encode_int16(mass_raw, signed=False))
319 # Encode optional soft lean mass if present
320 if data.soft_lean_mass is not None:
321 mass_raw = round(data.soft_lean_mass / MASS_RESOLUTION_KG)
322 if not 0 <= mass_raw <= 0xFFFF:
323 raise ValueError(f"Soft lean mass raw value {mass_raw} exceeds uint16 range")
324 result.extend(DataParser.encode_int16(mass_raw, signed=False))
326 # Encode optional body water mass if present
327 if data.body_water_mass is not None:
328 mass_raw = round(data.body_water_mass / MASS_RESOLUTION_KG)
329 if not 0 <= mass_raw <= 0xFFFF:
330 raise ValueError(f"Body water mass raw value {mass_raw} exceeds uint16 range")
331 result.extend(DataParser.encode_int16(mass_raw, signed=False))
333 def _encode_other_measurements(self, result: bytearray, data: BodyCompositionMeasurementData) -> None:
334 """Encode impedance, weight, and height measurements."""
335 # Encode optional impedance if present
336 if data.impedance is not None:
337 impedance_raw = round(data.impedance / IMPEDANCE_RESOLUTION)
338 if not 0 <= impedance_raw <= 0xFFFF:
339 raise ValueError(f"Impedance raw value {impedance_raw} exceeds uint16 range")
340 result.extend(DataParser.encode_int16(impedance_raw, signed=False))
342 # Encode optional weight if present
343 if data.weight is not None:
344 mass_raw = round(data.weight / MASS_RESOLUTION_KG)
345 if not 0 <= mass_raw <= 0xFFFF:
346 raise ValueError(f"Weight raw value {mass_raw} exceeds uint16 range")
347 result.extend(DataParser.encode_int16(mass_raw, signed=False))
349 # Encode optional height if present
350 if data.height is not None:
351 if data.measurement_units == MeasurementSystem.IMPERIAL:
352 height_raw = round(data.height / HEIGHT_RESOLUTION_IMPERIAL) # 0.1 inch resolution
353 else:
354 height_raw = round(data.height / HEIGHT_RESOLUTION_METRIC) # 0.001 m resolution
355 if not 0 <= height_raw <= 0xFFFF:
356 raise ValueError(f"Height raw value {height_raw} exceeds uint16 range")
357 result.extend(DataParser.encode_int16(height_raw, signed=False))
359 def _validate_against_feature_characteristic(
360 self,
361 basic: BasicOptionalFields,
362 mass: MassFields,
363 other: OtherMeasurements,
364 feature_data: BodyCompositionFeatureData,
365 ) -> None:
366 """Validate measurement data against Body Composition Feature characteristic.
368 Args:
369 basic: Basic optional fields (timestamp, user_id, basal_metabolism)
370 mass: Mass-related fields
371 other: Other measurements (impedance, weight, height)
372 feature_data: BodyCompositionFeatureData from feature characteristic
374 Raises:
375 ValueError: If measurement reports unsupported features
377 """
378 # Check that reported measurements are supported by device features
379 if basic.timestamp is not None and not feature_data.timestamp_supported:
380 raise ValueError("Timestamp reported but not supported by device features")
382 if basic.user_id is not None and not feature_data.multiple_users_supported:
383 raise ValueError("User ID reported but not supported by device features")
385 if basic.basal_metabolism is not None and not feature_data.basal_metabolism_supported:
386 raise ValueError("Basal metabolism reported but not supported by device features")
388 if mass.muscle_mass is not None and not feature_data.muscle_mass_supported:
389 raise ValueError("Muscle mass reported but not supported by device features")
391 if mass.muscle_percentage is not None and not feature_data.muscle_percentage_supported:
392 raise ValueError("Muscle percentage reported but not supported by device features")
394 if mass.fat_free_mass is not None and not feature_data.fat_free_mass_supported:
395 raise ValueError("Fat free mass reported but not supported by device features")
397 if mass.soft_lean_mass is not None and not feature_data.soft_lean_mass_supported:
398 raise ValueError("Soft lean mass reported but not supported by device features")
400 if mass.body_water_mass is not None and not feature_data.body_water_mass_supported:
401 raise ValueError("Body water mass reported but not supported by device features")
403 if other.impedance is not None and not feature_data.impedance_supported:
404 raise ValueError("Impedance reported but not supported by device features")
406 if other.weight is not None and not feature_data.weight_supported:
407 raise ValueError("Weight reported but not supported by device features")
409 if other.height is not None and not feature_data.height_supported:
410 raise ValueError("Height reported but not supported by device features")
412 def _parse_flags_and_body_fat(self, data: bytearray) -> FlagsAndBodyFat:
413 """Parse flags and body fat percentage from data.
415 Returns:
416 FlagsAndBodyFat containing flags, body fat percentage, and offset
418 """
419 # Parse flags (2 bytes)
420 flags = BodyCompositionFlags(DataParser.parse_int16(data, 0, signed=False))
422 # Validate and parse body fat percentage data
423 if len(data) < 4:
424 raise ValueError("Insufficient data for body fat percentage")
426 body_fat_raw = DataParser.parse_int16(data, 2, signed=False)
427 body_fat_percentage = float(body_fat_raw) * BODY_FAT_PERCENTAGE_RESOLUTION # 0.1% resolution
429 return FlagsAndBodyFat(flags=flags, body_fat_percentage=body_fat_percentage, offset=4)
431 def _parse_basic_optional_fields(
432 self,
433 data: bytearray,
434 flags: BodyCompositionFlags,
435 offset: int,
436 ) -> BasicOptionalFields:
437 """Parse basic optional fields (timestamp, user ID, basal metabolism).
439 Args:
440 data: Raw bytearray
441 flags: Parsed flags indicating which fields are present
442 offset: Current offset in data
444 Returns:
445 BasicOptionalFields containing timestamp, user_id, basal_metabolism, and updated offset
447 """
448 timestamp: datetime | None = None
449 user_id: int | None = None
450 basal_metabolism: int | None = None
452 # Parse optional timestamp (7 bytes) if present
453 if BodyCompositionFlags.TIMESTAMP_PRESENT in flags and len(data) >= offset + 7:
454 timestamp = IEEE11073Parser.parse_timestamp(data, offset)
455 offset += 7
457 # Parse optional user ID (1 byte) if present
458 if BodyCompositionFlags.USER_ID_PRESENT in flags and len(data) >= offset + 1:
459 user_id = int(data[offset])
460 offset += 1
462 # Parse optional basal metabolism (uint16) if present
463 if BodyCompositionFlags.BASAL_METABOLISM_PRESENT in flags and len(data) >= offset + 2:
464 basal_metabolism_raw = DataParser.parse_int16(data, offset, signed=False)
465 basal_metabolism = basal_metabolism_raw # in kJ
466 offset += 2
468 return BasicOptionalFields(
469 timestamp=timestamp, user_id=user_id, basal_metabolism=basal_metabolism, offset=offset
470 )
472 def _parse_mass_fields(
473 self,
474 data: bytearray,
475 flags: BodyCompositionFlags,
476 offset: int,
477 ) -> MassFields:
478 """Parse mass-related optional fields.
480 Args:
481 data: Raw bytearray
482 flags: Parsed flags
483 offset: Current offset
485 Returns:
486 MassFields containing muscle_mass, muscle_mass_unit, muscle_percentage,
487 fat_free_mass, soft_lean_mass, body_water_mass, and updated offset
489 """
490 muscle_mass: float | None = None
491 muscle_mass_unit: WeightUnit | None = None
492 muscle_percentage: float | None = None
493 fat_free_mass: float | None = None
494 soft_lean_mass: float | None = None
495 body_water_mass: float | None = None
497 # Parse optional muscle mass
498 if BodyCompositionFlags.MUSCLE_MASS_PRESENT in flags and len(data) >= offset + 2:
499 mass_value = self._parse_mass_field(data, flags, offset)
500 muscle_mass = mass_value.value
501 muscle_mass_unit = mass_value.unit
502 offset += 2
504 # Parse optional muscle percentage
505 if BodyCompositionFlags.MUSCLE_PERCENTAGE_PRESENT in flags and len(data) >= offset + 2:
506 muscle_percentage_raw = DataParser.parse_int16(data, offset, signed=False)
507 muscle_percentage = muscle_percentage_raw * MUSCLE_PERCENTAGE_RESOLUTION
508 offset += 2
510 # Parse optional fat free mass
511 if BodyCompositionFlags.FAT_FREE_MASS_PRESENT in flags and len(data) >= offset + 2:
512 fat_free_mass = self._parse_mass_field(data, flags, offset).value
513 offset += 2
515 # Parse optional soft lean mass
516 if BodyCompositionFlags.SOFT_LEAN_MASS_PRESENT in flags and len(data) >= offset + 2:
517 soft_lean_mass = self._parse_mass_field(data, flags, offset).value
518 offset += 2
520 # Parse optional body water mass
521 if BodyCompositionFlags.BODY_WATER_MASS_PRESENT in flags and len(data) >= offset + 2:
522 body_water_mass = self._parse_mass_field(data, flags, offset).value
523 offset += 2
525 return MassFields(
526 muscle_mass=muscle_mass,
527 muscle_mass_unit=muscle_mass_unit,
528 muscle_percentage=muscle_percentage,
529 fat_free_mass=fat_free_mass,
530 soft_lean_mass=soft_lean_mass,
531 body_water_mass=body_water_mass,
532 offset=offset,
533 )
535 def _parse_other_measurements(
536 self,
537 data: bytearray,
538 flags: BodyCompositionFlags,
539 offset: int,
540 ) -> OtherMeasurements:
541 """Parse impedance, weight, and height measurements.
543 Args:
544 data: Raw bytearray
545 flags: Parsed flags
546 offset: Current offset
548 Returns:
549 OtherMeasurements containing impedance, weight, and height
551 """
552 impedance: float | None = None
553 weight: float | None = None
554 height: float | None = None
556 # Parse optional impedance
557 if BodyCompositionFlags.IMPEDANCE_PRESENT in flags and len(data) >= offset + 2:
558 impedance_raw = DataParser.parse_int16(data, offset, signed=False)
559 impedance = impedance_raw * IMPEDANCE_RESOLUTION
560 offset += 2
562 # Parse optional weight
563 if BodyCompositionFlags.WEIGHT_PRESENT in flags and len(data) >= offset + 2:
564 weight = self._parse_mass_field(data, flags, offset).value
565 offset += 2
567 # Parse optional height
568 if BodyCompositionFlags.HEIGHT_PRESENT in flags and len(data) >= offset + 2:
569 height_raw = DataParser.parse_int16(data, offset, signed=False)
570 if BodyCompositionFlags.IMPERIAL_UNITS in flags: # Imperial units
571 height = height_raw * HEIGHT_RESOLUTION_IMPERIAL # 0.1 inch resolution
572 else: # SI units
573 height = height_raw * HEIGHT_RESOLUTION_METRIC # 0.001 m resolution
574 offset += 2
576 return OtherMeasurements(impedance=impedance, weight=weight, height=height)
578 def _parse_mass_field(self, data: bytearray, flags: BodyCompositionFlags, offset: int) -> MassValue:
579 """Parse a mass field with unit conversion.
581 Args:
582 data: Raw bytearray
583 flags: Parsed flags for unit determination
584 offset: Current offset
586 Returns:
587 MassValue containing mass value and unit string
589 """
590 mass_raw = DataParser.parse_int16(data, offset, signed=False)
591 if BodyCompositionFlags.IMPERIAL_UNITS in flags: # Imperial units
592 mass = mass_raw * MASS_RESOLUTION_LB # 0.01 lb resolution
593 mass_unit = WeightUnit.LB
594 else: # SI units
595 mass = mass_raw * MASS_RESOLUTION_KG # 0.005 kg resolution
596 mass_unit = WeightUnit.KG
597 return MassValue(value=mass, unit=mass_unit)