Source code for ferrosoft.apps.emiflow.services.pcf

#  Copyright (c) 2025 Ferrosoft GmbH. All rights reserved.
"""Product Carbon Footprint (PCF) calculation across all chains for a material.

The :class:`ProductCarbonFootprintService` aggregates transport
emissions for every :class:`TransportChain` that carries a given
:class:`Material`, normalises units across chains, and reports the
emission-per-tonne intensity in ``kgCO2e``.
"""
from dataclasses import dataclass
from decimal import Decimal
from typing import List

from django.utils.translation import gettext as _

from ferrosoft.apps.emiflow.models import TransportChain
from ferrosoft.apps.emiflow.services.emissions import TransportChainCalculator
from ferrosoft.apps.ferrobase.models import Material
from ferrosoft.apps.ferrobase.models.decimal import DecimalWithUnit
from ferrosoft.apps.ferrobase.tenant.utils import tenant_db_connection
from ferrosoft.decimal import zero


[docs] @dataclass class LifecycleResult: """One row of a PCF table: a labelled lifecycle contribution.""" label: str value: DecimalWithUnit
[docs] class ProductCarbonFootprintService: """Calculate the Product Carbon Footprint of one material across all transports. Uses a raw SQL query to find the transport chains carrying the material, then runs the chain calculator against each one and divides the summed emissions by the summed weight to express the result as ``kgCO2e`` per weight unit. """ _CHAIN_QUERY = """SELECT tc.id FROM emiflow_transportchain tc JOIN public.emiflow_freightitem ef on tc.id = ef.chain_id WHERE ef.material_id = %s GROUP BY tc.id; """ def __init__(self): self.calculator = TransportChainCalculator()
[docs] def calculate_for(self, material: Material) -> List[LifecycleResult]: """Compute the PCF for ``material`` and return one transport row. Returns an empty list when no chains carry the material. Raises :class:`RuntimeError` when the source chains mix weight or emission units, since combining mismatched units would silently falsify the result. """ # Collect chain IDs chains = [] with tenant_db_connection().cursor() as cursor: for result in cursor.execute(self._CHAIN_QUERY, [str(material.pk)]): chains.append(result[0]) sum_emission = zero sum_quantity = zero weight_unit = None emission_unit = None for chain in TransportChain.objects.filter(pk__in=chains): result = self.calculator.calculate_for(chain) # Ensure uniform units. if weight_unit is None: weight_unit = result.weight.try_unit().symbol elif weight_unit != result.weight.try_unit().symbol: raise RuntimeError("Different weight units") if emission_unit is None: emission_unit = result.emission_total.try_unit().symbol elif emission_unit != result.emission_total.try_unit().symbol: raise RuntimeError("Different emission units") sum_emission += result.emission_total.value sum_quantity += result.weight.value if sum_quantity == zero: lc_transport = zero else: lc_transport = Decimal(sum_emission / sum_quantity) if weight_unit is None or emission_unit is None: return [] # Convert to kgCO2e, as it is more convenient for the eye. if emission_unit == "gCO2e": emission_unit = "kgCO2e" lc_transport /= Decimal(1000) return [ LifecycleResult( label=_("Transport"), value=DecimalWithUnit.create( lc_transport.quantize(Decimal("1.00")), "%s/%s" % (emission_unit, weight_unit), ), ) ]