# 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 utf8 =head1 NAME ProductOpener::EnvironmentalScore - compute the EnvironmentalScore environmental grade of a food product =head1 SYNOPSIS C is used to compute the EnvironmentalScore environmental grade of a food product. =head1 DESCRIPTION The modules implements the Environmental-Score computation as defined by a collective that Open Food Facts is part of. It is based on the French AgriBalyse V3 database that contains environmental impact values for 2500 food product categories. AgriBalyse provides Life Cycle Analysis (LCA) values for food products categories, and some adjustments to the score are made for actual specific products using data about labels, origins of ingredients, packagings etc. =cut package ProductOpener::EnvironmentalScore; use ProductOpener::PerlStandards; use Exporter qw< import >; use Log::Any qw($log); BEGIN { use vars qw(@ISA @EXPORT_OK %EXPORT_TAGS); @EXPORT_OK = qw( &load_agribalyse_data &load_environmental_score_data &compute_environmental_score &localize_environmental_score %agribalyse ); # symbols to export on request %EXPORT_TAGS = (all => [@EXPORT_OK]); } use vars @EXPORT_OK; use ProductOpener::Config qw/:all/; use ProductOpener::Store qw/get_string_id_for_lang/; use ProductOpener::Tags qw/:all/; use ProductOpener::ProductsTags qw/:all/; use ProductOpener::Packaging qw/:all/; use ProductOpener::Ingredients qw/has_specific_ingredient_property/; use Storable qw(dclone freeze); use Text::CSV(); use Math::Round; use Data::DeepAccess qw(deep_get deep_exists); my $agribalyse_data_loaded = 0; my $environmental_score_data_loaded = 0; %agribalyse = (); =head1 VARIABLES =head2 %environmental_score_countries_enabled List of countries for which we are going to compute and display the Environmental-Score. The list is different from %environmental_score_countries that can contain more countries for which we have some data to compute the Environmental-Score (e.g. distances). 2021-10-28: we will now enable Environmental-Score for all available countries, so this list will be overrode when we load the Environmental-Score data. =cut my @environmental_score_countries_enabled_sorted = qw(be ch de es fr ie it lu nl uk); my %environmental_score_countries_enabled; foreach my $country (@environmental_score_countries_enabled_sorted) { $environmental_score_countries_enabled{$country} = 1; } =head1 FUNCTIONS =head2 load_agribalyse_data() Loads the AgriBalyse database. =cut sub load_agribalyse_data() { my $agribalyse_details_by_step_csv_file = $data_root . "/external-data/environmental_score/agribalyse/AGRIBALYSE_vf.csv.3"; my $rows_ref = []; my $encoding = "UTF-8"; open(my $version_file, "<:encoding($encoding)", $data_root . '/external-data/environmental_score/agribalyse/AGRIBALYSE_version.txt') or die($!); chomp(my $agribalyse_version = <$version_file>); close($version_file); $log->debug("opening agribalyse CSV file", {file => $agribalyse_details_by_step_csv_file, version => $agribalyse_version}) if $log->is_debug(); my $csv_options_ref = {binary => 1, sep_char => ","}; # should set binary attribute. my $csv = Text::CSV->new($csv_options_ref) or die("Cannot use CSV: " . Text::CSV->error_diag()); if (open(my $io, "<:encoding($encoding)", $agribalyse_details_by_step_csv_file)) { my $row_ref; # Skip 4 first lines $csv->getline($io); $csv->getline($io); $csv->getline($io); $csv->getline($io); while ($row_ref = $csv->getline($io)) { next if (not defined $row_ref->[0]) or ($row_ref->[0] eq ""); $agribalyse{$row_ref->[0]} = { code => $row_ref->[0], # Agribalyse code = Ciqual code name_fr => $row_ref->[4], # Nom du Produit en Français name_en => $row_ref->[5], # LCI Name dqr => $row_ref->[6], # DQR (data quality rating) # warning: the AGB file has a hidden H column ef_agriculture => $row_ref->[8] + 0, # Agriculture ef_processing => $row_ref->[9] + 0, # Transformation ef_packaging => $row_ref->[10] + 0, # Emballage ef_transportation => $row_ref->[11] + 0, # Transport ef_distribution => $row_ref->[12] + 0, # Supermarché et distribution ef_consumption => $row_ref->[13] + 0, # Consommation ef_total => $row_ref->[14] + 0, # Total co2_agriculture => $row_ref->[15] + 0, # Agriculture co2_processing => $row_ref->[16] + 0, # Transformation co2_packaging => $row_ref->[17] + 0, # Emballage co2_transportation => $row_ref->[18] + 0, # Transport co2_distribution => $row_ref->[19] + 0, # Supermarché et distribution co2_consumption => $row_ref->[20] + 0, # Consommation co2_total => $row_ref->[21] + 0, # Total version => $agribalyse_version }; } $log->info("loaded agribalyse data", {number_of_items => scalar keys %agribalyse}) if $log->is_info(); } else { die("Could not open agribalyse CSV $agribalyse_details_by_step_csv_file: $!"); } $agribalyse_data_loaded = 1; return; } my %environmental_score_data = (origins => {},); my %environmental_score_countries = (); =head2 load_environmental_score_data_origins_of_ingredients_distances ( $product_ref ) Loads the transport score matrix used to compute the Environmental-Score "transport" bonus (see https://docs.score-environnemental.com/methodologie/produit/transport). Despite its name, distances.csv does not hold raw distances but the pre-computed transport scores (/100) from the methodology: each score reflects the CO2 impact of shipping goods from an origin country to a destination country, derived from the distance, the modal mix (road / rail / sea) and ADEME Base Carbone emission factors. France scores 100 (local sourcing) and distant origins score 0. See https://wiki.openfoodfacts.org/Eco-score_transport_-_en for how it was built. The file is a matrix: rows = origin countries (columns 0..2 = ISO code, EN name, FR name), columns 3+ = destination country codes (the countries where the Eco-score is enabled, e.g. fr, be, de, uk ...). "gb" is normalised to "uk". Populates the global %environmental_score_data{origins} hash: $environmental_score_data{origins}{$origin_id}{ name_en => "...", name_fr => "...", transportation_score_$cc => , # one per destination country $cc transportation_score_world => 0, # "world" = unspecified destination => no bonus } Also builds %environmental_score_countries (the set of destination countries) and aliases en:unknown / en:world / en:european-union-and-non-european-union to a 0-score origin (unknown origin => no transport bonus). =cut sub load_environmental_score_data_origins_of_ingredients_distances() { # distances.csv is a pre-computed transport-score matrix (/100) from the # score-environnemental transport methodology: # - rows = origin country (cols 0..2 = ISO code, EN name, FR name) # - cols = destination country code (the countries where the Eco-score is enabled) # Each cell is the CO2-impact-based transport score (100 = France/local, 0 = far away). # France=100 rewards local sourcing; distant origins get 0. my $errors = 0; my $csv_options_ref = {binary => 1, sep_char => ","}; # should set binary attribute. my $csv = Text::CSV->new($csv_options_ref) or die("Cannot use CSV: " . Text::CSV->error_diag()); my $csv_file = $data_root . "/external-data/environmental_score/data/distances.csv"; my $encoding = "UTF-8"; $log->debug("opening environmental_score origins distances CSV file", {file => $csv_file}) if $log->is_debug(); if (open(my $io, "<:encoding($encoding)", $csv_file)) { my @countries = (); # Headers: ISO Country Code, Country (english), Country (french), AD, AL, AT, AX, BA, BE, BG, ... my $header_row_ref = $csv->getline($io); # Build the list of destination countries from columns 3+ (one per destination country code) for (my $i = 3; $i < (scalar @{$header_row_ref}); $i++) { $countries[$i] = lc($header_row_ref->[$i]); # Normalize ISO code: "gb" in the CSV -> "uk" used internally if ($countries[$i] eq 'gb') { $countries[$i] = 'uk'; } $environmental_score_countries{$countries[$i]} = 1; # en:unknown origin: no transport bonus for any destination (score 0) $environmental_score_data{origins}{"en:unknown"}{"transportation_score_" . $countries[$i]} = 0; } # "world" = destination not specified -> no transport bonus (score 0) $environmental_score_data{origins}{"en:unknown"}{"transportation_score_world"} = 0; my @environmental_score_countries_sorted = sort keys %environmental_score_countries; # Enable the Eco-score for all countries present in the matrix %environmental_score_countries_enabled = %environmental_score_countries; @environmental_score_countries_enabled_sorted = @environmental_score_countries_sorted; # Alias generic origin buckets to en:unknown (0 score => no transport bonus) $environmental_score_data{origins}{"en:world"} = $environmental_score_data{origins}{"en:unknown"}; $environmental_score_data{origins}{"en:european-union-and-non-european-union"} = $environmental_score_data{origins}{"en:unknown"}; $log->debug( "environmental_score origins distances CSV file - countries header row", {environmental_score_countries_sorted => \@environmental_score_countries_sorted} ) if $log->is_debug(); my $row_ref; while ($row_ref = $csv->getline($io)) { my $origin = $row_ref->[0]; next if ((not defined $origin) or ($origin eq "")); my $origin_id_exists_in_taxonomy; my $origin_id = canonicalize_taxonomy_tag("en", "origins", $origin, \$origin_id_exists_in_taxonomy); if (not $origin_id_exists_in_taxonomy) { $log->error( "environmental_score origin does not exist in taxonomy", {origin => $origin, origin_id => $origin_id} ) if $log->is_error(); $errors++; } $environmental_score_data{origins}{$origin_id} = { name_en => $row_ref->[1], name_fr => $row_ref->[2], }; # One transportation_score_ per destination country (columns 3+) # Empty cells are treated as 0 (no data -> no bonus) for (my $i = 3; $i < (scalar @{$row_ref}); $i++) { my $value = $row_ref->[$i]; if ($value eq "") { $value = 0; } $environmental_score_data{origins}{$origin_id}{"transportation_score_" . $countries[$i]} = $value; } # "world" destination (unspecified) -> score 0 $environmental_score_data{origins}{$origin_id}{"transportation_score_world"} = 0; } if ($errors) { #die("$errors unrecognized origins in CSV $csv_file"); } } else { die("Could not open environmental_score origins distances CSV $csv_file: $!"); } return; } =head2 load_environmental_score_data_origins_of_ingredients( $product_ref ) Loads, for each origin country, the two per-origin components of the Environmental-Score "origins of ingredients" adjustment: - epi_score: Environmental Performance Index of the country (a separate bonus/malus reflecting the country's environmental policy). - transportation_score_fr: the transport score (/100) for France as defined in the original French Environmental-Score specification. It first calls load_environmental_score_data_origins_of_ingredients_distances() to load the full transport-score matrix for all destination countries, then reads fr_countries.csv (column 1 = EPI score, column 2 = transport score for France). The France value from fr_countries.csv OVERRIDES the recomputed value from distances.csv, because the distances.csv scores were recomputed in a slightly different way and differ slightly from the original specification. Resulting structure per origin: $environmental_score_data{origins}{$origin_id}{ epi_score => , transportation_score_$cc => , # from distances.csv (all countries) transportation_score_fr => , # overridden from fr_countries.csv } Origins are matched against the taxonomy; special normalisation handles entries like "Congo [DRC]" vs "Congo [Republic]", "Macedonia [FYROM]" and leading articles ("La Guyane" ...). en:unknown / en:world / en:unspecified / en:european-union-and-non-european-union are aliased to a 0-score origin. At compute time the per-origin score is weighted by the ingredient percentage (score = Σ a_i * p_i) and turned into a bonus of up to 15 points (bonus = x * 0.15, i.e. the /100 weighted score divided by 6.66). =cut sub load_environmental_score_data_origins_of_ingredients() { # First load the full transport-score matrix (all destination countries) from distances.csv, # then overlay EPI scores + the original France transport score from fr_countries.csv below. load_environmental_score_data_origins_of_ingredients_distances(); my $errors = 0; my $csv_options_ref = {binary => 1, sep_char => ","}; # should set binary attribute. my $csv = Text::CSV->new($csv_options_ref) or die("Cannot use CSV: " . Text::CSV->error_diag()); my $csv_file = $data_root . "/external-data/environmental_score/data/fr_countries.csv"; my $encoding = "UTF-8"; $log->debug("opening environmental_score origins CSV file", {file => $csv_file}) if $log->is_debug(); if (open(my $io, "<:encoding($encoding)", $csv_file)) { # headers: Pays "Score Politique environnementale" Score Transport - France "Score Transport - Belgique" "Score Transport - Allemagne" "Score Transport - Irlande" Score Transport - Italie "Score Transport - Luxembourg" "Score Transport - Pays-Bas" "Score Transport - Espagne" "Score Transport - Suisse" # col 0 = Pays, col 1 = EPI score, col 2 = Score Transport - France my $header_row_ref = $csv->getline($io); # en:unknown: no EPI, no transport bonus (score 0) $environmental_score_data{origins}{"en:unknown"}{epi_score} = 0; # Alias generic origin buckets to en:unknown (0 score) $environmental_score_data{origins}{"en:world"} = $environmental_score_data{origins}{"en:unknown"}; $environmental_score_data{origins}{"en:european-union-and-non-european-union"} = $environmental_score_data{origins}{"en:unknown"}; my $row_ref; while ($row_ref = $csv->getline($io)) { my $origin = $row_ref->[0]; next if ((not defined $origin) or ($origin eq "")); my $origin_id_exists_in_taxonomy; my $origin_id = canonicalize_taxonomy_tag("fr", "origins", $origin, \$origin_id_exists_in_taxonomy); if (not $origin_id_exists_in_taxonomy) { # Environmental-Score entries like "Macedonia [FYROM]": remove the [..] part # but keep it in the first try, as it is needed to distinguish "Congo [DRC]" and "Congo [Republic]" if ($origin =~ /^(.*)\[(.*)\]/) { $origin_id = canonicalize_taxonomy_tag("fr", "origins", $1, \$origin_id_exists_in_taxonomy); if (not $origin_id_exists_in_taxonomy) { $origin_id = canonicalize_taxonomy_tag("fr", "origins", $2, \$origin_id_exists_in_taxonomy); } } } # La Guyane Française -> Guyane Française if (not $origin_id_exists_in_taxonomy) { if ($origin =~ /^(la|les|l'|le)\s?(.*)$/i) { $origin_id = canonicalize_taxonomy_tag("fr", "origins", $2, \$origin_id_exists_in_taxonomy); } } if (not $origin_id_exists_in_taxonomy) { $log->error( "environmental_score origin does not exist in taxonomy", {origin => $origin, origin_id => $origin_id} ) if $log->is_error(); $errors++; } $environmental_score_data{origins}{$origin_id}{epi_score} = $row_ref->[1]; # Override the France transport score from distances.csv with the original # French Environmental-Score value (col 2), because the distances.csv scores # were recomputed differently and differ slightly from the spec. $environmental_score_data{origins}{$origin_id}{"transportation_score_fr"} = $row_ref->[2]; } if ($errors) { #die("$errors unrecognized origins in CSV $csv_file"); } # en:unspecified is also an alias of en:unknown (0 score) $environmental_score_data{origins}{"en:unspecified"} = $environmental_score_data{origins}{"en:unknown"}; } else { die("Could not open environmental_score origins CSV $csv_file: $!"); } return; } =head2 load_environmental_score_data_packaging( $product_ref ) Loads the packaging data needed to compute the Environmental-Score packaging adjustment (a malus): a score per packaging material and a ratio per packaging shape/format, as published in the score-environnemental documentation (https://docs.score-environnemental.com/methodologie/produit/emballages). 1. fr_packaging_materials.csv (Matériaux, Score) -> %environmental_score_data{packaging_materials} Each material has a score. The Eco-score defines some entries that are in fact a shape + material combination (e.g. "Bouteille PET" = PET bottle); these are stored under composite keys "$material_id.$shape_id" (e.g. en:plastic.bottle) so they can be matched at compute time. The score is also attached to the packaging_materials taxonomy as the inherited property "environmental_score_score:en" so it can be resolved through taxonomy parents via get_inherited_property(). 2. fr_packaging_shapes.csv (Format, Ratio) -> %environmental_score_data{packaging_shapes} Each shape has a ratio used to weight the material score by the packaging format. The ratio is attached to the packaging_shapes taxonomy as the inherited property "environmental_score_ratio:en". Extra assignments map specific material/shape or shape keys to a source entry that already has a score (e.g. opaque pet bottle <- colored pet bottle, rPET bottle <- transparent rPET bottle, plastic <- other plastics, can <- drink-can, card <- backing, label <- sheet, spout <- bottle-cap, elo-pak <- tetra-pak). =cut sub load_environmental_score_data_packaging() { my $errors = 0; my $csv_options_ref = {binary => 1, sep_char => ","}; # should set binary attribute. my $csv = Text::CSV->new($csv_options_ref) or die("Cannot use CSV: " . Text::CSV->error_diag()); # Packaging materials # Eco_score_Calculateur.csv is not up to date anymore, instead use a copy of the table in # https://docs.score-environnemental.com/methodologie/produit/emballages/score-par-materiaux # my $csv_file = $data_root . "/external-data/environmental_score/data/Eco_score_Calculateur.csv.11"; my $csv_file = $data_root . "/external-data/environmental_score/data/fr_packaging_materials.csv"; my $encoding = "UTF-8"; $environmental_score_data{packaging_materials} = {}; # We will also add the data as a property to the packaging_materials taxonomy so that we can use the get_inherited_property function defined $properties{"packaging_materials"} or $properties{"packaging_materials"} = {}; $log->debug("opening environmental_score materials CSV file", {file => $csv_file}) if $log->is_debug(); if (open(my $io, "<:encoding($encoding)", $csv_file)) { my $row_ref; # Skip first line $csv->getline($io); # headers: Matériaux,Score while ($row_ref = $csv->getline($io)) { my $material = $row_ref->[0]; next if ((not defined $material) or ($material eq "")); # Special cases: normalize material labels to match the packaging_materials taxonomy $material =~ s/\(100\%\)//; $material =~ s/bisourcé/biosourcé/ig; $material =~ s/Aluminium \(léger < 60mm\)/Aluminium léger/ig; $material =~ s/Aluminium \(lourd > 60mm\)/Aluminium lourd/ig; $material =~ s/Bouteille PET coloré ou opaque/Bouteille PET coloré/ig; # The Eco-score specifies some materials that are in fact a combination of shape + material # e.g. "Bouteille PET" (PET bottle) is a separate entry from PET, with different scores. # We split out the shape and create composite material.shape keys # (e.g. en:plastic.bottle) used at compute time to look up the score. my $shape; if ($material =~ /^bouteille /i) { $shape = "en:bottle"; $material = $'; } if ($material =~ /^bouchon /i) { $shape = "en:bottle-cap"; $material = $'; } my $material_id_exists_in_taxonomy; my $material_id = canonicalize_taxonomy_tag("fr", "packaging_materials", $material, \$material_id_exists_in_taxonomy); if (not $material_id_exists_in_taxonomy) { $log->error( "environmental_score material does not exist in taxonomy", {material => $material, material_id => $material_id} ) if $log->is_error(); $errors++; } # Combine material + shape into a composite key (e.g. en:polyethylene-terephthalate.en:bottle) if (defined $shape) { $material_id = $material_id . "." . $shape; } # Store the score keyed by material_id (or material.shape composite) $environmental_score_data{packaging_materials}{$material_id} = { name_fr => $row_ref->[0], # Matériaux score => $row_ref->[1], # Score }; # Also expose the score as a taxonomy property so get_inherited_property() # can resolve it for sub-materials not explicitly listed in the CSV. (defined $properties{"packaging_materials"}{$material_id}) or $properties{"packaging_materials"}{$material_id} = {}; $properties{"packaging_materials"}{$material_id}{"environmental_score_score:en"} = $environmental_score_data{packaging_materials}{$material_id}{score}; } if ($errors) { die("$errors unrecognized materials in CSV $csv_file"); } # Extra assignments: map target material/shape keys (not in the CSV) to a source # entry that already has a score. This fills in gaps where the Eco-score data # does not have a dedicated row but should reuse a related material's score. # e.g. opaque-pet bottle <- colored-pet bottle (same recycling difficulty). # Use English names for source / target shapes and materials # they will be canonicalized with the taxonomies my @assignments = ( { target_shape => "bottle", target_material => "opaque pet", source_shape => "bottle", source_material => "colored pet" }, { target_shape => "bottle", target_material => "polyethylene terephthalate", source_shape => "bottle", source_material => "colored pet" }, # Assign transparent rPET bottle score to rPET { target_shape => "bottle", target_material => "rpet", source_shape => "bottle", source_material => "transparent rpet" }, { target_material => "plastic", source_material => "other plastics" }, ); foreach my $assignment_ref (@assignments) { # We canonicalize the names given in the assignments, as the taxonomies can change over time, including the canonical names my $target_material = canonicalize_taxonomy_tag_or_die("en", "packaging_materials", $assignment_ref->{target_material},); my $source_material = canonicalize_taxonomy_tag_or_die("en", "packaging_materials", $assignment_ref->{source_material},); my $target = $target_material; my $source = $source_material; if (defined $assignment_ref->{target_shape}) { my $target_shape = canonicalize_taxonomy_tag_or_die("en", "packaging_shapes", $assignment_ref->{target_shape},); my $source_shape = canonicalize_taxonomy_tag_or_die("en", "packaging_shapes", $assignment_ref->{source_shape},); $target .= '.' . $target_shape; $source .= '.' . $source_shape; } if (defined $environmental_score_data{packaging_materials}{$source}) { $environmental_score_data{packaging_materials}{$target} = $environmental_score_data{packaging_materials}{$source}; $properties{packaging_materials}{$target}{"environmental_score_score:en"} = $environmental_score_data{packaging_materials}{$source}{"score"}; } else { die("source of assignement $source does not have Environmental-Score data"); } } } else { die("Could not open environmental_score materials CSV $csv_file: $!"); } $log->debug("environmental_score packaging_materials data", {packaging_materials => $environmental_score_data{packaging_materials}}) if $log->is_debug(); # Packaging shapes / formats # fr_packaging_shapes.csv: Format, Ratio # The ratio weights the material score by the packaging format (larger / more # material-intensive shapes reduce the score more). $csv_file = $data_root . "/external-data/environmental_score/data/Eco_score_Calculateur.csv.12"; $csv_file = $data_root . "/external-data/environmental_score/data/fr_packaging_shapes.csv"; $encoding = "UTF-8"; $environmental_score_data{packaging_shapes} = {}; # We will also add the data as a property to the packaging_shapes taxonomy so that we can use the get_inherited_property function defined $properties{"packaging_shapes"} or $properties{"packaging_shapes"} = {}; $log->debug("opening environmental_score shapes CSV file", {file => $csv_file}) if $log->is_debug(); if (open(my $io, "<:encoding($encoding)", $csv_file)) { my $row_ref; # Skip first line $csv->getline($io); # headers: Format,Ratio while ($row_ref = $csv->getline($io)) { my $shape = $row_ref->[0]; # skip empty lines and comments next if ((not defined $shape) or ($shape eq "")) or ($shape =~ /^#/); # Special cases # skip ondulated cardboard (should be a material, not a shape) next if ($shape eq "Carton ondulé"); my $shape_id_exists_in_taxonomy; my $shape_id = canonicalize_taxonomy_tag("fr", "packaging_shapes", $shape, \$shape_id_exists_in_taxonomy); # "conserve" lives in the preservation taxonomy, not packaging_shapes; # remap it to en:can so it can be scored as a packaging shape. if ($shape_id =~ /^fr:conserve/i) { $shape_id = "en:can"; $shape_id_exists_in_taxonomy = 1; } if (not $shape_id_exists_in_taxonomy) { $log->error("environmental_score shape does not exist in taxonomy", {shape => $shape, shape_id => $shape_id}) if $log->is_error(); $errors++; } # Store the ratio keyed by shape_id $environmental_score_data{packaging_shapes}{$shape_id} = { name_fr => $row_ref->[0], # Format ratio => $row_ref->[1], # Ratio }; # French decimal: comma -> dot (0,2 => 0.2) $environmental_score_data{packaging_shapes}{$shape_id}{ratio} =~ s/,/\./; # Expose the ratio as a taxonomy property for get_inherited_property() resolution (defined $properties{"packaging_shapes"}{$shape_id}) or $properties{"packaging_shapes"}{$shape_id} = {}; $properties{"packaging_shapes"}{$shape_id}{"environmental_score_ratio:en"} = $environmental_score_data{packaging_shapes}{$shape_id}{ratio}; } if ($errors) { die("$errors unrecognized shapes in CSV $csv_file"); } # Extra assignments: alias shapes not in the CSV to a source shape that # already has a ratio, so they inherit the same packaging malus weight. $environmental_score_data{packaging_shapes}{"en:can"} = $environmental_score_data{packaging_shapes}{"en:drink-can"}; $properties{"packaging_shapes"}{"en:can"}{"environmental_score_ratio:en"} = $environmental_score_data{packaging_shapes}{"en:can"}{ratio}; $environmental_score_data{packaging_shapes}{"en:card"} = $environmental_score_data{packaging_shapes}{"en:backing"}; $properties{"packaging_shapes"}{"en:card"}{"environmental_score_ratio:en"} = $environmental_score_data{packaging_shapes}{"en:backing"}{ratio}; $environmental_score_data{packaging_shapes}{"en:label"} = $environmental_score_data{packaging_shapes}{"en:sheet"}; $properties{"packaging_shapes"}{"en:label"}{"environmental_score_ratio:en"} = $environmental_score_data{packaging_shapes}{"en:sheet"}{ratio}; $environmental_score_data{packaging_shapes}{"en:spout"} = $environmental_score_data{packaging_shapes}{"en:bottle-cap"}; $properties{"packaging_shapes"}{"en:spout"}{"environmental_score_ratio:en"} = $environmental_score_data{packaging_shapes}{"en:bottle-cap"}{ratio}; $environmental_score_data{packaging_shapes}{"xx:elo-pak"} = $environmental_score_data{packaging_shapes}{"en:tetra-pak"}; $properties{"packaging_shapes"}{"xx:elo-pak"}{"environmental_score_ratio:en"} = $environmental_score_data{packaging_shapes}{"en:tetra-pak"}{ratio}; } else { die("Could not open environmental_score shapes CSV $csv_file: $!"); } $log->debug("environmental_score packaging_shapes data", {packaging_materials => $environmental_score_data{packaging_shapes}}) if $log->is_debug(); return; } =head2 load_environmental_score_data( $product_ref ) Loads data needed to compute the Environmental-Score. =cut sub load_environmental_score_data() { load_environmental_score_data_origins_of_ingredients(); load_environmental_score_data_packaging(); $environmental_score_data_loaded = 1; return; } =head2 compute_environmental_score( $product_ref ) C computes the Environmental-Score of a food product, and stores the details of the computation. =head3 Arguments =head4 Product reference $product_ref =head3 Return values The Environmental-Score score and computations details are stored in the product reference passed as input parameter. Returned values: - environmental_score_score : numeric Environmental-Score value - environmental_score_grade : corresponding A to E grade - environmental_score_data : Environmental-Score computation details =cut sub compute_environmental_score ($product_ref) { # Some test cases do not load the Environmental-Score data (e.g. food.t) as they don't test the Environmental-Score # but compute_environmental_score() is still called by specific_processes_for_food_product($product_ref); # So we check if the data is loaded, and do not compute the Environmental-Score if not loaded if (not($environmental_score_data_loaded and $agribalyse_data_loaded)) { $log->warn("Environmental-Score data not loaded, cannot compute Environmental-Score") if $log->is_warn(); return; } my $old_environmental_score_data = $product_ref->{environmental_score_data}; my $old_agribalyse = $old_environmental_score_data->{agribalyse}; my $old_environmental_score_grade = $old_environmental_score_data->{grade}; my $old_environmental_score_score = $old_environmental_score_data->{score}; my $old_previous_data = $old_environmental_score_data->{previous_data}; delete $product_ref->{environmental_score_grade}; delete $product_ref->{environmental_score_score}; delete $product_ref->{downgraded}; $product_ref->{environmental_score_data} = {adjustments => {},}; remove_tag($product_ref, "misc", "en:environmental-score-computed"); remove_tag($product_ref, "misc", "en:environmental-score-missing-data-warning"); remove_tag($product_ref, "misc", "en:environmental-score-missing-data-no-packagings"); foreach my $missing (qw(labels origins packagings)) { remove_tag($product_ref, "misc", "en:environmental-score-missing-data-" . $missing); } remove_tag($product_ref, "misc", "en:environmental-score-no-missing-data"); remove_tag($product_ref, "misc", "en:environmental-score-not-applicable"); remove_tag($product_ref, "misc", "en:environmental-score-changed"); remove_tag($product_ref, "misc", "en:environmental-score-grade-changed"); remove_tag($product_ref, "misc", "en:environmental-score-score-above-100"); remove_tag($product_ref, "misc", "en:environmental-score-downgraded"); # Check if we have extended environmental_score_data from the impact estimator # Remove any misc "en:environmental-score-extended-data-version-[..]" tags if (defined $product_ref->{misc_tags}) { foreach my $tag (@{$product_ref->{misc_tags}}) { if ($tag =~ /^en:environmental-score-extended-data/) { remove_tag($product_ref, "misc", $tag); } } } # 2024/12 ecoscore fields were renamed to environmental_score fields # remove ecoscore fields delete $product_ref->{ecoscore_grade}; delete $product_ref->{ecoscore_score}; delete $product_ref->{ecoscore_data}; remove_tag($product_ref, "misc", "en:ecoscore-computed"); remove_tag($product_ref, "misc", "en:ecoscore-missing-data-warning"); remove_tag($product_ref, "misc", "en:ecoscore-missing-data-no-packagings"); foreach my $missing (qw(labels origins packagings)) { remove_tag($product_ref, "misc", "en:ecoscore-missing-data-" . $missing); } remove_tag($product_ref, "misc", "en:ecoscore-no-missing-data"); remove_tag($product_ref, "misc", "en:ecoscore-not-applicable"); remove_tag($product_ref, "misc", "en:ecoscore-changed"); remove_tag($product_ref, "misc", "en:ecoscore-grade-changed"); remove_tag($product_ref, "misc", "en:ecoscore-score-above-100"); remove_tag($product_ref, "misc", "en:ecoscore-downgraded"); # Special case for waters and sodas: disable the Environmental-Score my @categories_without_environmental_score = ("en:waters", "en:sodas", "en:energy-drinks", "en:fresh-vegetables", "en:fresh-fruits"); my $category_without_environmental_score; foreach my $category (@categories_without_environmental_score) { if (has_tag($product_ref, 'categories', $category)) { $category_without_environmental_score = $category; last; } } # Always compute the bonuses and maluses, even for categories that don't have Environmental-Score # (e.g. sodas, spring water) compute_environmental_score_production_system_adjustment($product_ref); compute_environmental_score_threatened_species_adjustment($product_ref); compute_environmental_score_origins_of_ingredients_adjustment($product_ref); compute_environmental_score_packaging_adjustment($product_ref); if ($category_without_environmental_score) { $product_ref->{environmental_score_data}{environmental_score_not_applicable_for_category} = $category_without_environmental_score; $product_ref->{environmental_score_data}{status} = "unknown"; $product_ref->{environmental_score_tags} = ["not-applicable"]; $product_ref->{environmental_score_grade} = "not-applicable"; $product_ref->{environmental_score_data}{grade} = "not-applicable"; add_tag($product_ref, "misc", "en:environmental-score-not-applicable"); add_tag($product_ref, "misc", "en:environmental-score-not-computed"); } else { # Compute the LCA Environmental-Score based on AgriBalyse compute_environmental_score_agribalyse($product_ref); # Compute the final Environmental-Score and assign the A to E grade # We need an AgriBalyse category match to compute the Environmental-Score # Note: the score can be 0 if (defined $product_ref->{environmental_score_data}{agribalyse}{score}) { $product_ref->{environmental_score_data}{status} = "known"; my $missing_data_warning; $product_ref->{environmental_score_data}{scores} = {}; $product_ref->{environmental_score_data}{grades} = {}; # Compute the Environmental-Score for all countries + world (with 0 for the transportation bonus) foreach my $cc (@environmental_score_countries_enabled_sorted, "world") { $product_ref->{environmental_score_data}{"scores"}{$cc} = $product_ref->{environmental_score_data}{agribalyse}{score}; $log->debug("compute_environmental_score - agribalyse score", {cc => $cc, agribalyse_score => $product_ref->{environmental_score_data}{agribalyse}{score}}) if $log->is_debug(); # Add adjustments (maluses or bonuses) my $bonus = 0; foreach my $adjustment (keys %{$product_ref->{environmental_score_data}{adjustments}}) { my $value; if ( (defined $cc) and (defined $product_ref->{environmental_score_data}{adjustments}{$adjustment}{"values"}) and (defined $product_ref->{environmental_score_data}{adjustments}{$adjustment}{"values"}{$cc})) { $value = $product_ref->{environmental_score_data}{adjustments}{$adjustment}{"values"}{$cc}; } elsif (defined $product_ref->{environmental_score_data}{adjustments}{$adjustment}{"value"}) { $value = $product_ref->{environmental_score_data}{adjustments}{$adjustment}{"value"}; } if (defined $value) { $bonus += $value; $log->debug( "compute_environmental_score - add adjustment", { adjustment => $adjustment, value => $value } ) if $log->is_debug(); } if (defined $product_ref->{environmental_score_data}{adjustments}{$adjustment}{warning}) { $missing_data_warning = 1; } } # The sum of the bonuses is capped at 25 if ($bonus > 25) { $bonus = 25; } $product_ref->{environmental_score_data}{"scores"}{$cc} += $bonus; # Assign A+ to F grade # SI(AO3>=90;"A+";SI(AO3>=75;"A";SI(AO3>=60;"B";SI(AO3>=45;"C";SI(AO3>=30;"D";SI(AO3>=15;"E";"F"))))));"") if ($product_ref->{environmental_score_data}{"scores"}{$cc} >= 90) { $product_ref->{environmental_score_data}{"grades"}{$cc} = "a-plus"; } elsif ($product_ref->{environmental_score_data}{"scores"}{$cc} >= 75) { $product_ref->{environmental_score_data}{"grades"}{$cc} = "a"; } elsif ($product_ref->{environmental_score_data}{"scores"}{$cc} >= 60) { $product_ref->{environmental_score_data}{"grades"}{$cc} = "b"; } elsif ($product_ref->{environmental_score_data}{"scores"}{$cc} >= 45) { $product_ref->{environmental_score_data}{"grades"}{$cc} = "c"; } elsif ($product_ref->{environmental_score_data}{"scores"}{$cc} >= 30) { $product_ref->{environmental_score_data}{"grades"}{$cc} = "d"; } elsif ($product_ref->{environmental_score_data}{"scores"}{$cc} >= 15) { $product_ref->{environmental_score_data}{"grades"}{$cc} = "e"; } else { $product_ref->{environmental_score_data}{"grades"}{$cc} = "f"; } $log->debug( "compute_environmental_score - final score and grade", { score => $product_ref->{environmental_score_data}{"scores"}{$cc}, grade => $product_ref->{environmental_score_data}{"grades"}{$cc} } ) if $log->is_debug(); } # The following values correspond to the Environmental-Score for France. # at run-time, they may be changed to the values for a specific country # after localize_environmental_score() is called # The environmental_score_tags used for the /environmental_score facet and the environmental_score_score used for sorting by Environmental-Score # can only have 1 value. # Unfortunately there is a MongoDB index limit and we cannot create a different set of field # for each country. $product_ref->{environmental_score_data}{"score"} = $product_ref->{environmental_score_data}{"scores"}{"fr"}; $product_ref->{environmental_score_data}{"grade"} = $product_ref->{environmental_score_data}{"grades"}{"fr"}; $product_ref->{"environmental_score_score"} = $product_ref->{environmental_score_data}{"scores"}{"fr"}; $product_ref->{"environmental_score_grade"} = $product_ref->{environmental_score_data}{"grades"}{"fr"}; $product_ref->{"environmental_score_tags"} = [$product_ref->{environmental_score_grade}]; if ($product_ref->{environmental_score_data}{"downgraded"}) { add_tag($product_ref, "misc", "en:environmental-score-downgraded"); } if ( ( $product_ref->{environmental_score_data}{scores}{fr_orig} // $product_ref->{environmental_score_data}{scores}{fr} ) > 100 ) { add_tag($product_ref, "misc", "en:environmental-score-score-above-100"); } if ($missing_data_warning) { $product_ref->{environmental_score_data}{missing_data_warning} = 1; add_tag($product_ref, "misc", "en:environmental-score-missing-data-warning"); # add facets for missing data foreach my $missing (qw(labels origins packagings)) { if (deep_exists($product_ref, "environmental_score_data", "missing", $missing)) { add_tag($product_ref, "misc", "en:environmental-score-missing-data-" . $missing); } } # environmental-score-missing-data-packagings will also be triggered when we have some packaging data that is not complete # e.g. we have a shape like "bottle" but no associated material # also add a facet when we have no packaging information at all my $packaging_warning = deep_get($product_ref, qw(environmental_score_data adjustments packaging warning)); if ((defined $packaging_warning) and ($packaging_warning eq "packaging_data_missing")) { add_tag($product_ref, "misc", "en:environmental-score-missing-data-no-packagings"); } } add_tag($product_ref, "misc", "en:environmental-score-computed"); } else { # No AgriBalyse category match $product_ref->{environmental_score_data}{missing_agribalyse_match_warning} = 1; $product_ref->{environmental_score_data}{status} = "unknown"; $product_ref->{environmental_score_tags} = ["unknown"]; $product_ref->{environmental_score_grade} = "unknown"; $product_ref->{environmental_score_data}{grade} = "unknown"; add_tag($product_ref, "misc", "en:environmental-score-not-computed"); } } # Track if environmental_score has changed through different Agribalyse versions # Don't overwrite previous_data from before. This should be manually cleared # before each version upgrade if (defined $old_previous_data) { $product_ref->{environmental_score_data}{previous_data} = $old_previous_data; $old_environmental_score_grade = $old_previous_data->{grade}; $old_environmental_score_score = $old_previous_data->{score}; } if (defined $old_environmental_score_score || defined $product_ref->{environmental_score_score}) { my $old_score = (defined $old_environmental_score_score) ? $old_environmental_score_score : 0; my $new_score = (defined $product_ref->{environmental_score_score}) ? $product_ref->{environmental_score_score} : 0; if (!defined $old_environmental_score_score || $old_score != $new_score) { if (!defined $old_previous_data && defined $old_agribalyse) { $product_ref->{environmental_score_data}{previous_data} = { grade => $old_environmental_score_grade, score => $old_environmental_score_score, agribalyse => $old_agribalyse }; } add_tag($product_ref, "misc", "en:environmental-score-changed"); if (!defined $old_environmental_score_grade || $old_environmental_score_grade ne $product_ref->{environmental_score_grade}) { add_tag($product_ref, "misc", "en:environmental-score-grade-changed"); } } } # Before 2025, the Environmental Score was called the Eco-Score. # to ease the transition, we copy environmental_score_tags to eco_score_tags # so that queries to MongoDB and off-query do not have to change if (defined $product_ref->{environmental_score_tags}) { $product_ref->{ecoscore_tags} = $product_ref->{environmental_score_tags}; } return; } =head2 compute_environmental_score_agribalyse ( $product_ref ) C computes the Life Cycle Analysis (LCA) part of the Environmental-Score, based on the French AgriBalyse database. =head3 Arguments =head4 Product reference $product_ref =head3 Return values The LCA score and computations details are stored in the product reference passed as input parameter. Returned values: $product_ref->{agribalyse} hash with: - $product_ref->{environmental_score_data}{missing} hash with: - categories if the product does not have a category - agb_category if the product does not have an Agribalyse match or proxy match for at least one of its categories. =cut sub compute_environmental_score_agribalyse ($product_ref) { $product_ref->{environmental_score_data}{agribalyse} = {}; # Check the input data # Check if one of the product categories has an Agribalyse match or proxy match my $agb; # match or proxy match my $agb_match; my $agb_proxy_match; if ((defined $product_ref->{categories_tags}) and (scalar @{$product_ref->{categories_tags}} > 0)) { # Start with most specific category first foreach my $category (reverse @{$product_ref->{categories_tags}}) { $agb_match = get_property("categories", $category, "agribalyse_food_code:en"); last if $agb_match; if (not defined $agb_proxy_match) { $agb_proxy_match = get_property("categories", $category, "agribalyse_proxy_food_code:en"); } } if ($agb_match) { $product_ref->{environmental_score_data}{agribalyse} = $agribalyse{$agb_match}; $product_ref->{environmental_score_data}{agribalyse}{agribalyse_food_code} = $agb_match; $agb = $agb_match; } elsif ($agb_proxy_match) { $product_ref->{environmental_score_data}{agribalyse} = $agribalyse{$agb_proxy_match}; $product_ref->{environmental_score_data}{agribalyse}{agribalyse_proxy_food_code} = $agb_proxy_match; $agb = $agb_proxy_match; } else { defined $product_ref->{environmental_score_data}{missing} or $product_ref->{environmental_score_data}{missing} = {}; $product_ref->{environmental_score_data}{missing}{agb_category} = 1; } } else { defined $product_ref->{environmental_score_data}{missing} or $product_ref->{environmental_score_data}{missing} = {}; $product_ref->{environmental_score_data}{missing}{categories} = 1; } # Compute the Environmental-Score on a 0 to 100 scale if ($agb) { if (not defined $agribalyse{$agb}{ef_total}) { $log->error("compute_environmental_score - ef_total missing for category", {agb => $agb, agribalyse => $agribalyse{$agb}}) if $log->is_error(); } else { # Formula to transform the Environmental Footprint single score to a 0 to 100 scale # Note: EF score are for mPt / kg in Agribalyse, we need it in micro points per 100g # Milk is considered to be a beverage if (has_tag($product_ref, 'categories', 'en:beverages') or (has_tag($product_ref, 'categories', 'en:milks'))) { # Beverages case: score = -36*\ln(x+1)+150score=− 36 * ln(x+1) + 150 $product_ref->{environmental_score_data}{agribalyse}{is_beverage} = 1; $product_ref->{environmental_score_data}{agribalyse}{score} = 0 + round(-36 * log($agribalyse{$agb}{ef_total} * (1000 / 10) + 1) + 150); } else { # 2021-02-17: new updated formula: 100-(20 * ln(10*x+1))/ln(2+ 1/(100*x*x*x*x)) - with x in MPt / kg. $product_ref->{environmental_score_data}{agribalyse}{is_beverage} = 0; $product_ref->{environmental_score_data}{agribalyse}{score} = 0 + round( 100 - 20 * log(10 * $agribalyse{$agb}{ef_total} + 1) / log( 2 + 1 / ( 100 * $agribalyse{$agb}{ef_total} * $agribalyse{$agb}{ef_total} * $agribalyse{$agb}{ef_total} * $agribalyse{$agb}{ef_total} ) ) ); } if ($product_ref->{environmental_score_data}{agribalyse}{score} < 0) { $product_ref->{environmental_score_data}{agribalyse}{score} = 0; } elsif ($product_ref->{environmental_score_data}{agribalyse}{score} > 100) { $product_ref->{environmental_score_data}{agribalyse}{score} = 100; } } } else { $product_ref->{environmental_score_data}{agribalyse}{warning} = "missing_agribalyse_match"; } return; } =head2 compute_environmental_score_production_system_adjustment ( $product_ref ) Computes an adjustment (bonus or malus) based on production system of the product (e.g. organic). =head3 Arguments =head4 Product reference $product_ref =head3 Return values The adjustment value and computations details are stored in the product reference passed as input parameter. Returned values: $product_ref->{adjustments}{production_system} hash with: - $product_ref->{environmental_score_data}{missing} hash with: =head3 Notes This function tests the presence of specific labels and categories that should not be renamed. They are listed in the t/environmental_score.t test file so that the test fail if they are renamed. The labels are listed in the Environmental-Score documentation: https://docs.score-environnemental.com/methodologie/produit/label =cut my @production_system_labels = ( ["fr:nature-et-progres", 20], ["fr:bio-coherence", 20], ["en:demeter", 20], ["fr:ab-agriculture-biologique", 15], ["en:eu-organic", 15], # Environmental-Score documentation: "Techniques de pêche durables : ligne et hameçon, pêche à la canne, casier, pêche à pied." ["en:sustainable-fishing-method", 15], ["fr:haute-valeur-environnementale", 10], ["en:utz-certified", 10], ["en:rainforest-alliance", 10], ["en:fairtrade-international", 10], ["fr:bleu-blanc-coeur", 10], ["fr:label-rouge", 10], ["en:sustainable-seafood-msc", 10], ["en:responsible-aquaculture-asc", 10], ); my $production_system_labels_initialized = 0; sub init_production_system_labels () { return if $production_system_labels_initialized; # Canonicalize the labels foreach my $label_ref (@production_system_labels) { # Canonicalize the label ids in case the normalized id changed $label_ref->[0] = canonicalize_taxonomy_tag("en", "labels", $label_ref->[0]); } $production_system_labels_initialized = 1; return; } sub compute_environmental_score_production_system_adjustment ($product_ref) { init_production_system_labels(); $product_ref->{environmental_score_data}{adjustments}{production_system} = {value => 0, labels => []}; foreach my $label_ref (@production_system_labels) { my ($label, $value) = @$label_ref; if ( has_tag($product_ref, "labels", $label) # Label Rouge labels is counted only for beef, veal and lamb and ( ($label ne "fr:label-rouge") or (has_tag($product_ref, "categories", "en:beef")) or (has_tag($product_ref, "categories", "en:veal-meat")) or (has_tag($product_ref, "categories", "en:lamb-meat"))) ) { push @{$product_ref->{environmental_score_data}{adjustments}{production_system}{labels}}, $label; # Don't count the points for en:eu-organic if we already have fr:ab-agriculture-biologique # and for ASC if we already have MSC if ( ( ($label ne "en:eu-organic") or not(has_tag($product_ref, "labels", "fr:ab-agriculture-biologique")) ) and (($label ne "en:sustainable-seafood-msc") or not(has_tag($product_ref, "labels", "en:sustainable-fishing-method"))) and ( ($label ne "en:responsible-aquaculture-asc") or not(has_tag($product_ref, "labels", "en:sustainable-seafood-msc") or has_tag($product_ref, "labels", "en:sustainable-fishing-method")) ) ) { $product_ref->{environmental_score_data}{adjustments}{production_system}{value} += $value; } } if ($product_ref->{environmental_score_data}{adjustments}{production_system}{value} > 20) { $product_ref->{environmental_score_data}{adjustments}{production_system}{value} = 20; } } # No labels warning if ($product_ref->{environmental_score_data}{adjustments}{production_system}{value} == 0) { $product_ref->{environmental_score_data}{adjustments}{production_system}{warning} = "no_label"; defined $product_ref->{environmental_score_data}{missing} or $product_ref->{environmental_score_data}{missing} = {}; $product_ref->{environmental_score_data}{missing}{labels} = 1; } return; } =head2 compute_environmental_score_threatened_species_adjustment ( $product_ref ) Computes an adjustment (malus) if the ingredients are harmful to threatened species. e.g. threatened fishes, or ingredients like palm oil that threaten the habitat of threatened species. =head3 Arguments =head4 Product reference $product_ref =head3 Return values The adjustment value and computations details are stored in the product reference passed as input parameter. Returned values: $product_ref->{adjustments}{threatened_species} hash with: - value: malus (-10 for palm oil) - ingredient: the id of the ingredient responsible for the malus =cut sub compute_environmental_score_threatened_species_adjustment ($product_ref) { $product_ref->{environmental_score_data}{adjustments}{threatened_species} = {}; # Products that contain palm oil that is not certified RSPO if ((has_tag($product_ref, "ingredients_analysis", "en:palm-oil")) and not(has_tag($product_ref, "labels", "en:roundtable-on-sustainable-palm-oil"))) { $product_ref->{environmental_score_data}{adjustments}{threatened_species}{value} = -10; $product_ref->{environmental_score_data}{adjustments}{threatened_species}{ingredient} = "en:palm-oil"; } # No ingredients warning if ((not defined $product_ref->{ingredients}) or (scalar @{$product_ref->{ingredients}} == 0)) { $product_ref->{environmental_score_data}{adjustments}{threatened_species}{warning} = "ingredients_missing"; defined $product_ref->{environmental_score_data}{missing} or $product_ref->{environmental_score_data}{missing} = {}; $product_ref->{environmental_score_data}{missing}{ingredients} = 1; } return; } =head2 aggregate_origins_of_ingredients ( $default_origins_ref, $aggregated_origins_ref, $ingredient_ref ) Computes adjustments(bonus or malus for transportation + EPI / Environmental Performance Index) according to the countries of origin of the ingredients. =head3 Arguments =head4 Default origins reference: $default_origins_ref Array of origins specified in the origins field, that we will use for ingredients that do not have a specific origin. =head4 Aggregated origins reference $aggregated_origins_ref Data structure to which we will add the percentages for the ingredient specified in $ingredient_ref =head4 Ingredient reference $ingredient_ref Ingredient reference that may contains an ingredients structure for sub-ingredients. =head3 Return values The percentages are stored in $aggregated_origins_ref =cut sub aggregate_origins_of_ingredients ($default_origins_ref, $aggregated_origins_ref, $ingredients_ref) { # The ingredients array contains sub-ingredients in nested ingredients foreach my $ingredient_ref (@$ingredients_ref) { my $ingredient_origins_ref; # If the ingredient has specified origins, use them if (defined $ingredient_ref->{origins}) { $ingredient_origins_ref = [split(/,/, $ingredient_ref->{origins})]; $log->debug("aggregate_origins_of_ingredients - ingredient has specified origins", {ingredient_id => $ingredient_ref->{id}, ingredient_origins_ref => $ingredient_origins_ref}) if $log->is_debug(); } # Otherwise, if the ingredient has sub ingredients, use the origins of the sub ingredients elsif (defined $ingredient_ref->{ingredients}) { $log->debug("aggregate_origins_of_ingredients - ingredient has subingredients", {ingredient_id => $ingredient_ref->{id}}) if $log->is_debug(); aggregate_origins_of_ingredients($default_origins_ref, $aggregated_origins_ref, $ingredient_ref->{ingredients}); } # Else use default origins else { $ingredient_origins_ref = $default_origins_ref; $log->debug("aggregate_origins_of_ingredients - use default origins", {ingredient_id => $ingredient_ref->{id}, ingredient_origins_ref => $ingredient_origins_ref}) if $log->is_debug(); } # If we are not using the origins of the sub ingredients, # aggregate the origins of the ingredient if (defined $ingredient_origins_ref) { $log->debug("aggregate_origins_of_ingredients - adding origins", {ingredient_id => $ingredient_ref->{id}, ingredient_origins_ref => $ingredient_origins_ref}) if $log->is_debug(); foreach my $origin_id (@$ingredient_origins_ref) { if (not defined $environmental_score_data{origins}{$origin_id}) { # If the origin is a child of a country, use the country my $country_code = get_inherited_property("origins", $origin_id, "country_code_2:en"); if ( (defined $country_code) and ( defined $environmental_score_data{origins} {canonicalize_taxonomy_tag("en", "origins", $country_code)}) ) { $origin_id = canonicalize_taxonomy_tag("en", "origins", $country_code); } else { $origin_id = "en:unknown"; } } defined $aggregated_origins_ref->{$origin_id} or $aggregated_origins_ref->{$origin_id} = 0; $aggregated_origins_ref->{$origin_id} += $ingredient_ref->{percent_estimate} / scalar(@$ingredient_origins_ref); } } } return; } =head2 get_country_origin_from_origins ( $origins_ref ) Given a list of origins, return the country for the first origin that is a country or a child of a country. =cut sub get_country_origin_from_origins ($origins_ref) { foreach my $origin_id (@$origins_ref) { # If the origin is a child of a country, use the country my $country_code = get_inherited_property("origins", $origin_id, "country_code_2:en"); if ( (defined $country_code) and (defined $environmental_score_data{origins}{canonicalize_taxonomy_tag("en", "origins", $country_code)})) { return canonicalize_taxonomy_tag("en", "origins", $country_code); } } return; } =head2 compute_environmental_score_origins_of_ingredients_adjustment ( $product_ref ) Computes adjustments(bonus or malus for transportation + EPI / Environmental Performance Index) according to the countries of origin of the ingredients. The transportation bonus or malus is computed for all the countries where the Environmental-Score is enabled. =head3 Arguments =head4 Product reference $product_ref =head3 Return values The adjustment value and computations details are stored in the product reference passed as input parameter. Returned values: $product_ref->{adjustments}{origins_of_ingredients} hash with: - value_[country code]: combined bonus or malus for transportation + EPI - epi_value - transportation_value_[country code] - aggregated origins: sorted array of origin + percent to show the % of ingredients by country used in the computation Note: the country EPI is not taken into account if the product already has a bonus for the production system. =cut sub compute_environmental_score_origins_of_ingredients_adjustment ($product_ref) { # First parse the "origins" field to see which countries are listed # Ignore entries that are not recognized or that do not have Environmental-Score values (only countries and continents) my @origins_from_origins_field = (); if (defined $product_ref->{origins_tags}) { foreach my $origin_id (@{$product_ref->{origins_tags}}) { if (defined $environmental_score_data{origins}{$origin_id}) { push @origins_from_origins_field, $origin_id; } } } # Check if we have categories with an origins:en property (e.g. French wines -> origins:en:france) my @origins_from_categories = (); if (defined $product_ref->{categories_tags}) { foreach my $category (@{$product_ref->{categories_tags}}) { my $origin_id = get_property("categories", $category, "origins:en"); if (defined $origin_id) { # There may be multiple comma separated origins, and they might not be canonical # so we split them and canonicalize them push @origins_from_categories, map ({canonicalize_taxonomy_tag("en", "origins", $_)} split(',', $origin_id)); } } } my $origin_from_categories = get_country_origin_from_origins(\@origins_from_categories); if (defined $origin_from_categories) { @origins_from_categories = ($origin_from_categories); } # If we don't have ingredients, check if we have an origin for a specific ingredient # (e.g. we have the label "French eggs" even though we don't have ingredients) if ( (scalar @origins_from_origins_field == 0) and ((not defined $product_ref->{ingredients}) or (scalar @{$product_ref->{ingredients}} == 0))) { my $origin_id = has_specific_ingredient_property($product_ref, undef, "origins"); if ((defined $origin_id) and (defined $environmental_score_data{origins}{$origin_id})) { push @origins_from_origins_field, $origin_id; } } # If we have origins from the origins field and from the categories, we will use the origins from the origins field my $default_origins_ref = \@origins_from_categories; if (scalar @origins_from_origins_field == 0) { @origins_from_origins_field = ("en:unknown"); } else { $default_origins_ref = \@origins_from_origins_field; } if (scalar @origins_from_categories == 0) { @origins_from_categories = ("en:unknown"); } $log->debug( "compute_environmental_score_origins_of_ingredients_adjustment - origins field", { origins_tags => $product_ref->{origins_tags}, origins_from_origins_field => \@origins_from_origins_field, origins_from_categories => \@origins_from_categories } ) if $log->is_debug(); # Sum the % values/estimates of all ingredients by origins my %aggregated_origins = (); if ((defined $product_ref->{ingredients}) and (scalar @{$product_ref->{ingredients}} > 0)) { aggregate_origins_of_ingredients($default_origins_ref, \%aggregated_origins, $product_ref->{ingredients}); } else { # If we don't have ingredients listed, apply the origins from the origins field # using a dummy ingredient aggregate_origins_of_ingredients($default_origins_ref, \%aggregated_origins, [{percent_estimate => 100}]); } # Compute the transportation and EPI values and a sorted list of aggregated origins my @aggregated_origins = (); my %transportation_scores; # We will compute a transportation score for all countries, and have a 0 transportation score and bonus for world foreach my $cc (@environmental_score_countries_enabled_sorted, "world") { $transportation_scores{$cc} = 0; } my $epi_score = 0; foreach my $origin_id ( sort ({($aggregated_origins{$b} <=> $aggregated_origins{$a}) || ($a cmp $b)} keys %aggregated_origins)) { my $percent = $aggregated_origins{$origin_id}; push @aggregated_origins, {origin => $origin_id, percent => $percent}; if (not defined $environmental_score_data{origins}{$origin_id}{epi_score}) { $log->error( "compute_environmental_score_origins_of_ingredients_adjustment - missing epi_score", {origin_id => $origin_id, origin_data => $environmental_score_data{origins}{$origin_id}} ) if $log->is_error(); } $epi_score += ($environmental_score_data{origins}{$origin_id}{epi_score} // 0) * $percent / 100; foreach my $cc (@environmental_score_countries_enabled_sorted) { $transportation_scores{$cc} += ($environmental_score_data{origins}{$origin_id}{"transportation_score_" . $cc} // 0) * $percent / 100; } } my $epi_value = $epi_score / 10 - 5; $log->debug("compute_environmental_score_origins_of_ingredients_adjustment - aggregated origins", {aggregated_origins => \@aggregated_origins}) if $log->is_debug(); # EPI score is not counted if we already have a bonus for the production system # In this case, we set the EPI score to 0 if ($product_ref->{environmental_score_data}{adjustments}{production_system}{value} > 0) { $epi_score = 0; $epi_value = 0; } $product_ref->{environmental_score_data}{adjustments}{origins_of_ingredients} = { origins_from_origins_field => \@origins_from_origins_field, origins_from_categories => \@origins_from_categories, aggregated_origins => \@aggregated_origins, epi_score => 0 + $epi_score, epi_value => 0 + round($epi_value), }; $product_ref->{environmental_score_data}{adjustments}{origins_of_ingredients}{"transportation_scores"} = \%transportation_scores; $product_ref->{environmental_score_data}{adjustments}{origins_of_ingredients}{"transportation_values"} = {}; $product_ref->{environmental_score_data}{adjustments}{origins_of_ingredients}{"values"} = {}; foreach my $cc (@environmental_score_countries_enabled_sorted, "world") { $product_ref->{environmental_score_data}{adjustments}{origins_of_ingredients}{"transportation_values"}{$cc} = 0 + round($transportation_scores{$cc} / 6.66); $product_ref->{environmental_score_data}{adjustments}{origins_of_ingredients}{"values"}{$cc} = 0 + round($epi_value) + $product_ref->{environmental_score_data}{adjustments}{origins_of_ingredients}{"transportation_values"} {$cc}; } # Add a warning if the only origin is en:unknown if (($#aggregated_origins == 0) and ($aggregated_origins[0]{origin} eq "en:unknown")) { $product_ref->{environmental_score_data}{adjustments}{origins_of_ingredients}{warning} = "origins_are_100_percent_unknown"; defined $product_ref->{environmental_score_data}{missing} or $product_ref->{environmental_score_data}{missing} = {}; $product_ref->{environmental_score_data}{missing}{origins} = 1; } return; } =head2 compute_environmental_score_packaging_adjustment ( $product_ref ) Computes adjustments (malus) based on the packaging of the product. =head3 Arguments =head4 Product reference $product_ref =head3 Return values The adjustment value and computations details are stored in the product reference passed as input parameter. Returned values: $product_ref->{adjustments}{packaging} hash with: - value: malus for packaging - packagings: details of the computation =cut sub compute_environmental_score_packaging_adjustment ($product_ref) { $log->debug("compute_environmental_score_packaging_adjustment - packagings data structure", {packagings => $product_ref->{packagings}}) if $log->is_debug(); # Sum the scores of all packagings components # Create a copy of the packagings structure, so that we can add Eco-score elements to it my $warning; # If we do not have packagings info, return the maximum malus if ((not defined $product_ref->{packagings}) or (scalar @{$product_ref->{packagings}} == 0)) { $product_ref->{environmental_score_data}{adjustments}{packaging} = {value => -15,}; # indicate that we are missing key data # this is to indicate to 3rd party that the computed Environmental-Score should not be displayed without warnings $product_ref->{environmental_score_data}{missing_key_data} = 1; $warning = "packaging_data_missing"; } else { my $packagings_ref = dclone($product_ref->{packagings}); my $packaging_score = 0; my $non_recyclable_and_non_biodegradable_materials = 0; foreach my $packaging_ref (@$packagings_ref) { # We need to match the material and shape to the Eco-score materials and shapes. # We may have a child of the entries listed in the Eco-score data. # Shape is needed first, as it is used in the material section to determine if a non recyclable material has a ratio >= 1 if (defined $packaging_ref->{shape}) { my $ratio = get_inherited_property("packaging_shapes", $packaging_ref->{shape}, "environmental_score_ratio:en"); if (defined $ratio) { $packaging_ref->{environmental_score_shape_ratio} = $ratio + 0; } else { if (not defined $warning) { $warning = "unscored_shape"; } } } else { $packaging_ref->{shape} = "en:unknown"; if (not defined $warning) { $warning = "unspecified_shape"; } } if (not defined $packaging_ref->{environmental_score_shape_ratio}) { # No shape specified, or no Eco-score score for it, use a ratio of 1 $packaging_ref->{environmental_score_shape_ratio} = 1; } # Material if (defined $packaging_ref->{material}) { # For aluminium, we need to differentiate heavy and light aluminium based on the shape if ($packaging_ref->{material} eq "en:aluminium") { my $weight = "thin"; if (defined $packaging_ref->{shape}) { $weight = get_inherited_property("packaging_shapes", $packaging_ref->{shape}, "weight:en"); $log->debug("aluminium", {weight => $weight, shape => $packaging_ref->{shape}}) if $log->is_debug(); if (not defined $weight) { $weight = "heavy"; } } if ($weight eq "heavy") { $packaging_ref->{material} = "en:heavy-aluminium"; } else { $packaging_ref->{material} = "en:light-aluminium"; } } my $score = get_inherited_property( "packaging_materials", $packaging_ref->{material}, "environmental_score_score:en" ); if (defined $score) { $packaging_ref->{environmental_score_material_score} = $score + 0; } else { if (not defined $warning) { $warning = "unscored_material"; } } # Check if there is a shape-specific material score (e.g. PEHD bottle) if (defined $packaging_ref->{shape}) { my $shape_specific_score = get_inherited_property( "packaging_materials", $packaging_ref->{material} . '.' . $packaging_ref->{shape}, "environmental_score_score:en" ); if (defined $shape_specific_score) { $packaging_ref->{environmental_score_material_score} = $shape_specific_score + 0; $packaging_ref->{material_shape} = $packaging_ref->{material} . '.' . $packaging_ref->{shape}; } } # Check if the material is non recyclable and non biodegradable my $non_recyclable_and_non_biodegradable = get_inherited_property( "packaging_materials", $packaging_ref->{material}, "non_recyclable_and_non_biodegradable:en" ); if (defined $non_recyclable_and_non_biodegradable) { $packaging_ref->{non_recyclable_and_non_biodegradable} = $non_recyclable_and_non_biodegradable; if ( ($non_recyclable_and_non_biodegradable ne "no") and ($packaging_ref->{environmental_score_shape_ratio} >= 1)) { $non_recyclable_and_non_biodegradable_materials++; } } } else { $packaging_ref->{material} = "en:unknown"; if (not defined $warning) { $warning = "unspecified_material"; } } if (not defined $packaging_ref->{environmental_score_material_score}) { # No material specified, or no Eco-score score for it, use a score of 0 $packaging_ref->{environmental_score_material_score} = 0; } # Multiply the shape ratio and the material score $packaging_score += (100 - $packaging_ref->{environmental_score_material_score}) * $packaging_ref->{environmental_score_shape_ratio}; } $packaging_score = 100 - $packaging_score; my $value = 0 + round($packaging_score / 10 - 10); if ($value < -15) { $value = -15; } $product_ref->{environmental_score_data}{adjustments}{packaging} = { packagings => $packagings_ref, score => $packaging_score, value => $value, non_recyclable_and_non_biodegradable_materials => $non_recyclable_and_non_biodegradable_materials, }; } if (defined $warning) { $product_ref->{environmental_score_data}{adjustments}{packaging}{warning} = $warning; defined $product_ref->{environmental_score_data}{missing} or $product_ref->{environmental_score_data}{missing} = {}; $product_ref->{environmental_score_data}{missing}{packagings} = 1; } return; } =head2 localize_environmental_score ( $cc, $product_ref) The Environmental-Score and some of its components depend on the country of the consumer, as we take transportation to the consumer into account. We compute the Environmental-Score for all countries, and this function copies the values for a specific country to the main Environmental-Score fields. Note: even if we could not compute the Environmental-Score (because of a missing category), we still localize the origins of ingredients, so that it can be displayed in separate knowledge panels. =head3 Arguments =head4 Country code of the request $cc =head4 Product reference $product_ref =head3 Return values The adjustment value and computations details are stored in the product reference passed as input parameter. =cut sub localize_environmental_score ($request_cc, $product_ref) { # Localize the Environmental-Score fields that depends on the country of the request if (defined $product_ref->{environmental_score_data}) { # Localize the final score # If we have specific Environmental-Score and origins of ingredients adjustment for the country, use it, # otherwise use the world values my $cc = $request_cc; if (not defined $product_ref->{environmental_score_data}{"scores"}{$cc}) { $cc = "world"; } if (defined $product_ref->{environmental_score_data}{"scores"}{$cc}) { $product_ref->{environmental_score_data}{"score"} = $product_ref->{environmental_score_data}{"scores"}{$cc}; $product_ref->{environmental_score_data}{"grade"} = $product_ref->{environmental_score_data}{"grades"}{$cc}; $product_ref->{"environmental_score_score"} = $product_ref->{environmental_score_data}{"score"}; $product_ref->{"environmental_score_grade"} = $product_ref->{environmental_score_data}{"grade"}; $product_ref->{"environmental_score_tags"} = [$product_ref->{environmental_score_grade}]; } # Localize the origins of ingredients data if (defined $product_ref->{environmental_score_data}{adjustments}{origins_of_ingredients}) { $product_ref->{environmental_score_data}{adjustments}{origins_of_ingredients}{"value"} = $product_ref->{environmental_score_data}{adjustments}{origins_of_ingredients}{"values"}{$cc}; $product_ref->{environmental_score_data}{adjustments}{origins_of_ingredients}{"transportation_score"} = $product_ref->{environmental_score_data}{adjustments}{origins_of_ingredients} {"transportation_scores"}{$cc}; $product_ref->{environmental_score_data}{adjustments}{origins_of_ingredients}{"transportation_value"} = $product_ref->{environmental_score_data}{adjustments}{origins_of_ingredients} {"transportation_values"}{$cc}; # For each origin, we also add its score (EPI + transporation to country of request) # so that clients can show which ingredients contributes the most to the origins of ingredients bonus / malus if ( defined $product_ref->{environmental_score_data}{adjustments}{origins_of_ingredients} {aggregated_origins}) { foreach my $origin_ref ( @{$product_ref->{environmental_score_data}{adjustments}{origins_of_ingredients}{aggregated_origins}} ) { my $origin_id = $origin_ref->{origin}; $origin_ref->{epi_score} = ($environmental_score_data{origins}{$origin_id}{epi_score} // 0) + 0; $origin_ref->{transportation_score} = ($environmental_score_data{origins}{$origin_id}{"transportation_score_" . $cc} // 0) + 0; } } } } return; } 1;