"""Tests for the numeric block.""" from __future__ import annotations import pandas as pd import pytest from src.numeric.obesity import ( OBESITY_LEVELS, apply_favc_override, derive_high_caloric_meal, ) from src.numeric.profile import ( bmi_to_band, bmr_mifflin_st_jeor, daily_target_kcal, macro_targets, tdee, ) from src.numeric.model import predict def _profile(**overrides): """Build a complete profile dict with sensible defaults for tests.""" base = { "age": 30, "weight_kg": 75, "height_cm": 175, "Gender": "Male", "family_history_with_overweight": "no", "FAVC": "no", "FCVC": 2.0, "NCP": 3.0, "CAEC": "Sometimes", "SMOKE": "no", "CH2O": 2.0, "SCC": "no", "FAF": 1.0, "TUE": 1.0, "CALC": "no", "MTRANS": "Public_Transportation", "activity_level": "moderate", "goal": "maintain", } base.update(overrides) return base def test_bmr_known_values(): # Mifflin-St Jeor for a 30-year-old, 75kg, 178cm male: # 10*75 + 6.25*178 - 5*30 + 5 = 1717.5 kcal assert bmr_mifflin_st_jeor(30, 75, 178, "male") == pytest.approx(1717.5, abs=0.5) def test_tdee_scales_with_activity(): bmr = 1700 assert tdee(bmr, "sedentary") < tdee(bmr, "moderate") < tdee(bmr, "very active") def test_target_respects_goal(): args = (30, 75, 178, "male", "moderate") lose = daily_target_kcal(*args, "lose") maintain = daily_target_kcal(*args, "maintain") gain = daily_target_kcal(*args, "gain") assert lose < maintain < gain assert maintain - lose == 500 assert gain - maintain == 300 def test_macro_targets_sum_to_target_kcal_within_rounding(): target = 2500.0 m = macro_targets(target, "maintain") total = m["protein_g"] * 4 + m["fat_g"] * 9 + m["carbohydrate_g"] * 4 assert total == pytest.approx(target, rel=0.01) def test_high_caloric_meal_threshold(): high = {"calories_kcal": 850, "fat_g": 40} low = {"calories_kcal": 400, "fat_g": 15} borderline = {"calories_kcal": 750, "fat_g": 20} assert derive_high_caloric_meal(high) is True assert derive_high_caloric_meal(low) is False assert derive_high_caloric_meal(borderline) is False def test_favc_override_flips_columns(): row = pd.DataFrame([{"FAVC_yes": 0, "FAVC_no": 1, "Age": 30}]) out = apply_favc_override(row, {"calories_kcal": 900, "fat_g": 35}) assert int(out["FAVC_yes"].iloc[0]) == 1 assert int(out["FAVC_no"].iloc[0]) == 0 def test_favc_override_noop_for_low_calorie_meal(): row = pd.DataFrame([{"FAVC_yes": 0, "FAVC_no": 1}]) out = apply_favc_override(row, {"calories_kcal": 400, "fat_g": 10}) assert int(out["FAVC_yes"].iloc[0]) == 0 def test_predict_returns_expected_shape_without_models(): """When models are absent, predict() must return a coherent fallback.""" out = predict(_profile(weight_kg=75, height_cm=178), nutrition=None) assert out["obesity_class"] in OBESITY_LEVELS assert out["daily_target_kcal"] > 0 assert out["predicted_bmi"] > 0 assert "models" in out # ─────────────────────────────────────────────────────────────────── # BMI-band rule (transparent, training-data-independent) # ─────────────────────────────────────────────────────────────────── @pytest.mark.parametrize( "bmi, expected", [ (12.0, "Insufficient_Weight"), (17.0, "Insufficient_Weight"), (18.4, "Insufficient_Weight"), (18.5, "Normal_Weight"), (22.0, "Normal_Weight"), (24.9, "Normal_Weight"), (25.0, "Overweight_Level_I"), (27.4, "Overweight_Level_I"), (27.5, "Overweight_Level_II"), (29.9, "Overweight_Level_II"), (30.0, "Obesity_Type_I"), (34.9, "Obesity_Type_I"), (35.0, "Obesity_Type_II"), (39.9, "Obesity_Type_II"), (40.0, "Obesity_Type_III"), (60.0, "Obesity_Type_III"), (100.0, "Obesity_Type_III"), ], ) def test_bmi_to_band_covers_full_severity_range(bmi, expected): assert bmi_to_band(bmi) == expected # ─────────────────────────────────────────────────────────────────── # Predict shape: every call must populate the anomaly fields # ─────────────────────────────────────────────────────────────────── def test_predict_always_returns_bmi_band_and_anomaly_fields(): out = predict(_profile(), nutrition=None) assert out["bmi_band"] in OBESITY_LEVELS assert "anomaly_flags" in out assert isinstance(out["anomaly_flags"], list) assert "ood_blended" in out assert "bmi_raw" in out def test_predict_probabilities_sum_to_one(): out = predict(_profile(), nutrition=None) total = sum(out["obesity_probabilities"].values()) assert total == pytest.approx(1.0, abs=1e-3) # ─────────────────────────────────────────────────────────────────── # Realistic profiles — model should land on (or near) the BMI band # ─────────────────────────────────────────────────────────────────── @pytest.mark.parametrize( "label, profile_kwargs, expected_band", [ ("lean male", dict(weight_kg=65, height_cm=178), "Normal_Weight"), ("normal male", dict(weight_kg=75, height_cm=178), "Normal_Weight"), ("overweight ii", dict(weight_kg=90, height_cm=178), "Overweight_Level_II"), ("obese i", dict(weight_kg=100, height_cm=178), "Obesity_Type_I"), ("obese ii", dict(weight_kg=115, height_cm=178), "Obesity_Type_II"), ("obese iii female", dict(weight_kg=110, height_cm=165, Gender="Female"), "Obesity_Type_III"), ("insufficient fem.", dict(weight_kg=45, height_cm=165, Gender="Female"), "Insufficient_Weight"), ], ) def test_predict_matches_bmi_band_on_realistic_profiles(label, profile_kwargs, expected_band): out = predict(_profile(**profile_kwargs), nutrition=None) assert out["bmi_band"] == expected_band, f"{label}: bmi_band wrong" # Final class within 1 step of the BMI band — the classifier is allowed # to land on an adjacent severity bucket for borderline BMIs. gap = abs(OBESITY_LEVELS.index(out["obesity_class"]) - OBESITY_LEVELS.index(expected_band)) assert gap <= 1, ( f"{label}: predicted {out['obesity_class']} is {gap} classes " f"from expected band {expected_band}" ) # Most probability mass should sit on the band or an adjacent bucket. top_two = sorted(out["obesity_probabilities"].items(), key=lambda kv: -kv[1])[:2] assert sum(p for _, p in top_two) > 0.7, f"{label}: top-2 mass under 70%" # ─────────────────────────────────────────────────────────────────── # Anomaly handling — out-of-distribution / rare-conditional inputs # ─────────────────────────────────────────────────────────────────── def test_anomaly_extreme_overweight_male_routes_to_type_iii(): """Male BMI 44.2 — Type_III training data is 99.7% female, so the raw classifier predicts Type_II. The BMI-band override must correct this and the final class must be Type_III.""" out = predict(_profile(weight_kg=140, height_cm=178, Gender="Male"), nutrition=None) assert out["bmi_band"] == "Obesity_Type_III" assert out["obesity_class"] == "Obesity_Type_III" assert out["ood_blended"] is True # A flag mentioning the female-bias must be visible to the user. assert any("female" in f.lower() for f in out["anomaly_flags"]) assert out["obesity_probabilities"]["Obesity_Type_III"] > 0.5 def test_anomaly_weight_above_training_range_is_flagged_and_blended(): out = predict(_profile(weight_kg=220, height_cm=178), nutrition=None) assert out["ood_blended"] is True assert out["obesity_class"] == "Obesity_Type_III" assert any("trained range" in f.lower() for f in out["anomaly_flags"]) # Despite the model wanting Type_II, the blended final class must # be at the top of the severity scale. assert out["obesity_probabilities"]["Obesity_Type_III"] > 0.5 def test_anomaly_weight_below_training_range_is_flagged(): out = predict(_profile(weight_kg=35, height_cm=178), nutrition=None) assert out["ood_blended"] is True assert out["obesity_class"] == "Insufficient_Weight" assert any("below the trained range" in f.lower() for f in out["anomaly_flags"]) def test_anomaly_bmi_below_training_range_is_flagged(): # Tall and very light → BMI ~10.1, below the trained 13.0 floor. out = predict(_profile(weight_kg=38, height_cm=195), nutrition=None) assert out["bmi_band"] == "Insufficient_Weight" assert any("bmi" in f.lower() and "outside" in f.lower() for f in out["anomaly_flags"]) def test_anomaly_age_outside_range_is_flagged(): out = predict(_profile(age=80), nutrition=None) assert any("age" in f.lower() and "outside" in f.lower() for f in out["anomaly_flags"]) # ─────────────────────────────────────────────────────────────────── # In-distribution behavior — model should NOT be overridden when it # is operating inside the trained envelope. # ─────────────────────────────────────────────────────────────────── def test_in_distribution_borderline_is_not_blended(): """BMI 26.8 is right on the Overweight I/II boundary. Adjacent-class disagreement is acceptable here — no OOD blend, no flags about training range.""" out = predict(_profile(weight_kg=85, height_cm=178), nutrition=None) assert out["ood_blended"] is False # No range-violation flags for in-distribution input. assert not any("range" in f.lower() for f in out["anomaly_flags"]) def test_in_distribution_female_type_iii_is_not_blended(): """Female Type_III is well-represented (323 examples) — the model should handle this without needing the BMI-band override.""" out = predict(_profile(weight_kg=110, height_cm=165, Gender="Female"), nutrition=None) assert out["bmi_band"] == "Obesity_Type_III" assert out["obesity_class"] == "Obesity_Type_III" assert out["ood_blended"] is False