Source code for usda_fdc.analysis.analysis

"""
Food nutrient analysis functionality.
"""

from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any, Union, Tuple

from ..models import Food, Nutrient
from ..utils import convert_measurement
from .dri import DriType, DriValue, Gender, get_dri, get_dri_value

# FDC writes its units in caps ("MG"), and micrograms as "UG". Anything not
# listed here — IU above all — has no mass dimension we can convert.
_FDC_UNIT_ALIASES = {
    "g": "g",
    "mg": "mg",
    "ug": "µg",
    "µg": "µg",
    "mcg": "µg",
}


def _dri_percent(amount: float, unit: Optional[str], dri: Optional[DriValue]) -> Optional[float]:
    """What percentage of a DRI an amount represents, or None if they cannot be compared.

    The DRI files disagree about units: an RDA for iron is 8 mg, its UL is
    0.045 — grams. Dividing a food's milligrams by the latter would report 1000x
    the truth, so the amount is converted into the DRI's own unit first, and a
    pair that cannot be converted (vitamin A in IU against a µg allowance)
    yields nothing rather than a confident wrong number.
    """
    if dri is None or not dri.value:
        return None

    food_unit = _FDC_UNIT_ALIASES.get((unit or "").strip().lower())
    dri_unit = _FDC_UNIT_ALIASES.get((dri.unit or "").strip().lower())
    if not food_unit or not dri_unit:
        return None

    try:
        comparable = convert_measurement(amount, food_unit, dri_unit)
    except ValueError:
        return None

    return (comparable / dri.value) * 100.0

[docs]@dataclass class NutrientValue: """ Represents a nutrient value with additional analysis information. """ nutrient: Nutrient amount: float unit: str dri: Optional[float] = None dri_percent: Optional[float] = None dri_type: Optional[DriType] = None # The unit ``dri`` is expressed in, which is not always the food's own: # an RDA for iron is 8 mg, its UL is 0.045 g. dri_unit: Optional[str] = None
[docs]@dataclass class NutrientAnalysis: """ Analysis of a food's nutrient content. """ food: Food serving_size: float # in grams nutrients: Dict[str, NutrientValue] = field(default_factory=dict) calories_per_serving: float = 0.0 protein_per_serving: float = 0.0 carbs_per_serving: float = 0.0 fat_per_serving: float = 0.0 macronutrient_distribution: Dict[str, float] = field(default_factory=dict)
[docs] def get_nutrient(self, nutrient_id: str) -> Optional[NutrientValue]: """ Get a nutrient value by ID. Args: nutrient_id: The nutrient ID or name. Returns: The nutrient value, or None if not found. """ return self.nutrients.get(nutrient_id.lower())
# FDC reports a food's energy several times over, under the same name: # # 1008 / 208 Energy kcal # 1062 / 268 Energy kJ (the same energy!) # 2047 / 957 Energy (Atwater General Factors) kcal # 2048 / 958 Energy (Atwater Specific Factors) kcal # # Matching those on the name alone made every one of them "calories", so # whichever came last in the list won. Clarified butter (fdc_id 171314) lists # 900 kcal and then 3766 kJ, and was reported as 3766 "calories" — a figure the # CLI and the HTML report then printed with a kcal suffix. # # Only the kcal rows are calories, and where a food carries more than one of # them we prefer the plain Energy row so the answer cannot depend on list order. _ENERGY_KCAL_PRECEDENCE = ( (1008, "208"), # Energy (2048, "958"), # Energy (Atwater Specific Factors) (2047, "957"), # Energy (Atwater General Factors) ) # Abridged responses spell the unit "KCAL"; full ones spell it "kcal". _KCAL_UNITS = {"kcal", "kilocalorie", "kilocalories"} def _is_energy(nutrient: Nutrient) -> bool: """Whether a nutrient row describes a food's energy, in any unit.""" return "energy" in (nutrient.name or "").lower() def _is_kcal(nutrient: Nutrient) -> bool: """Whether a nutrient row is measured in kilocalories rather than kilojoules.""" return (nutrient.unit_name or "").strip().lower() in _KCAL_UNITS def _energy_precedence(nutrient: Nutrient) -> int: """Rank an energy row; lower wins. An abridged food carries no nutrient id, only its number, so match on either. """ for rank, (nutrient_id, nutrient_nbr) in enumerate(_ENERGY_KCAL_PRECEDENCE): if nutrient.id == nutrient_id or str(nutrient.nutrient_nbr or "") == nutrient_nbr: return rank return len(_ENERGY_KCAL_PRECEDENCE) def _get_nutrient_id(nutrient: Nutrient) -> str: """ Get a standardized nutrient ID from a nutrient. Args: nutrient: The nutrient object. Returns: A standardized nutrient ID. """ if _is_energy(nutrient): # A kJ row is not calories. Give it an id of its own so it cannot # overwrite the kcal row it sits beside. if _is_kcal(nutrient): return "calories" return f"energy_{(nutrient.unit_name or 'unknown').strip().lower()}" # Map common nutrient names to standardized IDs name_map = { "protein": "protein", "total lipid (fat)": "fat", "fatty acids, total saturated": "saturated_fat", "carbohydrate, by difference": "carbs", "fiber, total dietary": "fiber", "sugars, total including nlea": "sugar", "calcium, ca": "calcium", "iron, fe": "iron", "sodium, na": "sodium", "vitamin c, total ascorbic acid": "vitamin_c", "vitamin a, iu": "vitamin_a", "cholesterol": "cholesterol", "potassium, k": "potassium" } # Try to match by name name_lower = nutrient.name.lower() for key, value in name_map.items(): if key in name_lower: return value # If no match, use a simplified version of the name return name_lower.replace(",", "").replace(" ", "_")
[docs]def analyze_food( food: Food, serving_size: float = 100.0, dri_type: DriType = DriType.RDA, gender: Gender = Gender.MALE, age: int = 30 ) -> NutrientAnalysis: """ Analyze the nutrient content of a food. Args: food: The food to analyze. serving_size: The serving size in grams. dri_type: The type of DRI to use for comparison. gender: The gender to use for DRI values. age: The age to use for DRI values. Returns: A NutrientAnalysis object. """ # Create analysis object analysis = NutrientAnalysis( food=food, serving_size=serving_size ) # Rank of the energy row currently holding "calories", so a lower-ranked # one cannot displace it. calories_rank: Optional[int] = None # Process nutrients for nutrient in food.nutrients: # Get standardized nutrient ID nutrient_id = _get_nutrient_id(nutrient) # Calculate amount for the serving size amount = nutrient.amount * (serving_size / 100.0) # Get DRI value if available, and the unit it is expressed in dri = get_dri_value(nutrient_id, dri_type, gender, age) # Create nutrient value nutrient_value = NutrientValue( nutrient=nutrient, amount=amount, unit=nutrient.unit_name, dri=dri.value if dri else None, dri_percent=_dri_percent(amount, nutrient.unit_name, dri), dri_type=dri_type, dri_unit=dri.unit if dri else None ) if nutrient_id == "calories": rank = _energy_precedence(nutrient) if calories_rank is not None and rank >= calories_rank: # Already holding a better energy row; keep it. continue calories_rank = rank # Add to nutrients dictionary analysis.nutrients[nutrient_id] = nutrient_value # Track macronutrients if nutrient_id == "protein": analysis.protein_per_serving = amount elif nutrient_id == "fat": analysis.fat_per_serving = amount elif nutrient_id == "carbs": analysis.carbs_per_serving = amount elif nutrient_id == "calories": analysis.calories_per_serving = amount # Calculate macronutrient distribution total_calories = 0.0 # Protein: 4 calories per gram protein_calories = analysis.protein_per_serving * 4.0 total_calories += protein_calories # Carbs: 4 calories per gram carb_calories = analysis.carbs_per_serving * 4.0 total_calories += carb_calories # Fat: 9 calories per gram fat_calories = analysis.fat_per_serving * 9.0 total_calories += fat_calories # If we don't have macronutrient data, use the calories value if total_calories == 0.0 and analysis.calories_per_serving > 0: total_calories = analysis.calories_per_serving # Calculate percentages if total_calories > 0: analysis.macronutrient_distribution = { "protein": (protein_calories / total_calories) * 100.0 if protein_calories > 0 else 0.0, "carbs": (carb_calories / total_calories) * 100.0 if carb_calories > 0 else 0.0, "fat": (fat_calories / total_calories) * 100.0 if fat_calories > 0 else 0.0 } return analysis
[docs]def analyze_foods( foods: List[Food], serving_sizes: Optional[List[float]] = None, dri_type: DriType = DriType.RDA, gender: Gender = Gender.MALE, age: int = 30 ) -> List[NutrientAnalysis]: """ Analyze the nutrient content of multiple foods. Args: foods: The foods to analyze. serving_sizes: The serving sizes in grams (one per food). dri_type: The type of DRI to use for comparison. gender: The gender to use for DRI values. age: The age to use for DRI values. Returns: A list of NutrientAnalysis objects. """ # Use default serving size if not provided if serving_sizes is None: serving_sizes = [100.0] * len(foods) # Ensure serving_sizes matches foods length if len(serving_sizes) != len(foods): raise ValueError("Number of serving sizes must match number of foods") # Analyze each food return [ analyze_food(food, serving_size, dri_type, gender, age) for food, serving_size in zip(foods, serving_sizes) ]
[docs]def compare_foods( foods: List[Food], nutrient_ids: Optional[List[str]] = None, serving_sizes: Optional[List[float]] = None, dri_type: DriType = DriType.RDA, gender: Gender = Gender.MALE, age: int = 30 ) -> Dict[str, List[Tuple[str, float, str]]]: """ Compare the nutrient content of multiple foods. Args: foods: The foods to compare. nutrient_ids: The nutrient IDs to compare. serving_sizes: The serving sizes in grams (one per food). dri_type: The type of DRI to use for comparison. gender: The gender to use for DRI values. age: The age to use for DRI values. Returns: A dictionary mapping nutrient IDs to lists of (food_name, amount, unit) tuples. """ # Analyze foods analyses = analyze_foods(foods, serving_sizes, dri_type, gender, age) # If no nutrient IDs provided, use common ones if nutrient_ids is None: nutrient_ids = ["protein", "fat", "carbs", "fiber", "vitamin_c", "calcium", "iron"] # Initialize result dictionary result: Dict[str, List[Tuple[str, float, str]]] = { nutrient_id: [] for nutrient_id in nutrient_ids } # Compare nutrients for analysis in analyses: for nutrient_id in nutrient_ids: nutrient_value = analysis.get_nutrient(nutrient_id) if nutrient_value: result[nutrient_id].append(( analysis.food.description, nutrient_value.amount, nutrient_value.unit )) return result