WIP: cálculo de impacto ambiental #59

Draft
pedro wants to merge 91 commits from feature/f31-device-enviromental-impact into main
20 changed files with 265 additions and 9 deletions

View file

@ -86,6 +86,9 @@
<li class="nav-item"> <li class="nav-item">
<a href="#log" class="nav-link" data-bs-toggle="tab" data-bs-target="#log">{% trans 'Log' %}</a> <a href="#log" class="nav-link" data-bs-toggle="tab" data-bs-target="#log">{% trans 'Log' %}</a>
</li> </li>
<li class="nav-item">
<a href="#environmental_impact" class="nav-link" data-bs-toggle="tab" data-bs-target="#environmental_impact">{% trans 'Environmental Impact' %}</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -105,6 +108,8 @@
{% include 'tabs/dpps.html' %} {% include 'tabs/dpps.html' %}
{% include 'tabs/environmental_impact.html' %}
<!-- Add a note popup --> <!-- Add a note popup -->
<div class="modal fade" id="addNoteModal" tabindex="-1" aria-labelledby="addNoteModalLabel" aria-hidden="true"> <div class="modal fade" id="addNoteModal" tabindex="-1" aria-labelledby="addNoteModalLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">

View file

@ -0,0 +1,45 @@
{% load i18n %}
<div class="tab-pane fade" id="environmental_impact">
<h5 class="card-title">{% trans 'Environmental Impact Details' %}</h5>
<hr />
<h6 class="mt-3 text-primary">{% trans 'While device is being used' %}</h6>
<div class="row mb-3">
<div class="col-sm-4 text-muted fw-bold">
{% trans 'CO2 Emissions' %}
</div>
<div class="col-sm-8">{{ impact.co2_emissions|default:'0.0' }} kg</div>
</div>
<div class="row mb-3">
<div class="col-sm-12 d-flex justify-content-end">
<div class="border p-2 rounded d-flex align-items-center">
<label for="algorithmSelect" class="text-muted fw-bold me-2">{% trans 'Algorithm Selector' %}</label>
<select class="form-select form-select-sm w-auto border-0 shadow-none" id="algorithmSelect" onchange="changeAlgorithm()">
<option value="dummy" selected>{% trans 'Dummy Algorithm' %}</option>
<option value="advanced">{% trans 'Advanced Algorithm' %}</option>
</select>
</div>
</div>
</div>
<div class="mt-4">
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#docsCollapse" aria-expanded="false" aria-controls="docsCollapse">
{% trans 'Read about the algorithm insights' %}
</button>
<div class="collapse mt-3" id="docsCollapse">
<div class="card card-body">
<div class="markdown-content">{{ impact.docs|safe }}</div>
</div>
</div>
</div>
</div>
<script>
function changeAlgorithm() {
var selectedAlgorithm = document.getElementById('algorithmSelect').value;
}
</script>

View file

@ -21,6 +21,7 @@ from evidence.models import UserProperty, SystemProperty
from lot.models import LotTag from lot.models import LotTag
from device.models import Device from device.models import Device
from device.forms import DeviceFormSet from device.forms import DeviceFormSet
from environmental_impact.algorithms.algorithm_factory import FactoryEnvironmentImpactAlgorithm
if settings.DPP: if settings.DPP:
from dpp.models import Proof from dpp.models import Proof
from dpp.api_dlt import PROOF_TYPE from dpp.api_dlt import PROOF_TYPE
@ -36,6 +37,7 @@ class DeviceLogMixin(DashboardView):
institution=self.request.user.institution institution=self.request.user.institution
) )
class NewDeviceView(DashboardView, FormView): class NewDeviceView(DashboardView, FormView):
template_name = "new_device.html" template_name = "new_device.html"
title = _("New Device") title = _("New Device")
@ -101,20 +103,28 @@ class DetailsView(DashboardView, TemplateView):
for x in _dpps: for x in _dpps:
dpp = "{}:{}".format(self.pk, x.signature) dpp = "{}:{}".format(self.pk, x.signature)
dpps.append((dpp, x.signature[:10], x)) dpps.append((dpp, x.signature[:10], x))
# TODO Specify algorithm via dropdown, if not specified, use default.
enviromental_impact_algorithm = FactoryEnvironmentImpactAlgorithm.run_environmental_impact_calculation(
"dummy_calc"
)
enviromental_impact = enviromental_impact_algorithm.get_device_environmental_impact(
self.object)
last_evidence = self.object.get_last_evidence() last_evidence = self.object.get_last_evidence()
uuids = self.object.uuids uuids = self.object.uuids
state_definitions = StateDefinition.objects.filter( state_definitions = StateDefinition.objects.filter(
institution=self.request.user.institution institution=self.request.user.institution
).order_by('order') ).order_by('order')
device_states = State.objects.filter(snapshot_uuid__in=uuids).order_by('-date') device_states = State.objects.filter(
snapshot_uuid__in=uuids).order_by('-date')
device_logs = DeviceLog.objects.filter( device_logs = DeviceLog.objects.filter(
snapshot_uuid__in=uuids).order_by('-date') snapshot_uuid__in=uuids).order_by('-date')
device_notes = Note.objects.filter(snapshot_uuid__in=uuids).order_by('-date') device_notes = Note.objects.filter(
snapshot_uuid__in=uuids).order_by('-date')
context.update({ context.update({
'object': self.object, 'object': self.object,
'snapshot': last_evidence, 'snapshot': last_evidence,
'lot_tags': lot_tags, 'lot_tags': lot_tags,
'impact': enviromental_impact,
'dpps': dpps, 'dpps': dpps,
"state_definitions": state_definitions, "state_definitions": state_definitions,
"device_states": device_states, "device_states": device_states,

View file

@ -87,6 +87,7 @@ INSTALLED_APPS = [
"action", "action",
"admin", "admin",
"api", "api",
"environmental_impact"
] ]
DPP = config("DPP", default=False, cast=bool) DPP = config("DPP", default=False, cast=bool)

View file

View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .dummy_algo.dummy_calculator import DummyEnvironmentalImpactAlgorithm
if TYPE_CHECKING:
from .algorithm_interface import EnvironmentImpactAlgorithm
class AlgorithmNames():
"""
Enum class for the different types of algorithms.
"""
DUMMY_CALC = "dummy_calc"
algorithm_names = {
DUMMY_CALC: DummyEnvironmentalImpactAlgorithm()
}
class FactoryEnvironmentImpactAlgorithm():
@staticmethod
def run_environmental_impact_calculation(algorithm_name: str) -> EnvironmentImpactAlgorithm:
try:
return AlgorithmNames.algorithm_names[algorithm_name]
except KeyError:
raise ValueError("Invalid algorithm name. Valid options are: " +
", ".join(AlgorithmNames.algorithm_names.keys()))

View file

@ -0,0 +1,11 @@
from abc import ABC, abstractmethod
from functools import lru_cache
from device.models import Device
from environmental_impact.models import EnvironmentalImpact
class EnvironmentImpactAlgorithm(ABC):
@abstractmethod
def get_device_environmental_impact(self, device: Device) -> EnvironmentalImpact:
pass

View file

@ -0,0 +1,8 @@
import markdown
def render_docs(file_path):
with open(file_path, 'r') as file:
markdown_content = file.read()
html_content = markdown.markdown(markdown_content)
return html_content

View file

@ -0,0 +1,24 @@
## _Dummy_ Algorithm Docs
This function calculates the **carbon footprint** of a device based on its power consumption and usage time.
### 1. Define Constants
- `avg_watts = 40`: Assumed average power consumption of the device in watts.
- `co2_per_kwh = 0.475`: CO₂ emissions per kilowatt-hour (kg CO₂/kWh), based on an estimated energy mix.
### 2. Retrieve Device Usage
- Calls `get_power_on_hours_from(device)`, which returns the total **power-on hours** for the device.
### 3. Compute Energy Consumption
- Converts power consumption to **kilowatt-hours (kWh)** using:
```
energy_kwh = (power_on_hours * avg_watts) / 1000
```
- This accounts for the total energy used over the recorded operational period.
### 4. Calculate CO₂ Emissions
- Multiplies the **energy consumption (kWh)** by the **CO₂ emission factor**:
```
co2_emissions = energy_kwh * co2_per_kwh
```
- This provides the estimated **CO₂ emissions in kilograms**.

View file

@ -0,0 +1,40 @@
import os
from device.models import Device
from ..algorithm_interface import EnvironmentImpactAlgorithm
from environmental_impact.models import EnvironmentalImpact
from ..docs_renderer import render_docs
class DummyEnvironmentalImpactAlgorithm(EnvironmentImpactAlgorithm):
def get_device_environmental_impact(self, device: Device) -> EnvironmentalImpact:
# TODO Make a constants file / class
avg_watts = 40 # Arbitrary laptop average consumption
co2_per_kwh = 0.475
power_on_hours = self.get_power_on_hours_from(device)
energy_kwh = (power_on_hours * avg_watts) / 1000
co2_emissions = energy_kwh * co2_per_kwh
current_dir = os.path.dirname(__file__)
docs_path = os.path.join(current_dir, 'docs.md')
docs = render_docs(docs_path)
return EnvironmentalImpact(co2_emissions=co2_emissions, docs=docs)
def get_power_on_hours_from(self, device: Device) -> int:
# TODO how do I check if the device is a legacy workbench? Is there a better way?
is_legacy_workbench = False if device.last_evidence.inxi else True
if not is_legacy_workbench:
storage_components = next((comp for comp in device.components if comp['type'] == 'Storage'), None)
str_time = storage_components.get('time of used', "")
else:
str_time = ""
uptime_in_hours = self.convert_str_time_to_hours(
str_time, is_legacy_workbench)
return uptime_in_hours
def convert_str_time_to_hours(self, time_str: str, is_legacy_workbench: bool) -> int:
if is_legacy_workbench:
return -1 # TODO Power on hours not available in legacy workbench
else:
multipliers = {'y': 365 * 24, 'd': 24, 'h': 1}
return sum(int(part[:-1]) * multipliers[part[-1]] for part in time_str.split())

View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class EnvironmentalImpactConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "environmental_impact"

View file

@ -0,0 +1,9 @@
from dataclasses import dataclass
from django.db import models
@dataclass
class EnvironmentalImpact:
carbon_saved: float = 0.0
co2_emissions: float = 0.0
docs: str = ""

View file

View file

@ -0,0 +1,44 @@
from unittest.mock import patch
import uuid
from django.test import TestCase
from device.models import Device
from environmental_impact.models import EnvironmentalImpact
from environmental_impact.algorithms.dummy_algo.dummy_calculator import DummyEnvironmentalImpactAlgorithm
from evidence.models import Evidence
class DummyEnvironmentalImpactAlgorithmTests(TestCase):
@patch('evidence.models.Evidence.get_doc', return_value={'credentialSubject': {}})
@patch('evidence.models.Evidence.get_time', return_value=None)
def setUp(self, mock_get_time, mock_get_doc):
self.device = Device(id='1')
evidence = self.device.last_evidence = Evidence(uuid=uuid.uuid4())
evidence.inxi = True
evidence.doc = {'credentialSubject': {}}
self.algorithm = DummyEnvironmentalImpactAlgorithm()
def test_get_power_on_hours_from_legacy_device(self):
# TODO is there a way to check that?
pass
@patch('evidence.models.Evidence.get_components', return_value=[0, 0, 0, 0, 0, 0, 0, 0, 0, {'time of used': '1y 2d 3h'}])
def test_get_power_on_hours_from_inxi_device(self, mock_get_components):
hours = self.algorithm.get_power_on_hours_from(self.device)
self.assertEqual(
hours, 8811, "Inxi-parsed devices should correctly compute power-on hours")
@patch('evidence.models.Evidence.get_components', return_value=[0, 0, 0, 0, 0, 0, 0, 0, 0, {'time of used': '1y 2d 3h'}])
def test_convert_str_time_to_hours(self, mock_get_components):
result = self.algorithm.convert_str_time_to_hours('1y 2d 3h', False)
self.assertEqual(
result, 8811, "String to hours conversion should match expected output")
@patch('evidence.models.Evidence.get_components', return_value=[0, 0, 0, 0, 0, 0, 0, 0, 0, {'time of used': '1y 2d 3h'}])
def test_environmental_impact_calculation(self, mock_get_components):
impact = self.algorithm.get_device_environmental_impact(self.device)
self.assertIsInstance(impact, EnvironmentalImpact,
"Output should be an EnvironmentalImpact instance")
expected_co2 = 8811 * 40 * 0.475 / 1000
self.assertAlmostEqual(impact.co2_emissions, expected_co2,
2, "CO2 emissions calculation should be accurate")

View file

@ -0,0 +1,17 @@
from environmental_impact.algorithms.algorithm_factory import FactoryEnvironmentImpactAlgorithm
from django.test import TestCase
from environmental_impact.algorithms.dummy_algo.dummy_calculator import DummyEnvironmentalImpactAlgorithm
class FactoryEnvironmentImpactAlgorithmTests(TestCase):
def test_valid_algorithm_name(self):
algorithm = FactoryEnvironmentImpactAlgorithm.run_environmental_impact_calculation(
'dummy_calc')
self.assertIsInstance(algorithm, DummyEnvironmentalImpactAlgorithm,
"Factory should return a DummyEnvironmentalImpactAlgorithm instance")
def test_invalid_algorithm_name(self):
with self.assertRaises(ValueError):
FactoryEnvironmentImpactAlgorithm.run_environmental_impact_calculation(
'invalid_calc')

View file

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.