Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 78 additions & 26 deletions mysite/dpp/lca.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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)

Expand All @@ -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,
Expand Down Expand Up @@ -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):
"""
Expand All @@ -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,
Expand All @@ -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({
Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -374,4 +426,4 @@ def create_supply_chain_lca(product):
scope_1_2_3=0,
)

return
return evaluation
30 changes: 18 additions & 12 deletions mysite/dpp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
'GJ': 1000 / 3.6,
}


## Organizations and companies

class Organization(models.Model):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]}"
Expand Down Expand Up @@ -1197,7 +1203,7 @@ class SustainabilityScore(models.Model):
scope_1_2_3 = models.FloatField("Scope 1+2+3 CO<sub>2</sub> emission", help_text="Total greenhouse gas emissions associated with the product over its lifecycle, expressed as kg CO<sub>2</sub> 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:
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand All @@ -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:
Expand Down
33 changes: 32 additions & 1 deletion mysite/dpp/templates/dpp/publisher_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ <h2 class="text-2xl font-semibold text-gray-900">Publish DPP: {{ object.producti
</div>

<div class="bg-white shadow overflow-hidden sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<div class="px-4 py-5">
<h3 class="px-4 text-lg font-medium">Details</h3>
</div>
<div class="border-t border-gray-200">
Expand All @@ -97,4 +97,35 @@ <h3 class="px-4 text-lg font-medium">Details</h3>
</dl>
</div>
</div>

<div class="bg-white shadow overflow-hidden">
<h3 class="font-semibold font-large">LCA results</h3>
<table class="table table-striped">
<thead>
<tr class="px-4 py-2">
<th>Assessment date</th>
<th>Assessed by</th>
<th></th>
</tr>
</thead>
<tbody>
{% for result in lca_results %}
<tr class="{% cycle 'bg-teal-50' 'bg-white' %} px-4 py-2">
<td>{{ result.assessment_date }}</td>
<td>{{ result.assessed_by }}</td>
<td>
<a href="{% url 'dpp:sustainabilityevaluation_detail' result.pk %}"
class="btn btn-sm btn-outline-primary">
See results
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3">No LCA results available.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
1 change: 1 addition & 0 deletions mysite/dpp/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
19 changes: 19 additions & 0 deletions mysite/mysite/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []


Expand Down