From f6a18d6b67bde24f06790301ce21a0a70cd55a7c Mon Sep 17 00:00:00 2001 From: Daniel Haselhan Date: Tue, 17 Dec 2024 16:27:27 -0800 Subject: [PATCH] feat: Add default CI to Fuel Category * Default CI is actually associated to fuel category and used as a fallback in several scenarios * The one covered here is Other, where if the user selects other it needs to use the CI of the Cateory, NOT the Type. --- .../versions/2024-12-17-23-58_851e09cf8661.py | 62 +++++++++++++ backend/lcfs/db/models/fuel/FuelCategory.py | 11 ++- .../db/seeders/common/seed_fuel_data.json | 11 ++- .../test_compliance_report_repo.py | 8 +- .../tests/fuel_code/test_fuel_code_repo.py | 92 ++++++++++++++++++- .../web/api/allocation_agreement/services.py | 8 +- backend/lcfs/web/api/fuel_code/repo.py | 15 ++- 7 files changed, 187 insertions(+), 20 deletions(-) create mode 100644 backend/lcfs/db/migrations/versions/2024-12-17-23-58_851e09cf8661.py diff --git a/backend/lcfs/db/migrations/versions/2024-12-17-23-58_851e09cf8661.py b/backend/lcfs/db/migrations/versions/2024-12-17-23-58_851e09cf8661.py new file mode 100644 index 000000000..e7f10a2c3 --- /dev/null +++ b/backend/lcfs/db/migrations/versions/2024-12-17-23-58_851e09cf8661.py @@ -0,0 +1,62 @@ +"""Add Default CI to Categories + +Revision ID: 851e09cf8661 +Revises: 5b374dd97469 +Create Date: 2024-12-17 23:58:07.462215 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "851e09cf8661" +down_revision = "5b374dd97469" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "fuel_category", + sa.Column( + "default_carbon_intensity", + sa.Numeric(precision=10, scale=2), + nullable=True, + comment="Default carbon intensity of the fuel category", + ), + ) + + # Populate default values for existing records + op.execute( + """ + UPDATE "fuel_category" SET "default_carbon_intensity" = 88.83 WHERE "description" = 'Jet fuel'; + """ + ) + op.execute( + """ + UPDATE "fuel_category" SET "default_carbon_intensity" = 100.21 WHERE "description" = 'Diesel'; + """ + ) + op.execute( + """ + UPDATE "fuel_category" SET "default_carbon_intensity" = 93.67 WHERE "description" = 'Gasoline'; + """ + ) + + # Now set the column to NOT NULL after populating defaults + op.alter_column( + "fuel_category", + "default_carbon_intensity", + existing_type=sa.Numeric(precision=10, scale=2), + nullable=False, + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("fuel_category", "default_carbon_intensity") + # ### end Alembic commands ### diff --git a/backend/lcfs/db/models/fuel/FuelCategory.py b/backend/lcfs/db/models/fuel/FuelCategory.py index f06dd753a..f4d3f0791 100644 --- a/backend/lcfs/db/models/fuel/FuelCategory.py +++ b/backend/lcfs/db/models/fuel/FuelCategory.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, Text, Enum +from sqlalchemy import Column, Integer, Text, Enum, Float, Numeric from lcfs.db.base import BaseModel, Auditable, DisplayOrder, EffectiveDates from sqlalchemy.orm import relationship @@ -25,7 +25,14 @@ class FuelCategory(BaseModel, Auditable, DisplayOrder, EffectiveDates): nullable=False, comment="Name of the fuel category", ) - description = Column(Text, nullable=True, comment="Description of the fuel categor") + description = Column( + Text, nullable=True, comment="Description of the fuel category" + ) + default_carbon_intensity = Column( + Numeric(10, 2), + nullable=False, + comment="Default carbon intensity of the fuel category", + ) energy_effectiveness_ratio = relationship("EnergyEffectivenessRatio") target_carbon_intensities = relationship( diff --git a/backend/lcfs/db/seeders/common/seed_fuel_data.json b/backend/lcfs/db/seeders/common/seed_fuel_data.json index ac68f21d7..c80c5f972 100644 --- a/backend/lcfs/db/seeders/common/seed_fuel_data.json +++ b/backend/lcfs/db/seeders/common/seed_fuel_data.json @@ -249,17 +249,20 @@ { "fuel_category_id": 1, "category": "Gasoline", - "description": "Gasoline" + "description": "Gasoline", + "default_carbon_intensity": 93.67 }, { "fuel_category_id": 2, "category": "Diesel", - "description": "Diesel" + "description": "Diesel", + "default_carbon_intensity": 100.21 }, { "fuel_category_id": 3, "category": "Jet fuel", - "description": "Jet fuel" + "description": "Jet fuel", + "default_carbon_intensity": 88.83 } ], "end_use_types": [ @@ -1092,4 +1095,4 @@ "display_order": 4 } ] -} \ No newline at end of file +} diff --git a/backend/lcfs/tests/compliance_report/test_compliance_report_repo.py b/backend/lcfs/tests/compliance_report/test_compliance_report_repo.py index 84ed2520b..fdce6f9ee 100644 --- a/backend/lcfs/tests/compliance_report/test_compliance_report_repo.py +++ b/backend/lcfs/tests/compliance_report/test_compliance_report_repo.py @@ -48,8 +48,12 @@ async def expected_uses(dbsession): @pytest.fixture async def fuel_categories(dbsession): fuel_categories = [ - FuelCategory(fuel_category_id=998, category="Gasoline"), - FuelCategory(fuel_category_id=999, category="Diesel"), + FuelCategory( + fuel_category_id=998, category="Gasoline", default_carbon_intensity=0 + ), + FuelCategory( + fuel_category_id=999, category="Diesel", default_carbon_intensity=0 + ), ] dbsession.add_all(fuel_categories) diff --git a/backend/lcfs/tests/fuel_code/test_fuel_code_repo.py b/backend/lcfs/tests/fuel_code/test_fuel_code_repo.py index 91b3d6b09..99bf2341d 100644 --- a/backend/lcfs/tests/fuel_code/test_fuel_code_repo.py +++ b/backend/lcfs/tests/fuel_code/test_fuel_code_repo.py @@ -145,7 +145,9 @@ async def test_get_fuel_type_by_id_not_found(fuel_code_repo, mock_db): @pytest.mark.anyio async def test_get_fuel_categories(fuel_code_repo, mock_db): - mock_fc = FuelCategory(fuel_category_id=1, category="Renewable") + mock_fc = FuelCategory( + fuel_category_id=1, category="Renewable", default_carbon_intensity=0 + ) mock_result = MagicMock() mock_result.scalars.return_value.all.return_value = [mock_fc] mock_db.execute.return_value = mock_result @@ -157,12 +159,14 @@ async def test_get_fuel_categories(fuel_code_repo, mock_db): @pytest.mark.anyio async def test_get_fuel_category_by_name(fuel_code_repo, mock_db): - mock_fc = FuelCategory(fuel_category_id=2, category="Fossil") + mock_fc = FuelCategory( + fuel_category_id=2, category="Fossil", default_carbon_intensity=0 + ) mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = mock_fc mock_db.execute.return_value = mock_result - result = await fuel_code_repo.get_fuel_category_by_name("Fossil") + result = await fuel_code_repo.get_fuel_category_by(category="Fossil") assert result == mock_fc @@ -646,6 +650,88 @@ async def test_get_standardized_fuel_data(fuel_code_repo, mock_db): assert result.uci == 5.0 +@pytest.mark.anyio +async def test_get_standardized_fuel_data_unrecognized(fuel_code_repo, mock_db): + # Mock an unrecognized fuel type + mock_fuel_type = FuelType( + fuel_type_id=1, + fuel_type="UnknownFuel", + default_carbon_intensity=None, + unrecognized=True, + ) + + # Mock a fuel category with a default CI + mock_fuel_category = FuelCategory( + fuel_category_id=2, category="SomeCategory", default_carbon_intensity=93.67 + ) + + # The repo uses get_one to get the fuel type. + mock_db.get_one.return_value = mock_fuel_type + + # Mock the repo method to get the fuel category + fuel_code_repo.get_fuel_category_by = AsyncMock(return_value=mock_fuel_category) + + # Setup side effects for subsequent queries: + # Energy Density + energy_density_result = MagicMock( + scalars=MagicMock( + return_value=MagicMock( + first=MagicMock(return_value=EnergyDensity(density=35.0)) + ) + ) + ) + # EER + eer_result = MagicMock( + scalars=MagicMock( + return_value=MagicMock( + first=MagicMock(return_value=EnergyEffectivenessRatio(ratio=2.0)) + ) + ) + ) + # Target Carbon Intensities + tci_result = MagicMock( + scalars=MagicMock( + return_value=MagicMock( + all=MagicMock( + return_value=[TargetCarbonIntensity(target_carbon_intensity=50.0)] + ) + ) + ) + ) + # Additional Carbon Intensity + aci_result = MagicMock( + scalars=MagicMock( + return_value=MagicMock( + one_or_none=MagicMock( + return_value=AdditionalCarbonIntensity(intensity=5.0) + ) + ) + ) + ) + + # Set the side_effect for the mock_db.execute calls in the order they're invoked + mock_db.execute.side_effect = [ + energy_density_result, + eer_result, + tci_result, + aci_result, + ] + + result = await fuel_code_repo.get_standardized_fuel_data( + fuel_type_id=1, fuel_category_id=2, end_use_id=3, compliance_period="2024" + ) + + # Since fuel_type is unrecognized, it should use the FuelCategory's default CI + assert result.effective_carbon_intensity == 93.67 + assert result.target_ci == 50.0 + assert result.eer == 2.0 + assert result.energy_density == 35.0 + assert result.uci == 5.0 + + # Ensure get_fuel_category_by was called once with the correct parameter + fuel_code_repo.get_fuel_category_by.assert_awaited_once_with(fuel_category_id=2) + + @pytest.mark.anyio async def test_get_additional_carbon_intensity(fuel_code_repo, mock_db): aci = AdditionalCarbonIntensity(additional_uci_id=1, intensity=10.0) diff --git a/backend/lcfs/web/api/allocation_agreement/services.py b/backend/lcfs/web/api/allocation_agreement/services.py index 31aa4ffdf..59ada9e41 100644 --- a/backend/lcfs/web/api/allocation_agreement/services.py +++ b/backend/lcfs/web/api/allocation_agreement/services.py @@ -49,8 +49,8 @@ async def convert_to_model( allocation_agreement.allocation_transaction_type ) ) - fuel_category = await self.fuel_repo.get_fuel_category_by_name( - allocation_agreement.fuel_category + fuel_category = await self.fuel_repo.get_fuel_category_by( + category=allocation_agreement.fuel_category ) fuel_type = await self.fuel_repo.get_fuel_type_by_name( allocation_agreement.fuel_type @@ -226,8 +226,8 @@ async def update_allocation_agreement( != allocation_agreement_data.fuel_category ): existing_allocation_agreement.fuel_category = ( - await self.fuel_repo.get_fuel_category_by_name( - allocation_agreement_data.fuel_category + await self.fuel_repo.get_fuel_category_by( + category=allocation_agreement_data.fuel_category ) ) diff --git a/backend/lcfs/web/api/fuel_code/repo.py b/backend/lcfs/web/api/fuel_code/repo.py index 594bd156f..325c3579b 100644 --- a/backend/lcfs/web/api/fuel_code/repo.py +++ b/backend/lcfs/web/api/fuel_code/repo.py @@ -1,10 +1,10 @@ +from dataclasses import dataclass from datetime import date from typing import List, Dict, Any, Union, Optional, Sequence import structlog from fastapi import Depends from sqlalchemy import and_, or_, select, func, text, update, distinct, desc, asc -from sqlalchemy.dialects import postgresql from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload, contains_eager @@ -33,7 +33,6 @@ ) from lcfs.web.api.fuel_code.schema import FuelCodeCloneSchema, FuelCodeSchema from lcfs.web.core.decorators import repo_handler -from dataclasses import dataclass logger = structlog.get_logger(__name__) @@ -175,9 +174,9 @@ async def get_fuel_categories(self) -> List[FuelCategory]: return (await self.db.execute(select(FuelCategory))).scalars().all() @repo_handler - async def get_fuel_category_by_name(self, name: str) -> FuelCategory: - """Get a fuel category by its name""" - result = await self.db.execute(select(FuelCategory).filter_by(category=name)) + async def get_fuel_category_by(self, **filters: Any) -> FuelCategory: + """Get a fuel category by any filters""" + result = await self.db.execute(select(FuelCategory).filter_by(**filters)) return result.scalar_one_or_none() @repo_handler @@ -861,6 +860,12 @@ async def get_standardized_fuel_data( if fuel_code_id: fuel_code = await self.get_fuel_code(fuel_code_id) effective_carbon_intensity = fuel_code.carbon_intensity + # Other Fuel uses the Default CI of the Category + elif fuel_type.unrecognized: + fuel_category = await self.get_fuel_category_by( + fuel_category_id=fuel_category_id + ) + effective_carbon_intensity = fuel_category.default_carbon_intensity else: effective_carbon_intensity = fuel_type.default_carbon_intensity