diff --git a/mysite/dpp/lca.py b/mysite/dpp/lca.py index b036a99..974b1bb 100644 --- a/mysite/dpp/lca.py +++ b/mysite/dpp/lca.py @@ -4,7 +4,9 @@ import bw2data as bwd import bw2io as bwi import datetime -from .models import * +import logging + +logger = logging.getLogger(__name__) RESOURCE_UNITS = [ 'kg', 'g', 'lb', 'oz', 'l', 'cm3', 'dm3', 'm3', 'ft3', 'gal', @@ -14,18 +16,22 @@ # for cat in ['Mass', 'Volume', 'Energy']: # RESOURCE_UNITS += list(UNIT_CHOICES[cat].keys()) + list(UNIT_CHOICES[cat].values()) -# Methods that are known to have zero calculated impact +# Methods that are too detailed (for (in)organics) EXCLUDED_METHODS = { - ("EF v3.1", "climate change: land use and land use change", "global warming potential (GWP100)"), - ("EF v3.1", "ionising radiation: human health", "human exposure efficiency relative to u235"), - ("EF v3.1", "ozone depletion", "ozone depletion potential (ODP)"), - ("EF v3.1", "water use", "user deprivation potential (deprivation-weighted water consumption)"), + "ecotoxicity: freshwater, inorganics', 'comparative toxic unit for ecosystems (CTUe)", + "ecotoxicity: freshwater, organics', 'comparative toxic unit for ecosystems (CTUe)", + "human toxicity: carcinogenic, inorganics', 'comparative toxic unit for human (CTUh)", + "human toxicity: carcinogenic, organics', 'comparative toxic unit for human (CTUh)", + "human toxicity: non-carcinogenic, inorganics', 'comparative toxic unit for human (CTUh)", + "human toxicity: non-carcinogenic, organics', 'comparative toxic unit for human (CTUh)')", } DEFAULT_REMOTE_PROJECT = "ecoinvent-3.12-biosphere" +ECOINVENT = "ecoinvent-3-12-cutoff" def setup_project(project_name: str) -> None: """Initialize the project if needed, and check that it is complete.""" if project_name not in bwd.projects: + logger.debug("Creating a new Brightway project setup.") bwi.remote.install_project(DEFAULT_REMOTE_PROJECT, project_name) bwd.projects.set_current(project_name) @@ -41,17 +47,18 @@ def ensure_methods(family: str): try: method_set = IndicatorSet.objects.get(name=family) except IndicatorSet.DoesNotExist: + logger.debug("Creating a set of environmental indicators.") method_set = IndicatorSet.objects.create(name=family, start_date=datetime.date.today()) methods = [ m for m in bwd.methods - if m[0] == family and m not in EXCLUDED_METHODS + if family in m and m[-2:] not in EXCLUDED_METHODS ] if len(methods) < len(ImpactIndicator.objects.filter(indicator_set=method_set)): return method_set unknown_category, _ = ImpactCategory.objects.get_or_create(name='Unknown') for m in methods: ImpactIndicator.objects.update_or_create( - method=m[1], + method=m[-2], unit=bwd.methods[m].get('unit'), indicator_set=method_set, impact_category=unknown_category, @@ -149,6 +156,44 @@ def find_biosphere_flow(exc, biosphere_db): act = biosphere_db.search(name)[0] return (act['database'], act['code']) +def make_transport_exchange(transports, product, amount): + """Create an exchange that represent the transport service + of `amount` of `product`, with distance specified in `transports`. + + Parameters: + transports: QuerySet of Transport table + product: ProductModel, input to some process + amount: int, amount of product transported + """ + # Import here to avoid circular imports + from .models import UNIT_CHOICES, CONVERSIONS + ecoinvent_codes = { + "train": '44760b3d59b51d5aaffecedeba08e3fa', # freight train, average, EU + "ocean ship": '1d2cb4018daf428aebf419380c6a2975', # sea freight container ship, GLO + "truck": 'dd736ab7d965646d04b091ae5fa68d97', # lorry, unspecified, RER + "inland ship": '1d510fd336c629c3687f812967538191', # inland waterways, RER + "airplane": '96a835b204d1d9327b56bca7995dc24f', # aircraft, unspecified, GLO + "delivery van": '558949a7ddfcccba760eab39bda68e88', # light commercial vehicle, EU + "NA": 'dd736ab7d965646d04b091ae5fa68d97', #FIXME defaults to truck for now + } + transport = transports.filter(product=product).first() + if transport is None: + raise LookupError(f"No transport specified for {product}") + # Determine the mass of one unit of product (kg) + input_prod = transport.product + if hasattr(input_prod, 'properties'): + mass = input_prod.properties.weight * CONVERSIONS[input_prod.properties.weight_unit] + elif input_prod.unit in UNIT_CHOICES['Mass']: + mass = CONVERSIONS[input_prod.unit] + else: + mass = 1 + + return { # Calculate transport distance (t-km) + "input": (ECOINVENT, ecoinvent_codes[transport.mode]), + "amount": mass / 1000 * amount * transport.distance, + "type": "technosphere", + "unit": 'ton kilometer', + } def convert_dpp_to_brightway(processes: list, db_name: str): """ @@ -164,6 +209,7 @@ def convert_dpp_to_brightway(processes: list, db_name: str): bw_activities = {} for dpp_process in processes: location = str(dpp_process.facility.country) if dpp_process.facility else 'GLO' + transports = dpp_process.functional_flow.productionline.transport exchanges = [{ "input": (db_name, dpp_process.pk), "amount": dpp_process.amount, @@ -180,14 +226,21 @@ def convert_dpp_to_brightway(processes: list, db_name: str): sign = 1 if exc.direction == 'in' else -1 try: source_db = exc.product.manufacturing_info.database + db_code = exc.product.manufacturing_info.db_code except AttributeError: source_db = db_name + db_code = exc.product.manufacturing_info.pk exchanges.append({ - "input": (source_db, exc.product.manufacturing_info.pk), + "input": (source_db, db_code), "amount": sign * exc.amount, "type": "technosphere", "unit": exc.product.model.unit, }) + # Add transport exchange for this input product + if sign == 1: + exchanges.append( + make_transport_exchange(transports, exc.product, exc.amount) + ) for exc in EnvExchange.objects.filter(process=dpp_process): bioshpere_flow = find_biosphere_flow(exc, biosphere) exchanges.append({ @@ -252,8 +305,8 @@ def traverse(flow, depth=0): return visited.add(flow.id) - # Get the production process for this item - assert hasattr(flow, 'manufacturing_info'), f"Product {flow} has no production process!" + # Get the ManufacturingProcess for this flow + assert hasattr(flow, 'manufacturing_info'), f"Product {flow} has no manufacturing process!" process = flow.manufacturing_info processes_to_include.append(process) # convert_dpp_to_bw_activity(process, db_name) @@ -275,22 +328,20 @@ def lca_calculations(activity, family: str = 'EF v3.1'): # Select methods belonging to family methods = [ m for m in bwd.methods - if m[0] == family and m not in EXCLUDED_METHODS + if family in m and m[-2:] not in EXCLUDED_METHODS ] + logger.debug(f"{len(methods)} EF methods found") if not methods: - print(f"⚠️ No {family} methods available.") - return + logger.warning(f"No {family} methods available.") + raise RuntimeError(f"No {family} methods available.") methods = sorted(methods) # Calculate LCA results lca = activity.lca(methods[0]) - results = [(methods[0], lca.score, bwd.methods[methods[0]].get('unit'))] + results = [(methods[0][-2], lca.score, bwd.methods[methods[0]].get('unit'))] for m in methods[1:]: lca.switch_method(m) lca.lcia() - results.append((m, lca.score, bwd.methods[m].get("unit"))) - print(f"\n{family} results for {activity['name']}:") - for m, val, unit in results: - print(f" {m[1]} -> {val:.6g} {unit}") + results.append((m[-2], lca.score, bwd.methods[m].get("unit"))) return results def create_supply_chain_lca(product): @@ -322,11 +373,12 @@ def create_supply_chain_lca(product): # Create unique Brightway database db_name = f"dpp_{product.model.name}_{product.pk}" if db_name in bwd.databases: - merge_choice = prompt_choice( - f"Foreground DB '{db_name}' exists. Choose action:", - ["Add data", "Overwrite"], - default_index=0, - ) + merge_choice = "Overwrite" + # merge_choice = prompt_choice( + # f"Foreground DB '{db_name}' exists. Choose action:", + # ["Add data", "Overwrite"], + # default_index=0, + # ) if merge_choice == "Overwrite": del bwd.databases[db_name] else: @@ -364,7 +416,7 @@ def create_supply_chain_lca(product): else: for m, value, unit in results: SustainabilityScore.objects.update_or_create( - impact_indicator=ImpactIndicator.objects.get(method=m,indicator_set=method_set), + impact_indicator=ImpactIndicator.objects.get(method=m, indicator_set=method_set), evaluation=evaluation, impact_value=value, upstream_phase=0, @@ -374,4 +426,4 @@ def create_supply_chain_lca(product): scope_1_2_3=0, ) - return + return evaluation diff --git a/mysite/dpp/models.py b/mysite/dpp/models.py index e81c2eb..9db316b 100644 --- a/mysite/dpp/models.py +++ b/mysite/dpp/models.py @@ -58,6 +58,7 @@ 'GJ': 1000 / 3.6, } + ## Organizations and companies class Organization(models.Model): @@ -252,7 +253,7 @@ def get_hazardous_concentrations(self): """Returns a dict with the concentration of each hazardous material""" concentrations = defaultdict(float) try: - product_weight = self.properties.weight * CONVERSIONS[self.properties.weight_unit.unit] + product_weight = self.properties.weight * CONVERSIONS[self.properties.weight_unit] except ProductProperties.DoesNotExist: print(f"Weight of {self} unknown; cannot calculate concentration.") bom = self.get_composition() @@ -282,7 +283,7 @@ def add_concentrations(self): Concentration.objects.update_or_create( product=self, material=material, fraction=frac ) - packaging = Material.objects.update_or_create(name='Total packaging material') + packaging = Material.objects.update_or_create(name='Total packaging material')[0] if hasattr(self, 'properties'): Concentration.objects.update_or_create( product=self, @@ -381,7 +382,7 @@ def packaging_ratio(self): if self.weight == 0: return 0 package_weight = 0 - for pack in self.produced_by.prod_exchanges.filter(type='pack'): + for pack in self.product.produced_by.prod_exchanges.filter(type='pack'): package_weight += pack.properties.weight * CONVERSIONS[pack.weight_unit] if self.includes_packaging: return package_weight / (self.weight - package_weight) @@ -458,7 +459,7 @@ def disassemble(self): """ created_items = [] - for i, component in enumerate(self.components.all()): #TODO: make this table + for i, component in enumerate(self.components.all()): component_serial = f"{self.serial_number}-C{i}" for j in range(component.amount): # Generate unique serial number for each component @@ -887,15 +888,20 @@ class Transport(models.Model): for inputs to a production line. """ VEHICLES = { - 'ocean': 'Ship (ocean)', - 'NA': 'Unspecified', + "train": "Freight train", + "ocean ship": "Sea freight container ship", + "truck": "Lorry / truck, average", + "inland ship": "Inland waterway ship", + "airplane": "Aircraft", + "delivery van": "Light commercial vehicle (delivery van)", + "NA": "Unspecified", } production_line = models.ForeignKey(ProductionLine, on_delete=models.CASCADE, related_name='transport') product = models.ForeignKey(Flow, on_delete=models.CASCADE, related_name='transport') distance = models.PositiveSmallIntegerField("Transport distance (km)", default=0, validators=[MaxValueValidator(40000)]) - mode = models.CharField("Main mode of transport", max_length=10, choices=VEHICLES, default='NA') + mode = models.CharField("Main mode of transport", max_length=12, choices=VEHICLES, default='NA') utilisation_ratio = models.FloatField(default=0.5, validators=FRACTION_VALIDATOR) - #TODO: if mode='car', calculate allocation factor as: min(1, prod.volume / 0.2 m3). + #NOTE: if mode='car', calculate allocation factor as: min(1, prod.volume / 0.2 m3). def __str__(self): return f"{self.distance} km by {self.VEHICLES[self.mode]}" @@ -1197,7 +1203,7 @@ class SustainabilityScore(models.Model): scope_1_2_3 = models.FloatField("Scope 1+2+3 CO2 emission", help_text="Total greenhouse gas emissions associated with the product over its lifecycle, expressed as kg CO2 equivalents.") def __str__(self): - return f"{self.impact_value} {self.impact_indicator.unit} for {self.evaluation}" + return f"{self.impact_value:.3g} {self.impact_indicator.unit} for {self.evaluation}" def clean(self): if self.impact_indicator.is_environmental != self.evaluation.is_environmental: @@ -1324,7 +1330,7 @@ def run_from_step(self, start_step: int): Args: start_step (int): Step (0-5) to start Returns: - bool: Whether the + bool: Whether an error was encountered or not """ self.error_message = "" if self.status + 1 < start_step: @@ -1344,7 +1350,7 @@ def run_from_step(self, start_step: int): else: self.status = 1 self.save() - + # Step 2: Aggregate if start_step <= 2: mp = pl.aggregate_production() @@ -1370,7 +1376,7 @@ def run_from_step(self, start_step: int): result = lca.create_supply_chain_lca(pl.final_product) self.status = 5 self.save() - + except Exception as e: self.error_message = f"Error at step {self.status + 1}: {str(e)}" finally: diff --git a/mysite/dpp/templates/dpp/publisher_detail.html b/mysite/dpp/templates/dpp/publisher_detail.html index 4339262..d729ea9 100644 --- a/mysite/dpp/templates/dpp/publisher_detail.html +++ b/mysite/dpp/templates/dpp/publisher_detail.html @@ -77,7 +77,7 @@

Publish DPP: {{ object.producti
-
+

Details

@@ -97,4 +97,35 @@

Details

+ +
+

LCA results

+ + + + + + + + + + {% for result in lca_results %} + + + + + + {% empty %} + + + + {% endfor %} + +
Assessment dateAssessed by
{{ result.assessment_date }}{{ result.assessed_by }} + + See results + +
No LCA results available.
+
{% endblock %} \ No newline at end of file diff --git a/mysite/dpp/views.py b/mysite/dpp/views.py index 1664d3b..65170bc 100644 --- a/mysite/dpp/views.py +++ b/mysite/dpp/views.py @@ -425,6 +425,7 @@ def get_context_data(self, **kwargs): } for i in range(1, 6) ] + context['lca_results'] = SustainabilityEvaluation.objects.filter(is_environmental=True, product=publisher.production_line.final_product) return context class DppFullView(DetailView): diff --git a/mysite/mysite/settings.py b/mysite/mysite/settings.py index e435e1a..8093b3e 100644 --- a/mysite/mysite/settings.py +++ b/mysite/mysite/settings.py @@ -25,6 +25,25 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "file": { + "level": "DEBUG", + "class": "logging.FileHandler", + "filename": "logs/debug.log", + }, + }, + "loggers": { + "django": { + "handlers": ["file"], + "level": "DEBUG", + "propagate": True, + }, + }, +} + ALLOWED_HOSTS = []