| """Nutri-Score 2023 (v2) — a faithful port of Open Food Facts' reference implementation. |
| |
| Why this exists |
| --------------- |
| 58% of US products in Open Food Facts carry `nutriscore_grade = 'unknown'`. Nutri-Score |
| is a European scheme and OFF often cannot assign it to US items, usually because the |
| category needed to pick the algorithm variant is missing. Ranking "healthier alternatives" |
| on OFF's grade alone would therefore silently discard most of the US catalog. |
| |
| So NutriWeb computes the grade itself wherever the input nutrients exist. We keep OFF's |
| own grade alongside ours and never merge the two — the UI always states which is which. |
| |
| Provenance |
| ---------- |
| Ported from `lib/ProductOpener/Nutriscore.pm` (`compute_nutriscore_score_2023`) and the |
| category predicates in `lib/ProductOpener/Food.pm`, from openfoodfacts-server @ main. |
| Threshold tables are reproduced verbatim; the irregular steps (e.g. beverage energy |
| jumping 30/90/150/210 then by 30) are intentional and match upstream. |
| |
| Reference: Nutri-Score 2023 main algorithm update, Santé publique France. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass, field |
|
|
| |
| |
| |
| |
| |
|
|
| THRESHOLDS: dict[str, list[float]] = { |
| |
| "energy": [335, 670, 1005, 1340, 1675, 2010, 2345, 2680, 3015, 3350], |
| "energy_beverages": [30, 90, 150, 210, 240, 270, 300, 330, 360, 390], |
| "sugars": [3.4, 6.8, 10, 14, 17, 20, 24, 27, 31, 34, 37, 41, 44, 48, 51], |
| "sugars_beverages": [0.5, 2, 3.5, 5, 6, 7, 8, 9, 10, 11], |
| "saturated_fat": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], |
| "salt": [0.2, 0.4, 0.6, 0.8, 1, 1.2, 1.4, 1.6, 1.8, 2, |
| 2.2, 2.4, 2.6, 2.8, 3, 3.2, 3.4, 3.6, 3.8, 4], |
| |
| "energy_from_saturated_fat": [120, 240, 360, 480, 600, 720, 840, 960, 1080, 1200], |
| "saturated_fat_ratio": [10, 16, 22, 28, 34, 40, 46, 52, 58, 64], |
| |
| |
| "fruits_vegetables_legumes": [40, 60, 80, 80, 80], |
| "fruits_vegetables_legumes_beverages": [40, 40, 60, 60, 80, 80], |
| "fiber": [3.0, 4.1, 5.2, 6.3, 7.4], |
| "proteins": [2.4, 4.8, 7.2, 9.6, 12, 14, 17], |
| "proteins_beverages": [1.2, 1.5, 1.8, 2.1, 2.4, 2.7, 3.0], |
| } |
|
|
| |
| KJ_PER_G_FAT = 37.0 |
|
|
| |
| |
| |
| |
| |
| |
|
|
| BEVERAGE_ROOTS = {"en:beverages", "en:beverage-preparations"} |
| NOT_BEVERAGES_2023 = {"en:meal-replacement", "en:soups"} |
| ALSO_BEVERAGES_2023 = { |
| "en:milks", "en:plant-based-milk-alternatives", "en:dairy-drinks", |
| "en:plant-based-beverages", "en:tea-based-beverages", "en:iced-teas", |
| "en:herbal-tea-beverages", "en:coffee-beverages", "en:coffee-drinks", |
| "en:coffees", "en:herbal-teas", "en:teas", |
| } |
| FAT_OIL_NUTS_SEEDS_ROOTS = {"en:fats", "en:creams", "en:seeds"} |
|
|
| |
| |
| EXEMPTED = { |
| "en:alcoholic-beverages", "en:baby-foods", "en:baby-milks", "en:chewing-gum", |
| "en:food-additives", "en:dietary-supplements", "en:meal-replacements", |
| "en:salts", "en:spices", "en:sugar-substitutes", "en:vinegars", |
| "en:non-food-products", |
| } |
| NOT_EXEMPTED = { |
| "en:tea-based-beverages", "en:iced-teas", "en:herbal-tea-beverages", |
| "en:coffee-beverages", "en:coffee-drinks", |
| } |
|
|
|
|
| @dataclass |
| class CategoryFlags: |
| """Which algorithm variant applies, derived from `categories_tags`.""" |
|
|
| is_beverage: bool = False |
| is_water: bool = False |
| is_cheese: bool = False |
| is_fat_oil_nuts_seeds: bool = False |
| is_red_meat_product: bool = False |
|
|
|
|
| def category_flags( |
| categories_tags: list[str] | None, |
| fat_oil_nuts_seeds_ids: frozenset[str] = frozenset(), |
| red_meat_ids: frozenset[str] = frozenset(), |
| ) -> CategoryFlags: |
| """Derive the Nutri-Score variant flags from a product's category tags. |
| |
| `fat_oil_nuts_seeds_ids` and `red_meat_ids` come from |
| `pipeline/taxonomy.py`, which resolves the WCO Harmonized-System properties |
| that upstream reads via taxonomy inheritance. Passing empty sets falls back |
| to the explicit root categories only. |
| """ |
| tags = set(categories_tags or ()) |
|
|
| is_beverage = bool(tags & BEVERAGE_ROOTS) and not (tags & NOT_BEVERAGES_2023) |
| if tags & ALSO_BEVERAGES_2023: |
| is_beverage = True |
|
|
| is_water = "en:spring-waters" in tags and not ( |
| tags & {"en:flavored-waters", "en:flavoured-waters"} |
| ) |
| is_cheese = "en:cheeses" in tags and "fr:fromages-blancs" not in tags |
|
|
| if "en:chestnuts" in tags: |
| is_fat = False |
| else: |
| is_fat = bool(tags & FAT_OIL_NUTS_SEEDS_ROOTS) or bool(tags & fat_oil_nuts_seeds_ids) |
|
|
| return CategoryFlags( |
| is_beverage=is_beverage, |
| is_water=is_water, |
| is_cheese=is_cheese, |
| is_fat_oil_nuts_seeds=is_fat, |
| is_red_meat_product=bool(tags & red_meat_ids), |
| ) |
|
|
|
|
| def is_exempt(categories_tags: list[str] | None) -> bool: |
| """True if OFF excludes this product from Nutri-Score (salt, spices, alcohol...).""" |
| tags = set(categories_tags or ()) |
| return bool(tags & EXEMPTED) and not (tags & NOT_EXEMPTED) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _points(value: float | None, table_key: str) -> int: |
| """One point per threshold exceeded. Missing values score 0, per upstream.""" |
| if value is None: |
| return 0 |
| table = THRESHOLDS[table_key] |
| |
| if table_key == "saturated_fat_ratio": |
| return sum(1 for t in table if value >= t) |
| return sum(1 for t in table if value > t) |
|
|
|
|
| def _table(nutrient: str, is_beverage: bool) -> str: |
| """Beverages use their own table where one exists.""" |
| key = f"{nutrient}_beverages" |
| return key if is_beverage and key in THRESHOLDS else nutrient |
|
|
|
|
| @dataclass |
| class NutriScoreResult: |
| score: int |
| grade: str |
| negative_points: int |
| positive_points: int |
| counted_proteins: bool |
| detail: dict[str, int] = field(default_factory=dict) |
|
|
|
|
| def compute( |
| *, |
| energy_kj: float | None, |
| sugars: float | None, |
| saturated_fat: float | None, |
| salt: float | None, |
| fiber: float | None, |
| proteins: float | None, |
| fruits_vegetables_legumes: float | None, |
| fat: float | None = None, |
| has_non_nutritive_sweeteners: bool = False, |
| flags: CategoryFlags | None = None, |
| ) -> NutriScoreResult | None: |
| """Compute the 2023 Nutri-Score. All nutrients are per 100 g / 100 ml. |
| |
| Returns None when there is too little data to score honestly — see |
| `has_sufficient_data`. Absent *optional* nutrients (fiber, fruit content) |
| score 0 points, matching upstream, which is a conservative penalty. |
| """ |
| flags = flags or CategoryFlags() |
|
|
| if not has_sufficient_data( |
| energy_kj=energy_kj, sugars=sugars, saturated_fat=saturated_fat, |
| salt=salt, proteins=proteins, |
| ): |
| return None |
|
|
| |
| |
| if flags.is_fat_oil_nuts_seeds: |
| energy_value = (saturated_fat or 0.0) * KJ_PER_G_FAT |
| energy_key = "energy_from_saturated_fat" |
| satfat_value = ( |
| (saturated_fat / fat * 100.0) if fat not in (None, 0) and saturated_fat is not None |
| else None |
| ) |
| satfat_key = "saturated_fat_ratio" |
| else: |
| energy_value, energy_key = energy_kj, "energy" |
| satfat_value, satfat_key = saturated_fat, "saturated_fat" |
|
|
| bev = flags.is_beverage |
| detail = { |
| "energy": _points(energy_value, _table(energy_key, bev)), |
| "sugars": _points(sugars, _table("sugars", bev)), |
| "saturated_fat": _points(satfat_value, satfat_key), |
| "salt": _points(salt, "salt"), |
| "fiber": _points(fiber, "fiber"), |
| "proteins": _points(proteins, _table("proteins", bev)), |
| "fruits_vegetables_legumes": _points( |
| fruits_vegetables_legumes, _table("fruits_vegetables_legumes", bev) |
| ), |
| } |
|
|
| |
| |
| if flags.is_red_meat_product: |
| detail["proteins"] = min(detail["proteins"], 2) |
|
|
| negative = detail["energy"] + detail["sugars"] + detail["saturated_fat"] + detail["salt"] |
| |
| if bev: |
| detail["non_nutritive_sweeteners"] = 4 if has_non_nutritive_sweeteners else 0 |
| negative += detail["non_nutritive_sweeteners"] |
|
|
| |
| |
| if bev or flags.is_cheese: |
| count_proteins = True |
| elif flags.is_fat_oil_nuts_seeds: |
| count_proteins = negative < 7 |
| else: |
| count_proteins = negative < 11 |
|
|
| positive = detail["fiber"] + detail["fruits_vegetables_legumes"] |
| if count_proteins: |
| positive += detail["proteins"] |
|
|
| score = negative - positive |
| return NutriScoreResult( |
| score=score, |
| grade=grade_for(score, flags), |
| negative_points=negative, |
| positive_points=positive, |
| counted_proteins=count_proteins, |
| detail=detail, |
| ) |
|
|
|
|
| def grade_for(score: int, flags: CategoryFlags) -> str: |
| """Map a numeric score to a letter. Cutoffs differ by variant.""" |
| if flags.is_beverage: |
| if flags.is_water: |
| return "a" |
| if score <= 2: |
| return "b" |
| if score <= 6: |
| return "c" |
| if score <= 9: |
| return "d" |
| return "e" |
| if flags.is_fat_oil_nuts_seeds: |
| if score <= -6: |
| return "a" |
| if score <= 2: |
| return "b" |
| if score <= 10: |
| return "c" |
| if score <= 18: |
| return "d" |
| return "e" |
| if score <= 0: |
| return "a" |
| if score <= 2: |
| return "b" |
| if score <= 10: |
| return "c" |
| if score <= 18: |
| return "d" |
| return "e" |
|
|
|
|
| def has_sufficient_data( |
| *, |
| energy_kj: float | None, |
| sugars: float | None, |
| saturated_fat: float | None, |
| salt: float | None, |
| proteins: float | None, |
| ) -> bool: |
| """Whether we have enough to compute a grade we're willing to show. |
| |
| Upstream treats every missing nutrient as 0 points, which would hand a |
| product with no nutrition data whatsoever a clean 'A'. We require the four |
| negative-side drivers plus protein to be present, so a sparse record is |
| reported as unscored rather than as healthy. |
| """ |
| return all(v is not None for v in (energy_kj, sugars, saturated_fat, salt, proteins)) |
|
|