"""Reference catalog: emitters, energy carriers, and their emission factors.
A :class:`EmissionsCatalog` is a published bundle of emission data (e.g.
the GLEC public extract) with copyright and licence information. Each
catalog contains :class:`Emitter` (vehicles or hub equipment),
:class:`EnergyCarrier`, and :class:`EmissionFactor`. Models share the
shape of the upstream ``emiflow.json`` JSON schema so updates can be
imported in bulk.
"""
from decimal import Decimal
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy
from ferrosoft.apps.ferrobase.models import (
UUIDModel,
MeasurementUnit,
IntegerChoicesMixin,
ReadOnlyMixin,
)
from ferrosoft.apps.ferrobase.models.decimal import DecimalWithUnit
[docs]
class DataSource(models.IntegerChoices):
"""Provenance of the figures in a catalog (primary measured vs. defaults)."""
PRIMARY_DATA = 1, _("Primary data")
SECONDARY_DATA = 2, _("Secondary data")
DEFAULT_DATA = 3, _("Default data")
[docs]
class EmissionCategory(models.IntegerChoices):
"""The two emission categories tracked per energy carrier.
*Operation* covers tailpipe / process emissions; *Energy Provision*
covers upstream emissions from producing and distributing the energy
carrier (well-to-tank).
"""
OPERATION = 2, pgettext_lazy("Emission Category", "Operation")
ENERGY_PROVISION = 3, _("Energy Provision")
[docs]
class LoadCharacteristics(models.IntegerChoices):
"""Vehicle load categories used by some catalog lookups."""
LIGHT = 1, _("Light")
AVERAGE = 2, _("Average")
HEAVY = 3, _("Heavy")
CONTAINER = 4, _("Container")
[docs]
class EmitterEpitype(IntegerChoicesMixin, models.IntegerChoices):
"""Top-level type of an :class:`Emitter`.
Vehicles are differentiated further by :class:`VehicleType`; the term
*epitype* distinguishes this overarching axis from the vehicle
sub-type.
"""
VEHICLE = 1, _("Vehicle")
MATERIAL_HANDLER = 3, _("Material Handler")
PROCESSING_UNIT = 4, _("Processing Unit")
[docs]
@classmethod
def hub_equipment(cls) -> "tuple[EmitterEpitype]":
"""Return the subset of epitypes that designate hub (non-vehicle) equipment."""
return cls.MATERIAL_HANDLER, cls.PROCESSING_UNIT
[docs]
class VehicleType(IntegerChoicesMixin, models.IntegerChoices):
"""Sub-type of a :class:`VEHICLE <EmitterEpitype.VEHICLE>` emitter, by operating environment."""
ROAD = 1, _("Road")
RAIL = 2, _("Rail")
INLAND_SHIPPING = 3, _("Inland Shipping")
MARITIME_SHIPPING = 4, _("Maritime Shipping")
# noinspection PyPep8Naming
[docs]
def CatalogDecimalField(*args, **kwargs):
"""Build a ``DecimalField`` sized for catalog values.
Allocates eight fractional digits (factors are small) and only four
integer digits, since catalog quantities rarely exceed four digits
before the decimal point.
"""
return models.DecimalField(
max_digits=12,
decimal_places=8,
*args,
**kwargs,
)
[docs]
class EmissionsCatalog(UUIDModel):
"""A named bundle of emission-calculation reference data.
Catalogs group :class:`Emitter`, :class:`EnergyCarrier`, and
:class:`EmissionFactor` entries together with their licensing terms.
The schema mirrors the published ``emiflow.json`` JSON schema so
third-party catalogs can be imported directly.
"""
class Meta:
verbose_name = _("Emissions catalog")
constraints = [
models.UniqueConstraint(
name="uniq_emissions_catalog_name",
fields=["name"],
)
]
name = models.CharField(
verbose_name=_("Catalog name"),
)
data_source = models.PositiveIntegerField(
verbose_name=_("Data Source"),
choices=DataSource.choices,
default=DataSource.DEFAULT_DATA,
)
copyright = models.CharField(
verbose_name=_("Copyright"),
)
license_uri = models.CharField(
verbose_name=_("License URI"),
)
license_text = models.CharField(
verbose_name=_("License Text"),
)
def __str__(self):
return self.name
[docs]
def get_absolute_url(self):
return reverse("emiflow:emissions_catalog_detail", kwargs={"pk": self.pk})
[docs]
@classmethod
def get_ordered(cls):
"""Return all catalogs sorted alphabetically by name."""
return cls.objects.order_by("name")
[docs]
class EmitterManager(models.Manager):
"""Manager that eagerly joins catalog, energy carrier, and intensity unit."""
[docs]
def get_queryset(self):
return (
super()
.get_queryset()
.select_related(
"catalog",
"energy_carrier",
"emission_intensity",
"emission_intensity__unit",
)
)
[docs]
class Emitter(ReadOnlyMixin, UUIDModel):
"""A vehicle or piece of hub equipment that emits greenhouse gases.
The :attr:`epitype` field discriminates between vehicles (with a
further :attr:`vehicle_type`) and hub equipment (material handlers,
processing units). Each emitter has a default
:class:`EnergyCarrier` and an optional cached
:class:`EmissionIntensity`. The :attr:`~ReadOnlyMixin.read_only`
flag inherited from :class:`ReadOnlyMixin` is set to ``True`` for
rows imported from a published catalog (which must be copied before
they can be modified) and left ``False`` for user-defined entries,
which are editable in place.
"""
class Meta:
verbose_name = _("Emitter")
verbose_name_plural = _("Emitters")
constraints = [
models.UniqueConstraint(
name="uniq_vehicle_name",
fields=["catalog", "name"],
),
]
indexes = [
models.Index(fields=["name"]),
models.Index(fields=["catalog"]),
models.Index(fields=["epitype"]),
models.Index(fields=["vehicle_type"]),
models.Index(fields=["energy_carrier"]),
]
objects = EmitterManager()
catalog = models.ForeignKey(
EmissionsCatalog,
on_delete=models.CASCADE,
related_name="emitters",
)
name = models.CharField(
verbose_name=_("Emitter Name"),
)
epitype = models.PositiveIntegerField(
verbose_name=_("Type of Emitter"),
choices=EmitterEpitype.choices,
)
vehicle_type = models.PositiveIntegerField(
verbose_name=_("Vehicle type"),
help_text=_("This field has only meaning for vehicles."),
choices=VehicleType.choices,
null=True,
blank=True,
)
energy_carrier = models.ForeignKey(
"emiflow.EnergyCarrier",
help_text=_("Energy carrier with which the emitter is usually operated."),
null=True,
on_delete=models.SET_NULL,
verbose_name=_("Energy Carrier"),
related_name="+",
)
emission_intensity = models.ForeignKey(
"emiflow.EmissionIntensity",
null=True,
on_delete=models.SET_NULL,
verbose_name=_("Emission Intensity"),
related_name="+",
)
def __str__(self):
return self.name
[docs]
@classmethod
def get_ordered(cls, catalog=None):
"""Return emitters sorted by ``(catalog, name)``, optionally filtered to one catalog."""
qs = cls.objects.order_by("catalog", "name")
if catalog:
qs = qs.filter(catalog=catalog)
return qs
[docs]
def get_absolute_url(self):
return reverse("emiflow:emitter_update", args=[self.pk])
[docs]
class EnergyCarrier(ReadOnlyMixin, UUIDModel):
"""A substance or phenomenon (fuel, electricity, heat) that powers an emitter.
Holds physical attributes (lower heating value, density) needed to
translate between mass, volume, and energy when a quantity is
expressed in a non-mass unit. The
:attr:`~ReadOnlyMixin.read_only` flag distinguishes carriers
imported from a published catalog (``True``, copy-on-write) from
user-defined entries (``False``, editable in place).
"""
class Meta:
verbose_name = _("Energy carrier")
verbose_name_plural = _("Energy carriers")
constraints = [
models.UniqueConstraint(
name="uniq_energy_carrier_name",
fields=[
"catalog",
"name",
"composition",
"application",
],
),
]
indexes = [
models.Index(fields=["name"]),
]
catalog = models.ForeignKey(
EmissionsCatalog,
on_delete=models.CASCADE,
related_name="energy_carriers",
)
name = models.CharField(
verbose_name=_("Name"),
)
composition = models.CharField(
verbose_name=_("Composition"),
blank=True,
default="",
)
application = models.CharField(
verbose_name=_("Application"),
blank=True,
default="",
)
lower_heating = CatalogDecimalField(
verbose_name=_("Lower Heating Value"),
blank=True,
default=None,
null=True,
)
density = CatalogDecimalField(
verbose_name=_("Density"),
blank=True,
default=None,
null=True,
help_text=_(
"Enter a density in kilogram/liter to be able to specify quantities in volume units, e.g., in liters."
),
)
def __str__(self):
return self.name
[docs]
@classmethod
def get_ordered(cls, catalog=None):
"""Return energy carriers sorted by ``(catalog, name)``, optionally filtered to one catalog."""
qs = cls.objects.order_by("catalog", "name")
if catalog:
qs = qs.filter(catalog=catalog)
return qs
[docs]
def get_absolute_url(self):
return reverse("emiflow:energycarrier_update", args=[self.pk])
[docs]
class EmissionFactorManager(models.Manager):
"""Manager that eagerly joins the energy carrier and unit."""
[docs]
def get_queryset(self):
return (
super()
.get_queryset()
.select_related(
"energy_carrier",
"unit",
)
)
[docs]
class EmissionFactor(ReadOnlyMixin, UUIDModel):
"""A per-unit emission factor for one :class:`EnergyCarrier`.
Typically four factors are stored per carrier, formed by the Cartesian
product of two units (``gCO2e/MJ`` and ``kgCOe/kg``) and the two
:class:`EmissionCategory` values. The
:attr:`~ReadOnlyMixin.read_only` flag is ``True`` for factors
imported from a published catalog (which must be copied before
being changed) and ``False`` for user-defined factors, which can
be edited in place.
"""
class Meta:
verbose_name = _("Emission factor")
indexes = [
models.Index(fields=["category"]),
]
constraints = [
models.UniqueConstraint(
name="uniq_emission_factor",
fields=["energy_carrier", "category", "unit"],
)
]
objects = EmissionFactorManager()
energy_carrier = models.ForeignKey(
EnergyCarrier,
on_delete=models.CASCADE,
related_name="emission_factors",
)
value = CatalogDecimalField(
verbose_name=_("Factor value"),
)
unit = models.ForeignKey(
MeasurementUnit,
on_delete=models.RESTRICT,
related_name="+",
)
category = models.PositiveIntegerField(
verbose_name=_("Emission category"),
choices=EmissionCategory.choices,
)
def __str__(self):
return "%s: %s" % (
self.energy_carrier.name,
str(DecimalWithUnit(self.value.quantize(Decimal("1.00")), self.unit)),
)
@property
def factor(self) -> DecimalWithUnit:
"""Factor value paired with its unit, as a :class:`DecimalWithUnit`."""
return DecimalWithUnit(self.value, self.unit)
[docs]
def get_absolute_url(self):
return reverse("emiflow:emissionfactor_update", args=[self.pk])