Skip to main content

oxihuman_morph/
body_composition.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Fat / muscle / bone composition model with formula-based conversions.
5
6// ── Types ─────────────────────────────────────────────────────────────────────
7
8/// Body composition as fractions (should sum to ~1.0).
9#[allow(dead_code)]
10#[derive(Debug, Clone, PartialEq)]
11pub struct BodyComposition {
12    /// Fraction of body fat (0.0–1.0).
13    pub fat_pct: f32,
14    /// Fraction of muscle mass.
15    pub muscle_pct: f32,
16    /// Fraction of bone mass.
17    pub bone_pct: f32,
18    /// Fraction of water.
19    pub water_pct: f32,
20}
21
22/// Extended profile including biometric context.
23#[allow(dead_code)]
24#[derive(Debug, Clone)]
25pub struct CompositionProfile {
26    /// 0 = male, 1 = female.
27    pub sex: u8,
28    pub age: f32,
29    pub height_m: f32,
30    pub weight_kg: f32,
31    pub composition: BodyComposition,
32}
33
34// ── Validation & basic formulas ───────────────────────────────────────────────
35
36/// Returns true if all fractions are in `[0,1]` and sum is within 1% of 1.0.
37#[allow(dead_code)]
38pub fn validate_composition(comp: &BodyComposition) -> bool {
39    let parts = [comp.fat_pct, comp.muscle_pct, comp.bone_pct, comp.water_pct];
40    if parts.iter().any(|v| !(0.0..=1.0).contains(v)) {
41        return false;
42    }
43    let sum: f32 = parts.iter().sum();
44    (sum - 1.0).abs() < 0.01
45}
46
47/// Body Mass Index.
48#[allow(dead_code)]
49pub fn bmi(height_m: f32, weight_kg: f32) -> f32 {
50    if height_m < 0.01 {
51        return 0.0;
52    }
53    weight_kg / (height_m * height_m)
54}
55
56/// Deurenberg formula: estimate body fat % from BMI, sex, and age.
57/// sex: 0 = male, 1 = female.
58#[allow(dead_code)]
59pub fn body_fat_from_bmi_sex_age(bmi_val: f32, sex: u8, age_years: f32) -> f32 {
60    // BF% = 1.20 * BMI + 0.23 * age - 10.8 * sex_factor - 5.4
61    // sex_factor: 1 for male, 0 for female
62    let sex_factor = if sex == 0 { 1.0_f32 } else { 0.0_f32 };
63    let bf = 1.20 * bmi_val + 0.23 * age_years - 10.8 * sex_factor - 5.4;
64    (bf / 100.0).clamp(0.02, 0.65)
65}
66
67/// Lean mass in kilograms.
68#[allow(dead_code)]
69pub fn lean_mass_kg(weight_kg: f32, fat_pct: f32) -> f32 {
70    weight_kg * (1.0 - fat_pct.clamp(0.0, 1.0))
71}
72
73/// Fat mass in kilograms.
74#[allow(dead_code)]
75pub fn fat_mass_kg(weight_kg: f32, fat_pct: f32) -> f32 {
76    weight_kg * fat_pct.clamp(0.0, 1.0)
77}
78
79/// Classify body fat percentage.
80/// sex: 0 = male, 1 = female.
81#[allow(dead_code)]
82pub fn classify_body_fat(fat_pct: f32, sex: u8) -> &'static str {
83    if sex == 0 {
84        // Male thresholds (ACE guidelines)
85        if fat_pct < 0.05 {
86            "essential"
87        } else if fat_pct < 0.14 {
88            "athletic"
89        } else if fat_pct < 0.18 {
90            "fitness"
91        } else if fat_pct < 0.25 {
92            "average"
93        } else {
94            "obese"
95        }
96    } else {
97        // Female thresholds
98        if fat_pct < 0.10 {
99            "essential"
100        } else if fat_pct < 0.21 {
101            "athletic"
102        } else if fat_pct < 0.25 {
103            "fitness"
104        } else if fat_pct < 0.32 {
105            "average"
106        } else {
107            "obese"
108        }
109    }
110}
111
112/// Devine formula: ideal body weight in kg.
113/// sex: 0 = male, 1 = female.
114#[allow(dead_code)]
115pub fn ideal_weight_devine(height_m: f32, sex: u8) -> f32 {
116    let height_cm = height_m * 100.0;
117    let inches_over_5ft = ((height_cm / 2.54) - 60.0).max(0.0);
118    if sex == 0 {
119        50.0 + 2.3 * inches_over_5ft
120    } else {
121        45.5 + 2.3 * inches_over_5ft
122    }
123}
124
125/// Fat-Free Mass Index = lean_mass_kg / height_m^2.
126#[allow(dead_code)]
127pub fn ffmi(lean_mass_kg_val: f32, height_m: f32) -> f32 {
128    if height_m < 0.01 {
129        return 0.0;
130    }
131    lean_mass_kg_val / (height_m * height_m)
132}
133
134// ── Morph parameter mapping ───────────────────────────────────────────────────
135
136/// Map body composition to a list of morph parameter names and values.
137#[allow(dead_code)]
138pub fn morph_params_from_composition(comp: &BodyComposition) -> Vec<(String, f32)> {
139    vec![
140        ("fat-torso".to_string(), comp.fat_pct),
141        ("fat-arms".to_string(), comp.fat_pct * 0.7),
142        ("fat-legs".to_string(), comp.fat_pct * 0.8),
143        ("muscle-torso".to_string(), comp.muscle_pct),
144        (
145            "muscle-arms".to_string(),
146            comp.muscle_pct * 1.1_f32.min(1.0),
147        ),
148        ("muscle-legs".to_string(), comp.muscle_pct * 0.9),
149        ("bone-mass".to_string(), comp.bone_pct),
150    ]
151}
152
153/// Inverse mapping: reconstruct approximate composition from morph params.
154#[allow(dead_code)]
155pub fn composition_from_morph_params(params: &[(String, f32)]) -> BodyComposition {
156    let get = |key: &str| -> f32 {
157        params
158            .iter()
159            .find(|(k, _)| k == key)
160            .map_or(0.0, |(_, v)| *v)
161    };
162    let fat_pct = get("fat-torso").clamp(0.0, 1.0);
163    let muscle_pct = get("muscle-torso").clamp(0.0, 1.0);
164    let bone_pct = get("bone-mass").clamp(0.0, 1.0);
165    let rest = (1.0_f32 - fat_pct - muscle_pct - bone_pct).max(0.0);
166    BodyComposition {
167        fat_pct,
168        muscle_pct,
169        bone_pct,
170        water_pct: rest,
171    }
172}
173
174/// Linear interpolation between two compositions.
175#[allow(dead_code)]
176pub fn interpolate_compositions(
177    a: &BodyComposition,
178    b: &BodyComposition,
179    t: f32,
180) -> BodyComposition {
181    let lerp = |x: f32, y: f32| x + t * (y - x);
182    BodyComposition {
183        fat_pct: lerp(a.fat_pct, b.fat_pct),
184        muscle_pct: lerp(a.muscle_pct, b.muscle_pct),
185        bone_pct: lerp(a.bone_pct, b.bone_pct),
186        water_pct: lerp(a.water_pct, b.water_pct),
187    }
188}
189
190/// L2 distance between two composition vectors.
191#[allow(dead_code)]
192pub fn composition_distance(a: &BodyComposition, b: &BodyComposition) -> f32 {
193    let df = a.fat_pct - b.fat_pct;
194    let dm = a.muscle_pct - b.muscle_pct;
195    let db = a.bone_pct - b.bone_pct;
196    let dw = a.water_pct - b.water_pct;
197    (df * df + dm * dm + db * db + dw * dw).sqrt()
198}
199
200// ── Tests ─────────────────────────────────────────────────────────────────────
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    fn valid_comp() -> BodyComposition {
207        BodyComposition {
208            fat_pct: 0.20,
209            muscle_pct: 0.45,
210            bone_pct: 0.15,
211            water_pct: 0.20,
212        }
213    }
214
215    #[test]
216    fn test_validate_composition_valid() {
217        assert!(validate_composition(&valid_comp()));
218    }
219
220    #[test]
221    fn test_validate_composition_over_1() {
222        let c = BodyComposition {
223            fat_pct: 0.5,
224            muscle_pct: 0.5,
225            bone_pct: 0.5,
226            water_pct: 0.5,
227        };
228        assert!(!validate_composition(&c));
229    }
230
231    #[test]
232    fn test_validate_composition_negative() {
233        let c = BodyComposition {
234            fat_pct: -0.1,
235            muscle_pct: 0.5,
236            bone_pct: 0.2,
237            water_pct: 0.4,
238        };
239        assert!(!validate_composition(&c));
240    }
241
242    #[test]
243    fn test_bmi_formula() {
244        let b = bmi(1.8, 81.0);
245        assert!((b - 25.0).abs() < 0.1);
246    }
247
248    #[test]
249    fn test_bmi_zero_height() {
250        assert_eq!(bmi(0.0, 80.0), 0.0);
251    }
252
253    #[test]
254    fn test_body_fat_no_nan() {
255        let bf = body_fat_from_bmi_sex_age(25.0, 0, 30.0);
256        assert!(!bf.is_nan());
257    }
258
259    #[test]
260    fn test_body_fat_clamped() {
261        let bf = body_fat_from_bmi_sex_age(25.0, 0, 30.0);
262        assert!((0.02..=0.65).contains(&bf));
263    }
264
265    #[test]
266    fn test_classify_fat_male_athletic() {
267        assert_eq!(classify_body_fat(0.10, 0), "athletic");
268    }
269
270    #[test]
271    fn test_classify_fat_female_obese() {
272        assert_eq!(classify_body_fat(0.38, 1), "obese");
273    }
274
275    #[test]
276    fn test_classify_fat_male_essential() {
277        assert_eq!(classify_body_fat(0.03, 0), "essential");
278    }
279
280    #[test]
281    fn test_ideal_weight_sex_difference() {
282        let male = ideal_weight_devine(1.75, 0);
283        let female = ideal_weight_devine(1.75, 1);
284        assert!(male > female);
285    }
286
287    #[test]
288    fn test_ideal_weight_devine_known() {
289        // 5'9" = 175.26 cm. inches over 5ft = 9
290        let w = ideal_weight_devine(1.7526, 0);
291        assert!((w - 70.7).abs() < 1.0);
292    }
293
294    #[test]
295    fn test_ffmi_range() {
296        let f = ffmi(70.0, 1.8);
297        assert!(f > 10.0 && f < 30.0);
298    }
299
300    #[test]
301    fn test_interpolate_t0() {
302        let a = valid_comp();
303        let b = BodyComposition {
304            fat_pct: 0.30,
305            muscle_pct: 0.40,
306            bone_pct: 0.10,
307            water_pct: 0.20,
308        };
309        let out = interpolate_compositions(&a, &b, 0.0);
310        assert!((out.fat_pct - a.fat_pct).abs() < 1e-6);
311    }
312
313    #[test]
314    fn test_interpolate_t1() {
315        let a = valid_comp();
316        let b = BodyComposition {
317            fat_pct: 0.30,
318            muscle_pct: 0.40,
319            bone_pct: 0.10,
320            water_pct: 0.20,
321        };
322        let out = interpolate_compositions(&a, &b, 1.0);
323        assert!((out.fat_pct - b.fat_pct).abs() < 1e-6);
324    }
325
326    #[test]
327    fn test_composition_distance_zero_same() {
328        let a = valid_comp();
329        let d = composition_distance(&a, &a);
330        assert!(d.abs() < 1e-6);
331    }
332
333    #[test]
334    fn test_composition_distance_positive_different() {
335        let a = valid_comp();
336        let b = BodyComposition {
337            fat_pct: 0.30,
338            muscle_pct: 0.40,
339            bone_pct: 0.10,
340            water_pct: 0.20,
341        };
342        assert!(composition_distance(&a, &b) > 0.0);
343    }
344
345    #[test]
346    fn test_lean_mass_kg() {
347        assert!((lean_mass_kg(80.0, 0.20) - 64.0).abs() < 1e-4);
348    }
349
350    #[test]
351    fn test_fat_mass_kg() {
352        assert!((fat_mass_kg(80.0, 0.20) - 16.0).abs() < 1e-4);
353    }
354}