# This file is part of Product Opener. # # Product Opener # Copyright (C) 2011-2026 Association Open Food Facts # Contact: contact@openfoodfacts.org # Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France # # Product Opener is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . =encoding UTF-8 =head1 NAME ProductOpener::Nutriscore - compute the Nutriscore grade of a food product =head1 SYNOPSIS C is used to compute the Nutriscore score and grade of a food product. use ProductOpener::Nutriscore qw/:all/; my $nutriscore_data_ref = { # Nutrients energy => 518, # in kJ sugars => 3, saturated_fat => 0.7, saturated_fat_ratio => 0.7 / 3 * 100, sodium => 0.61 / 2.5 * 1000, # in mg, sodium = salt divided by 2.5 fruits_vegetables_nuts_colza_walnut_olive_oils => 20, # in % fiber => 2.2, proteins => 6.7, # The Nutri-Score computation is different for beverages, waters, cheeses and fats is_beverage => 1, is_water => 0, is_cheese => 0, is_fat => 1, # for 2021 version is_fat_oil_nuts_seed => 1, # for 2023 version }; my ($nutriscore_score, $nutriscore_grade) = compute_nutriscore_score_and_grade( $nutriscore_data_ref ); print "Rounded value for sugars: " . $nutriscore_data_ref->{sugars_value} . "\n"; print "Points for sugars: " . $nutriscore_data_ref->{sugars_points}. "\n"; =head1 DESCRIPTION The modules implements the Nutri-Score computation as defined by Santé publique France. Input values for nutrients are rounded according to the Nutri-Score definition and added to the hash passed in parameter with the corresponding amount of positive or negative points. =cut package ProductOpener::Nutriscore; use ProductOpener::PerlStandards; use Exporter qw< import >; use Log::Any qw($log); BEGIN { use vars qw(@ISA @EXPORT_OK %EXPORT_TAGS); @EXPORT_OK = qw( %points_thresholds &compute_nutriscore_score_and_grade &compute_nutriscore_grade &get_value_with_one_less_negative_point_2023 &get_value_with_one_more_positive_point_2023 ); # symbols to export on request %EXPORT_TAGS = (all => [@EXPORT_OK]); } use vars @EXPORT_OK; use ProductOpener::Numbers qw/round_to_max_decimal_places/; =head1 FUNCTIONS Note: a significantly different Nutri-Score algorithm has been published in 2022 and 2023 (respectively for foods and beverages). The functions related to the previous algorithm are suffixed with _2021, and functions for the new algorithm are suffixed with _2023. We will keep both algorithms for a transition period, and we will be able to remove the 2021 algorithm and related functions after. =cut sub compute_nutriscore_score_and_grade ($nutriscore_data_ref, $version = "2021") { if ($version eq "2023") { return compute_nutriscore_score_and_grade_2023($nutriscore_data_ref); } return compute_nutriscore_score_and_grade_2021($nutriscore_data_ref); } # methods for an easy switch from version 2021 to version 2023. sub get_value_with_one_less_negative_point ($nutriscore_data_ref, $nutrient) { return get_value_with_one_less_negative_point_2023($nutriscore_data_ref, $nutrient); } sub get_value_with_one_more_positive_point ($nutriscore_data_ref, $nutrient) { return get_value_with_one_more_positive_point_2023($nutriscore_data_ref, $nutrient); } sub compute_nutriscore_grade ($nutrition_score, $is_beverage, $is_water, $version = "2021") { if ($version eq "2023") { return compute_nutriscore_grade_2023($nutrition_score, $is_beverage, $is_water); } return compute_nutriscore_grade_2021($nutrition_score, $is_beverage, $is_water); } # 2021 algorithm =head2 compute_nutriscore_score_and_grade_2021( $nutriscore_data_ref ) Computes the Nutri-Score score and grade of a food product, and also returns the details of the points for each nutrient. =head3 Arguments: $nutriscore_data_ref Hash reference used for both input and output: =head4 Input keys: data to compute Nutri-Score The hash must contain values for the following keys: - energy -> energy in kJ / 100g or 100ml - sugars -> sugars in g / 100g or 100ml - saturated_fat -> saturated fats in g / 100g or 100ml - saturated_fat_ratio -> saturated fat divided by fat * 100 (in %) - sodium -> sodium in mg / 100g or 100ml (if sodium is computed from salt, it needs to use a sodium = salt / 2.5 conversion factor) - fruits_vegetables_nuts_colza_walnut_olive_oils -> % of fruits, vegetables, nuts, and colza / walnut / olive oils - fiber -> fiber in g / 100g or 100ml - proteins -> proteins in g / 100g or 100ml The values will be rounded according to the Nutri-Score rules, they do not need to be rounded before being passed as arguments. If the product is a beverage, water, cheese, or fat, it must contain a positive value for the corresponding keys: - is_beverage - is_water - is_cheese - is_fat =head4 Output keys: details of the Nutri-Score computation Returned values: - [nutrient]_value -> rounded values for each nutrient according to the Nutri-Score rules - [nutrient]_points -> points for each nutrient - negative_points -> sum of negative nutrients points - positive_points -> sum of positive nutrients points The nutrients that are counted for the negative and positive points depend on the product type (if it is a beverage, cheese or fat) and on the values for some of the nutrients. =head3 Return values The function returns a list of 2 values: - Nutri-Score score from -15 to 40 - Corresponding Nutri-Score letter grade from a to e (in lowercase) The letter grade depends on the score and on whether the product is a beverage, or is a water. =cut sub compute_nutriscore_score_and_grade_2021 ($nutriscore_data_ref) { # We will pass a %point structure to get the details of the computation # so that it can be returned my %points = (); my $nutrition_score = compute_nutriscore_score_2021($nutriscore_data_ref); my $nutrition_grade = compute_nutriscore_grade_2021( $nutrition_score, $nutriscore_data_ref->{is_beverage}, $nutriscore_data_ref->{is_water} ); return ($nutrition_score, $nutrition_grade); } my %points_thresholds_2021 = ( # negative points energy => [335, 670, 1005, 1340, 1675, 2010, 2345, 2680, 3015, 3350], # kJ / 100g energy_beverages => [0, 30, 60, 90, 120, 150, 180, 210, 240, 270], # kJ /100g or 100ml sugars => [4.5, 9, 13.5, 18, 22.5, 27, 31, 36, 40, 45], # g / 100g sugars_beverages => [0, 1.5, 3, 4.5, 6, 7.5, 9, 10.5, 12, 13.5], # g / 100g or 100ml saturated_fat => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], # g / 100g saturated_fat_ratio => [10, 16, 22, 28, 34, 40, 46, 52, 58, 64], # % sodium => [90, 180, 270, 360, 450, 540, 630, 720, 810, 900], # mg / 100g # positive points fruits_vegetables_nuts_colza_walnut_olive_oils => [40, 60, 80, 80, 80], # % fruits_vegetables_nuts_colza_walnut_olive_oils_beverages => [40, 40, 60, 60, 80, 80, 80, 80, 80, 80], fiber => [0.9, 1.9, 2.8, 3.7, 4.7], # g / 100g - AOAC method proteins => [1.6, 3.2, 4.8, 6.4, 8.0] # g / 100g ); =head2 get_value_with_one_less_negative_point_2021 ( $nutriscore_data_ref, $nutrient ) For a given Nutri-Score nutrient value, return the highest smaller value that would result in less negative points. e.g. for a sugars value of 15 (which gives 3 points), return 13.5 (which gives 2 points). The value corresponds to the highest smaller threshold. Return undef is the input nutrient value already gives the minimum amount of points (0). =cut sub get_value_with_one_less_negative_point_2021 ($nutriscore_data_ref, $nutrient) { my $nutrient_threshold_id = $nutrient; if ( (defined $nutriscore_data_ref->{is_beverage}) and ($nutriscore_data_ref->{is_beverage}) and (defined $points_thresholds_2021{$nutrient_threshold_id . "_beverages"})) { $nutrient_threshold_id .= "_beverages"; } my $lower_threshold; foreach my $threshold (@{$points_thresholds_2021{$nutrient_threshold_id}}) { # The saturated fat ratio table uses the greater or equal sign instead of greater if ( (($nutrient eq "saturated_fat_ratio") and ($nutriscore_data_ref->{$nutrient . "_value"} >= $threshold)) or (($nutrient ne "saturated_fat_ratio") and ($nutriscore_data_ref->{$nutrient . "_value"} > $threshold))) { $lower_threshold = $threshold; } } return $lower_threshold; } =head2 get_value_with_one_more_positive_point_2021 ( $nutriscore_data_ref, $nutrient ) For a given Nutri-Score nutrient value, return the smallest higher value that would result in more positive points. e.g. for a proteins value of 2.0 (which gives 1 point), return 3.3 (which gives 2 points) The value corresponds to the smallest higher threshold + 1 increment so that it strictly greater than the threshold. Return undef is the input nutrient value already gives the maximum amount of points. =cut sub get_value_with_one_more_positive_point_2021 ($nutriscore_data_ref, $nutrient) { my $nutrient_threshold_id = $nutrient; if ( (defined $nutriscore_data_ref->{is_beverage}) and ($nutriscore_data_ref->{is_beverage}) and (defined $points_thresholds_2021{$nutrient_threshold_id . "_beverages"})) { $nutrient_threshold_id .= "_beverages"; } my $higher_threshold; foreach my $threshold (@{$points_thresholds_2021{$nutrient_threshold_id}}) { if ($nutriscore_data_ref->{$nutrient . "_value"} < $threshold) { $higher_threshold = $threshold; last; } } # The return value needs to be stricly greater than the threshold my $return_value = $higher_threshold; if ($return_value) { if ($nutrient eq "fruits_vegetables_nuts_colza_walnut_olive_oils") { $return_value += 1; } else { $return_value += 0.1; } } return $return_value; } sub compute_nutriscore_score_2021 ($nutriscore_data_ref) { # If the product is in fats and oils category, # the saturated fat points are replaced by the saturated fat / fat ratio points my $saturated_fat = "saturated_fat"; if ($nutriscore_data_ref->{is_fat}) { $saturated_fat = "saturated_fat_ratio"; } # The values must be rounded with one more digit than the thresolds. # Undefined values are counted as 0 (it can be the case in particular for waters that have different nutrients listed) # Note (2023/08/08): there used to be a rounding rule (e.g. in the Nutri-Score specification UPDATED 12/05/2020): # "Points are assigned for a given nutrient based on the content of the nutrient in question in the food, with a # rounded value corresponding to one additional number with regard to the point attribution threshold." # # This rule has been changed (e.g. in spec UPDATED 27/09/2022): # Points are assigned according to the values indicated on the mandatory nutritional declaration. # To determine number of decimals needed, we recommend the use of the European guidance document # with regards to the settings of tolerances for nutrient values for labels. For optional nutrients, in accordance # with Article 30-2 of the INCO regulation 1169/2011, such as fibre, rounding guidelines from the previous # document are also recommended. # Round with 1 digit after the comma for energy, saturated fat, saturated fat ratio, sodium and fruits foreach my $nutrient ("energy", $saturated_fat, "sodium", "fruits_vegetables_nuts_colza_walnut_olive_oils") { if (defined $nutriscore_data_ref->{$nutrient}) { $nutriscore_data_ref->{$nutrient . "_value"} = 0 + int($nutriscore_data_ref->{$nutrient} * 10 + 0.5) / 10; } else { $nutriscore_data_ref->{$nutrient . "_value"} = 0; } } # Round with 2 digits for sugars, fiber and proteins foreach my $nutrient ("sugars", "fiber", "proteins") { if (defined $nutriscore_data_ref->{$nutrient}) { $nutriscore_data_ref->{$nutrient . "_value"} = 0 + int($nutriscore_data_ref->{$nutrient} * 100 + 0.5) / 100; } else { $nutriscore_data_ref->{$nutrient . "_value"} = 0; } } # Special case for sugar: we need to round to 2 digits if we are close to a threshold defined with 1 digit (e.g. 4.5) # but if the threshold is defined with 0 digit (e.g. 9) we need to round with 1 digit. if ( (($nutriscore_data_ref->{"sugars_value"} - int($nutriscore_data_ref->{"sugars_value"})) > 0.9) or (($nutriscore_data_ref->{"sugars_value"} - int($nutriscore_data_ref->{"sugars_value"})) < 0.1)) { $nutriscore_data_ref->{"sugars_value"} = 0 + int(($nutriscore_data_ref->{"sugars"} // 0) * 10 + 0.5) / 10; } # Compute the negative and positive points foreach my $nutrient ("energy", "sugars", $saturated_fat, "sodium", "fruits_vegetables_nuts_colza_walnut_olive_oils", "fiber", "proteins") { my $nutrient_threshold_id = $nutrient; if ( (defined $nutriscore_data_ref->{is_beverage}) and ($nutriscore_data_ref->{is_beverage}) and (defined $points_thresholds_2021{$nutrient_threshold_id . "_beverages"})) { $nutrient_threshold_id .= "_beverages"; } $nutriscore_data_ref->{$nutrient . "_points"} = 0; foreach my $threshold (@{$points_thresholds_2021{$nutrient_threshold_id}}) { # The saturated fat ratio table uses the greater or equal sign instead of greater if ( (($nutrient eq "saturated_fat_ratio") and ($nutriscore_data_ref->{$nutrient . "_value"} >= $threshold)) or ( ($nutrient ne "saturated_fat_ratio") and ($nutriscore_data_ref->{$nutrient . "_value"} > $threshold)) ) { $nutriscore_data_ref->{$nutrient . "_points"}++; } } } # negative points $nutriscore_data_ref->{negative_points} = 0; foreach my $nutrient ("energy", "sugars", $saturated_fat, "sodium") { $nutriscore_data_ref->{negative_points} += $nutriscore_data_ref->{$nutrient . "_points"}; } # If the sum of negative points is greater or equal to 11 # and if the fruits points are less than the maximum (5 or 10 for beverages) # then proteins do not count # If the product is a cheese, always count the proteins points $nutriscore_data_ref->{positive_points} = 0; my @positive_nutrients = qw(fruits_vegetables_nuts_colza_walnut_olive_oils fiber); if ( ($nutriscore_data_ref->{negative_points} < 11) or ((defined $nutriscore_data_ref->{is_cheese}) and ($nutriscore_data_ref->{is_cheese})) or ( ((defined $nutriscore_data_ref->{is_beverage}) and ($nutriscore_data_ref->{is_beverage})) and ($nutriscore_data_ref->{fruits_vegetables_nuts_colza_walnut_olive_oils_points} == 10)) or ( ((not defined $nutriscore_data_ref->{is_beverage}) or (not $nutriscore_data_ref->{is_beverage})) and ($nutriscore_data_ref->{fruits_vegetables_nuts_colza_walnut_olive_oils_points} == 5)) ) { push @positive_nutrients, "proteins"; } foreach my $nutrient (@positive_nutrients) { $nutriscore_data_ref->{positive_points} += $nutriscore_data_ref->{$nutrient . "_points"}; } my $score = $nutriscore_data_ref->{negative_points} - $nutriscore_data_ref->{positive_points}; return $score; } sub compute_nutriscore_grade_2021 ($nutrition_score, $is_beverage, $is_water) { my $grade = ""; if (not defined $nutrition_score) { return ''; } if ($is_beverage) { if ($is_water) { $grade = 'a'; } elsif ($nutrition_score <= 1) { $grade = 'b'; } elsif ($nutrition_score <= 5) { $grade = 'c'; } elsif ($nutrition_score <= 9) { $grade = 'd'; } else { $grade = 'e'; } } else { if ($nutrition_score <= -1) { $grade = 'a'; } elsif ($nutrition_score <= 2) { $grade = 'b'; } elsif ($nutrition_score <= 10) { $grade = 'c'; } elsif ($nutrition_score <= 18) { $grade = 'd'; } else { $grade = 'e'; } } return $grade; } # 2022 / 2023 algorithm =head2 compute_nutriscore_score_and_grade_2023( $nutriscore_data_ref ) Computes the Nutri-Score score and grade of a food product, and also returns the details of the points for each nutrient. =head3 Arguments: $nutriscore_data_ref Hash reference used for both input and output: =head4 Input keys: data to compute Nutri-Score The hash must contain values for the following keys: - energy -> energy in kJ / 100g or 100ml - sugars -> sugars in g / 100g or 100ml - saturated_fat -> saturated fats in g / 100g or 100ml - saturated_fat_ratio -> saturated fat divided by fat * 100 (in %) - salt -> ??? - sodium -> sodium in mg / 100g or 100ml (if sodium is computed from salt, it needs to use a sodium = salt / 2.5 conversion factor - fruits_vegetables_legumes -> % of fruits, vegetables, nuts, and colza / walnut / olive oils - fiber -> fiber in g / 100g or 100ml - proteins -> proteins in g / 100g or 100ml The values will be rounded according to the Nutri-Score rules, they do not need to be rounded before being passed as arguments. If the product is a beverage, water, cheese, or fat, it must contain a positive value for the corresponding keys: - is_beverage - is_water - is_cheese - is_fat_oil_nuts_seeds - is_red_meat_product =head4 Output keys: details of the Nutri-Score computation Returned values: - [nutrient]_value -> rounded values for each nutrient according to the Nutri-Score rules - [nutrient]_points -> points for each nutrient - [nutrient]_points_max -> maximum number of points that can be allocated for each specific nutrient - negative_nutrients -> list of nutrients that are counted in negative points - negative_points -> sum of negative nutrients points - positive_nutrients -> list of nutrients that are counted in positive points - positive_points -> sum of positive nutrients points - count_proteins -> indicates if proteins are counted in the positive points - count_proteins_reason -> indicates why proteins are counted The nutrients that are counted for the negative and positive points depend on the product type (if it is a beverage, cheese or fat) and on the values for some of the nutrients. =head3 Return values The function returns a list of 2 values: - Nutri-Score score from -15 to 40 - Corresponding Nutri-Score letter grade from a to e (in lowercase) The letter grade depends on the score and on whether the product is a beverage, or is a water. =cut sub compute_nutriscore_score_and_grade_2023 ($nutriscore_data_ref) { # We will pass a %point structure to get the details of the computation # so that it can be returned my %points = (); my $nutrition_score = compute_nutriscore_score_2023($nutriscore_data_ref); my $nutrition_grade = compute_nutriscore_grade_2023( $nutrition_score, $nutriscore_data_ref->{is_beverage}, $nutriscore_data_ref->{is_water}, $nutriscore_data_ref->{is_fat_oil_nuts_seeds}, ); return ($nutrition_score, $nutrition_grade); } my %points_thresholds_2023 = ( # negative points energy => [335, 670, 1005, 1340, 1675, 2010, 2345, 2680, 3015, 3350], # kJ / 100g energy_beverages => [30, 90, 150, 210, 240, 270, 300, 330, 360, 390], # kJ /100g or 100ml sugars => [3.4, 6.8, 10, 14, 17, 20, 24, 27, 31, 34, 37, 41, 44, 48, 51], # g / 100g sugars_beverages => [0.5, 2, 3.5, 5, 6, 7, 8, 9, 10, 11], # g / 100g or 100ml saturated_fat => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], # g / 100g 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], # g / 100g # for fats energy_from_saturated_fat => [120, 240, 360, 480, 600, 720, 840, 960, 1080, 1200], # g / 100g saturated_fat_ratio => [10, 16, 22, 28, 34, 40, 46, 52, 58, 64], # % # positive points 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], # g / 100g - AOAC method proteins => [2.4, 4.8, 7.2, 9.6, 12, 14, 17], # g / 100g proteins_beverages => [1.2, 1.5, 1.8, 2.1, 2.4, 2.7, 3.0], # g / 100g ); =head2 get_value_with_one_less_negative_point_2023 ($is_beverage, $nutrient, $current_value) For a given Nutri-Score nutrient value, return the highest smaller value that would result in less negative points. e.g. for a sugars value of 15 (which gives 3 points), return 13.5 (which gives 2 points). The value corresponds to the highest smaller threshold. Return undef if the input nutrient value already gives the minimum amount of points (0). =cut sub get_value_with_one_less_negative_point_2023 ($is_beverage, $nutrient, $current_value) { my $nutrient_threshold_id = $nutrient; if ($is_beverage and (defined $points_thresholds_2023{$nutrient_threshold_id . "_beverages"})) { $nutrient_threshold_id .= "_beverages"; } my $lower_threshold; foreach my $threshold (@{$points_thresholds_2023{$nutrient_threshold_id}}) { if ($current_value > $threshold) { $lower_threshold = $threshold; } } return $lower_threshold; } =head2 get_value_with_one_more_positive_point_2023 ($is_beverage, $nutrient, $current_value) For a given Nutri-Score nutrient value, return the smallest higher value that would result in more positive points. e.g. for a proteins value of 2.0 (which gives 1 point), return 3.3 (which gives 2 points) The value corresponds to the smallest higher threshold + 1 increment so that it strictly greater than the threshold. Return undef if the input nutrient value already gives the maximum amount of points. =cut sub get_value_with_one_more_positive_point_2023 ($is_beverage, $nutrient, $current_value) { my $nutrient_threshold_id = $nutrient; if ($is_beverage and (defined $points_thresholds_2023{$nutrient_threshold_id . "_beverages"})) { $nutrient_threshold_id .= "_beverages"; } my $higher_threshold; foreach my $threshold (@{$points_thresholds_2023{$nutrient_threshold_id}}) { if ($current_value < $threshold) { $higher_threshold = $threshold; last; } } # The return value needs to be strictly greater than the threshold my $return_value = $higher_threshold; if ($return_value) { if ($nutrient eq "fruits_vegetables_legumes") { $return_value += 1; } else { $return_value += 0.1; } } return $return_value; } sub add_units_to_positive_and_negative_nutriscore_components ($nutriscore_data_ref) { foreach my $type (qw/positive negative/) { my $components_ref = $nutriscore_data_ref->{components}{$type}; foreach my $component_ref (@$components_ref) { # Compute the unit. my $unit; if ($component_ref->{id} eq 'non_nutritive_sweeteners') { $unit = 'number'; } elsif (($component_ref->{id} eq 'fruits_vegetables_legumes') or ($component_ref->{id} eq 'saturated_fat_ratio')) { $unit = '%'; } else { if ($component_ref->{id} =~ /^energy/) { $unit = 'kJ'; } else { $unit = 'g'; } } $component_ref->{unit} = $unit; } } return; } sub compute_nutriscore_score_2023 ($nutriscore_data_ref) { # The values must be rounded with one more digit than the thresholds. # Undefined values are counted as 0 (it can be the case in particular for waters that have different nutrients listed) # The Nutri-Score FAQ has been updated (UPDATED 27/09/2022 or earleier), # and there is now no rounding of nutrient values # Points are assigned according to the values indicated on the mandatory nutritional declaration. # To determine number of decimals needed, we recommend the use of the European guidance document # with regards to the settings of tolerances for nutrient values for labels. For optional nutrients, in accordance # with Article 30-2 of the INCO regulation 1169/2011, such as fibre, rounding guidelines from the previous # document are also recommended. # Compute the negative and positive points # If the product is in fats, oils, nuts and seeds category, # the energy points are replaced by the energy from saturates points, and # the saturated fat points are replaced by the saturated fat / fat ratio points my $energy = "energy"; my $saturated_fat = "saturated_fat"; if ($nutriscore_data_ref->{is_fat_oil_nuts_seeds}) { $saturated_fat = "saturated_fat_ratio"; $energy = "energy_from_saturated_fat"; } foreach my $nutrient ($energy, "sugars", $saturated_fat, "salt", "fruits_vegetables_legumes", "fiber", "proteins") { my $nutrient_threshold_id = $nutrient; if ( (defined $nutriscore_data_ref->{is_beverage}) and ($nutriscore_data_ref->{is_beverage}) and (defined $points_thresholds_2023{$nutrient_threshold_id . "_beverages"})) { $nutrient_threshold_id .= "_beverages"; } $nutriscore_data_ref->{$nutrient . "_points"} = 0; # If the nutrient value is defined, assign points according to the thresholds if (defined $nutriscore_data_ref->{$nutrient}) { foreach my $threshold (@{$points_thresholds_2023{$nutrient_threshold_id}}) { # The saturated fat ratio table uses the greater or equal sign instead of greater if ( (($nutrient eq "saturated_fat_ratio") and ($nutriscore_data_ref->{$nutrient} >= $threshold)) or (($nutrient ne "saturated_fat_ratio") and ($nutriscore_data_ref->{$nutrient} > $threshold))) { $nutriscore_data_ref->{$nutrient . "_points"}++; } } } # max number of squares in the gauge we will display to user for this nutrient $nutriscore_data_ref->{$nutrient . "_points_max"} = scalar @{$points_thresholds_2023{$nutrient_threshold_id}}; } # For red meat products, the number of maximum protein points is set at 2 points # Red meat products qualifying for this specific rule are products from beef, veal, swine and lamb, # though they include also game/venison, horse, donkey, goat, camel and kangaroo. if (($nutriscore_data_ref->{is_red_meat_product}) and ($nutriscore_data_ref->{proteins_points} > 2)) { $nutriscore_data_ref->{proteins_points} = 2; $nutriscore_data_ref->{proteins_points_limited_reason} = "red_meat_product"; } # Store the lists of negative and positive components retained for the Nutri-Score of the product $nutriscore_data_ref->{components} = { negative => [], positive => [], }; # negative points # We need to compute negative points first, as proteins may not be counted in the positive points # depending on the total of negative points my $negative_components = [$energy, "sugars", $saturated_fat, "salt"]; # Beverages with non-nutritive sweeteners have 4 extra negative points if ($nutriscore_data_ref->{is_beverage}) { push @$negative_components, "non_nutritive_sweeteners"; $nutriscore_data_ref->{non_nutritive_sweeteners_points_max} = 4; # If we don't have ingredients, assume the product does not contain non-nutritive sweeteners if ( (defined $nutriscore_data_ref->{non_nutritive_sweeteners}) and ($nutriscore_data_ref->{non_nutritive_sweeteners} > 0)) { $nutriscore_data_ref->{non_nutritive_sweeteners_points} = 4; } else { $nutriscore_data_ref->{non_nutritive_sweeteners_points} = 0; } } $nutriscore_data_ref->{negative_points} = 0; foreach my $nutrient (@$negative_components) { my $points = ($nutriscore_data_ref->{$nutrient . "_points"} || 0); my $points_max = $nutriscore_data_ref->{$nutrient . "_points_max"}; push @{$nutriscore_data_ref->{components}{negative}}, { id => $nutrient, value => round_to_max_decimal_places($nutriscore_data_ref->{$nutrient}, 2), points => $points, points_max => $points_max, }; $nutriscore_data_ref->{negative_points} += $points; $nutriscore_data_ref->{negative_points_max} += $points_max; } # positive points $nutriscore_data_ref->{positive_points} = 0; $nutriscore_data_ref->{positive_nutrients} = ["fiber", "fruits_vegetables_legumes"]; # positive points for proteins are counted in the following 3 cases: # - the product is a beverage # - the product is not in the fats, oils, nuts and seeds category # and the negative points are less than 11 or the product is a cheese # - the product is in the fats, oils, nuts and seeds category # and the negative points are less than 7 $nutriscore_data_ref->{count_proteins} = 0; if ($nutriscore_data_ref->{is_beverage}) { $nutriscore_data_ref->{count_proteins} = 1; $nutriscore_data_ref->{count_proteins_reason} = "beverage"; } elsif ($nutriscore_data_ref->{is_cheese}) { $nutriscore_data_ref->{count_proteins} = 1; $nutriscore_data_ref->{count_proteins_reason} = "cheese"; } else { if ($nutriscore_data_ref->{is_fat_oil_nuts_seeds}) { if ($nutriscore_data_ref->{negative_points} < 7) { $nutriscore_data_ref->{count_proteins} = 1; $nutriscore_data_ref->{count_proteins_reason} = "negative_points_less_than_7"; } else { $nutriscore_data_ref->{count_proteins_reason} = "negative_points_greater_than_or_equal_to_7"; } } else { if ($nutriscore_data_ref->{negative_points} < 11) { $nutriscore_data_ref->{count_proteins} = 1; $nutriscore_data_ref->{count_proteins_reason} = "negative_points_less_than_11"; } else { $nutriscore_data_ref->{count_proteins_reason} = "negative_points_greater_than_or_equal_to_11"; } } } if ($nutriscore_data_ref->{count_proteins}) { unshift @{$nutriscore_data_ref->{positive_nutrients}}, "proteins"; } foreach my $nutrient (@{$nutriscore_data_ref->{positive_nutrients}}) { my $points = ($nutriscore_data_ref->{$nutrient . "_points"} || 0); my $points_max = $nutriscore_data_ref->{$nutrient . "_points_max"}; push @{$nutriscore_data_ref->{components}{positive}}, { id => $nutrient, value => round_to_max_decimal_places($nutriscore_data_ref->{$nutrient}, 2), points => $points, points_max => $points_max, }; $nutriscore_data_ref->{positive_points} += $points; $nutriscore_data_ref->{positive_points_max} += $points_max; } # Add units for the retained positive and negative components # We add the units directly in the data instead of computing them at runtime when needed # in order to make the data more easily understood by itself add_units_to_positive_and_negative_nutriscore_components($nutriscore_data_ref); # Delete input nutrient values, keep only values inside the retained positive and negative components foreach my $key (keys %$nutriscore_data_ref) { if ($key =~ /^(energy|energy_from_saturated_fat|sugars|fat|saturated_fat|saturated_fat_ratio|salt|non_nutritive_sweeteners|fruits_vegetables_legumes|fiber|proteins)$/ ) { delete $nutriscore_data_ref->{$key}; delete $nutriscore_data_ref->{$key . "_points"}; delete $nutriscore_data_ref->{$key . "_points_max"}; } } my $score = $nutriscore_data_ref->{negative_points} - $nutriscore_data_ref->{positive_points}; return $score; } sub compute_nutriscore_grade_2023 ($nutrition_score, $is_beverage, $is_water, $is_fat_oil_nuts_seeds) { my $grade = ""; if (not defined $nutrition_score) { return ''; } # Beverages if ($is_beverage) { if ($is_water) { $grade = 'a'; } elsif ($nutrition_score <= 2) { $grade = 'b'; } elsif ($nutrition_score <= 6) { $grade = 'c'; } elsif ($nutrition_score <= 9) { $grade = 'd'; } else { $grade = 'e'; } } # Fats, oils, nuts and seeds elsif ($is_fat_oil_nuts_seeds) { if ($nutrition_score <= -6) { $grade = 'a'; } elsif ($nutrition_score <= 2) { $grade = 'b'; } elsif ($nutrition_score <= 10) { $grade = 'c'; } elsif ($nutrition_score <= 18) { $grade = 'd'; } else { $grade = 'e'; } } # Other foods else { if ($nutrition_score <= -0) { $grade = 'a'; } elsif ($nutrition_score <= 2) { $grade = 'b'; } elsif ($nutrition_score <= 10) { $grade = 'c'; } elsif ($nutrition_score <= 18) { $grade = 'd'; } else { $grade = 'e'; } } return $grade; } %points_thresholds = %points_thresholds_2021; 1;