Source code for usda_fdc.analysis.dri

"""
Dietary Reference Intake (DRI) data and utilities.
"""

import os
import json
import logging
from enum import Enum
from typing import Dict, NamedTuple, Optional, Any, Union

logger = logging.getLogger(__name__)

# Path to DRI data files
DRI_DATA_DIR = os.path.join(os.path.dirname(__file__), "resources", "dri")

[docs]class Gender(str, Enum): """Gender for DRI calculations.""" MALE = "male" FEMALE = "female"
[docs]class DriType(str, Enum): """Types of Dietary Reference Intakes. Only RDA and UL ship with data. Asking for a type with no data behind it returns ``None`` and logs a warning — see ``get_dri``. """ RDA = "rda" # Recommended Dietary Allowance AI = "ai" # Adequate Intake UL = "ul" # Tolerable Upper Intake Level EAR = "ear" # Estimated Average Requirement AMDR = "amdr" # Acceptable Macronutrient Distribution Range
class DriValue(NamedTuple): """A DRI, with the unit it is expressed in. The unit is the whole point: the shipped data files disagree about it. rda.json states a natural unit per nutrient (iron in mg, vitamin A in µg), while ul.json expresses every nutrient in grams (iron 0.045 = 45 mg). Handing a caller a bare number invited it to be compared against a food's milligrams, off by a factor of a thousand and with nothing to show for it. """ value: float unit: str # Any age, for a data file that does not break its values down by age. _ANY_AGE = "*" # Cache for DRI data, normalized to a single shape _dri_cache: Dict[str, Dict[str, Any]] = {} # DRI types we have already warned about, so a missing file is reported once # rather than once per nutrient per food. _warned_missing: set = set() def _parse_age_group(age_group: str) -> str: """Turn a metadata age_group ("19+ years", "19-50 years") into a range key.""" return age_group.replace("years", "").replace("year", "").strip() def _normalize(raw: Dict[str, Any]) -> Dict[str, Any]: """Normalize a DRI file into one shape. Two schemas ship in this package: * ``rda.json`` keys nutrients directly, and breaks each down by gender and age with its own unit: ``{"iron": {"male": {"19+": 8}, "female": {...}, "unit": "mg"}}`` * ``ul.json``, ``rda_male.json`` and ``rda_female.json`` wrap a flat table in metadata, apply to one age band, and express everything in grams: ``{"metadata": {...}, "dietary_reference_intakes": {"iron": 0.045}}`` Only the first was ever read, which is why every DriType other than RDA silently returned None. """ table = raw.get("dietary_reference_intakes") if table is None: return raw # rda.json shape: already normalized metadata = raw.get("metadata", {}) age_range = _parse_age_group(metadata.get("age_group", "")) or _ANY_AGE gender = metadata.get("gender", "all") genders = [g.value for g in Gender] if gender in ("all", "", None) else [gender] normalized: Dict[str, Any] = {} for nutrient_id, value in table.items(): entry: Dict[str, Any] = {"unit": "g"} # this schema is grams throughout for g in genders: entry[g] = {age_range: value} normalized[nutrient_id] = entry return normalized def _load_dri_data(dri_type: DriType) -> Dict[str, Any]: """ Load DRI data from JSON file. Args: dri_type: The type of DRI to load. Returns: Dictionary containing DRI data, normalized to a single shape. """ if dri_type.value in _dri_cache: return _dri_cache[dri_type.value] file_path = os.path.join(DRI_DATA_DIR, f"{dri_type.value}.json") try: with open(file_path, 'r') as f: data = _normalize(json.load(f)) except FileNotFoundError: # No data ships for this type. Say so once, instead of returning None # forever and leaving the caller to wonder why every DRI is missing. if dri_type.value not in _warned_missing: _warned_missing.add(dri_type.value) logger.warning( "No DRI data is available for %s; %s comparisons will be empty. " "This package ships data for %s and %s only.", dri_type.value.upper(), dri_type.value.upper(), DriType.RDA.value.upper(), DriType.UL.value.upper(), ) data = {} _dri_cache[dri_type.value] = data return data def get_dri_value( nutrient_id: Union[str, int], dri_type: DriType = DriType.RDA, gender: Gender = Gender.MALE, age: int = 30 ) -> Optional[DriValue]: """ Get the Dietary Reference Intake (DRI) for a nutrient, with its unit. Prefer this over ``get_dri``: the shipped data files express their values in different units, so a number on its own cannot be compared against a food. Args: nutrient_id: The nutrient ID or name. dri_type: The type of DRI to retrieve. gender: The gender to use for the DRI. age: The age to use for the DRI. Returns: The DRI and its unit, or None if there is none for this nutrient. """ nutrient_id = str(nutrient_id).lower() dri_data = _load_dri_data(dri_type) if nutrient_id not in dri_data: return None entry = dri_data[nutrient_id] unit = entry.get("unit", "g") gender_data = entry.get(gender.value, {}) for age_range, value in gender_data.items(): if age_range == _ANY_AGE: return DriValue(value, unit) if "-" in age_range: min_age, max_age = map(int, age_range.split("-")) if min_age <= age <= max_age: return DriValue(value, unit) elif age_range.endswith("+"): if age >= int(age_range[:-1]): return DriValue(value, unit) return None
[docs]def get_dri( nutrient_id: Union[str, int], dri_type: DriType = DriType.RDA, gender: Gender = Gender.MALE, age: int = 30 ) -> Optional[float]: """ Get the Dietary Reference Intake (DRI) for a nutrient. The value is expressed in the unit the underlying data file uses, which is not the same across DRI types: RDA values come in a natural unit per nutrient (iron in mg), UL values come in grams (iron 0.045). Use ``get_dri_value`` to get the unit along with the number. Args: nutrient_id: The nutrient ID or name. dri_type: The type of DRI to retrieve. gender: The gender to use for the DRI. age: The age to use for the DRI. Returns: The DRI value, or None if not found. """ dri = get_dri_value(nutrient_id, dri_type, gender, age) return dri.value if dri else None