From e4ba693d686b967b9247cf39d4132292faa2599c Mon Sep 17 00:00:00 2001 From: codingfabi Date: Sun, 5 May 2024 17:03:32 +0200 Subject: [PATCH 1/5] add a calculation function that works generically for all trips --- co2calculator/calculate.py | 23 +++++++++++++++++++++++ co2calculator/util.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 co2calculator/util.py diff --git a/co2calculator/calculate.py b/co2calculator/calculate.py index d240d3d..dd8c35f 100644 --- a/co2calculator/calculate.py +++ b/co2calculator/calculate.py @@ -17,6 +17,7 @@ calc_co2_train, calc_co2_tram, ) +from co2calculator.util import get_calc_function_from_transport_mode from ._types import Kilogram, Kilometer from .constants import ( @@ -115,6 +116,28 @@ def calc_co2_heating( return consumption_kwh * area_share / KWH_TO_TJ * co2e +def calc_co2_trip( + distance: Kilometer | None, + transportation_mode: TransportationMode, + custom_emission_factor: Kilogram | None = None, + options: dict = None, +) -> Kilogram: + """Function to compute emissions for a trip based on distance + + :param distance: Distance travelled in km + :param transportation_mode: mode of transport. For options, see TransportationMode enum. + :param custom_emission_factor: custom emission factor in kg/km. If provided, this will be used instead of the included emission factors. + :param options: options for the trip. Type must match transportation mode. + + :return: Emissions of the business trip in co2 equivalents. + """ + if custom_emission_factor is not None: + return distance * custom_emission_factor + else: + calc_function = get_calc_function_from_transport_mode(transportation_mode) + return calc_function(distance, options) + + def calc_co2_businesstrip( transportation_mode: TransportationMode, start=None, diff --git a/co2calculator/util.py b/co2calculator/util.py new file mode 100644 index 0000000..973b7c4 --- /dev/null +++ b/co2calculator/util.py @@ -0,0 +1,29 @@ +"""Generic collection of util functions and maps.""" + +from co2calculator.constants import TransportationMode +from co2calculator.mobility.calculate_mobility import ( + calc_co2_bicycle, + calc_co2_bus, + calc_co2_car, + calc_co2_ferry, + calc_co2_motorbike, + calc_co2_pedelec, + calc_co2_train, + calc_co2_tram, +) + + +def get_calc_function_from_transport_mode( + transport_mode: TransportationMode, +) -> callable: + transportation_mode_calc_function_map = { + TransportationMode.CAR: calc_co2_car, + TransportationMode.MOTORBIKE: calc_co2_motorbike, + TransportationMode.BUS: calc_co2_bus, + TransportationMode.TRAIN: calc_co2_train, + TransportationMode.BICYCLE: calc_co2_bicycle, + TransportationMode.TRAM: calc_co2_tram, + TransportationMode.FERRY: calc_co2_ferry, + TransportationMode.PEDELEC: calc_co2_pedelec, + } + return transportation_mode_calc_function_map[transport_mode] From 8b11d6db13fbf03ea7f733b7f82e2f91e7fe7125 Mon Sep 17 00:00:00 2001 From: codingfabi Date: Sun, 5 May 2024 17:08:56 +0200 Subject: [PATCH 2/5] add settings.json to vscode --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 8748661..ba56804 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ __pycache__/ # C extensions *.so +#.vscode +settings.json + # Distribution / packaging .Python build/ From 69e187410f4381c91f6f740fabb89568b778e6eb Mon Sep 17 00:00:00 2001 From: codingfabi Date: Sun, 5 May 2024 17:21:26 +0200 Subject: [PATCH 3/5] add tests --- co2calculator/calculate.py | 6 ++++ tests/unit/test_calculate.py | 66 ++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/co2calculator/calculate.py b/co2calculator/calculate.py index dd8c35f..e1e20a7 100644 --- a/co2calculator/calculate.py +++ b/co2calculator/calculate.py @@ -132,8 +132,14 @@ def calc_co2_trip( :return: Emissions of the business trip in co2 equivalents. """ if custom_emission_factor is not None: + print("Ignoring transportation mode as custom emission factor is set") return distance * custom_emission_factor else: + # check for invalid transportation mode + assert transportation_mode.lower() in ( + item.value for item in TransportationMode + ) + # pass the distance and options to the respective function calc_function = get_calc_function_from_transport_mode(transportation_mode) return calc_function(distance, options) diff --git a/tests/unit/test_calculate.py b/tests/unit/test_calculate.py index 1e9dd9e..3ab581e 100644 --- a/tests/unit/test_calculate.py +++ b/tests/unit/test_calculate.py @@ -11,6 +11,72 @@ from co2calculator.constants import RangeCategory +@pytest.mark.parametrize( + "distance, transportation_mode,options,custom_emission_factor,expected_emissions", + [ + pytest.param(100, "car", None, None, 21.5, id="basic car trip"), + pytest.param( + 100, "car", None, 0.1, 10.0, id="car trip with custom emission factor" + ), + ], +) +def test_calc_co2_trip( + distance: float, + transportation_mode: str, + options: dict, + custom_emission_factor: float, + expected_emissions: float, +): + """Test: Calculate car-trip emissions based on given distance. + Expect: Returns emissions and distance. + """ + actual_emissions = candidate.calc_co2_trip( + distance=distance, + transportation_mode=transportation_mode, + options=options, + custom_emission_factor=custom_emission_factor, + ) + + assert actual_emissions == expected_emissions + + +def test_calc_co2_trip_invalid_transportation_mode(): + """Test: Calculate car-trip emissions with invalid transportation mode. + Expect: Test fails. + """ + with pytest.raises(AssertionError): + candidate.calc_co2_trip( + distance=100, + transportation_mode="invalid", + options=None, + custom_emission_factor=None, + ) + + +def test_calc_co2_trip_invalid_options_for_transportation_mode(): + """Test: Should raise exception if options are not valid for transportation mode. + Expect: Test fails. + """ + with pytest.raises(ValueError): + candidate.calc_co2_trip( + distance=100, + transportation_mode="car", + options={"size": "big"}, + custom_emission_factor=None, + ) + + +def test_calc_co2_trip_ignore_error_on_custom_emission_factor(): + """Test: Should ignore invalid transportation mode if custom emission factor is set""" + result = candidate.calc_co2_trip( + distance=100, + transportation_mode="invalid", + options=None, + custom_emission_factor=0.1, + ) + assert result == 10 + + # @pytest.mark.skip( # reason="Failing right now, but units will change anyways. let's check after the co2factors are updated" # ) From a595340b14b9bf0f4cfc8c8773a1eeb07c494bfb Mon Sep 17 00:00:00 2001 From: codingfabi Date: Sun, 5 May 2024 17:24:31 +0200 Subject: [PATCH 4/5] remove leftover business trip function --- co2calculator/calculate.py | 91 ---------------------- tests/functional/test_calculate.py | 119 ----------------------------- tests/unit/test_calculate.py | 45 ----------- 3 files changed, 255 deletions(-) diff --git a/co2calculator/calculate.py b/co2calculator/calculate.py index e1e20a7..c6fe72c 100644 --- a/co2calculator/calculate.py +++ b/co2calculator/calculate.py @@ -144,97 +144,6 @@ def calc_co2_trip( return calc_function(distance, options) -def calc_co2_businesstrip( - transportation_mode: TransportationMode, - start=None, - destination=None, - distance: Kilometer = None, - size: Size = None, - fuel_type: CarFuel | BusFuel | TrainFuel = None, - occupancy: int = None, - seating: FlightClass | FerryClass = None, - passengers: int = None, - roundtrip: bool = False, -) -> Tuple[Kilogram, Kilometer, str, str]: - """Function to compute emissions for business trips based on transportation mode and trip specifics - - :param transportation_mode: mode of transport [car, bus, train, plane, ferry] - :param start: Start of the trip (alternatively, distance can be provided) - :param destination: Destination of the trip (alternatively, distance can be provided) - :param distance: Distance travelled in km (alternatively, start and destination can be provided) - :param size: Size class of the vehicle [small, medium, large, average] - only used for car and bus - :param fuel_type: Fuel type of the vehicle - [average, cng, diesel, electric, gasoline, hybrid, hydrogen, plug-in_hybrid] - - only used for car, bus and train - :param occupancy: Occupancy of the vehicle in % [20, 50, 80, 100] - only used for bus - :param seating: seating class ["average", "Economy class", "Premium economy class", "Business class", "First class"] - - only used for plane - :param passengers: Number of passengers in the vehicle (including the participant), number from 1 to 9 - - only used for car - :param roundtrip: whether the trip is a round trip or not [True, False] - :type transportation_mode: str - :type distance: Kilometer - :type size: str - :type fuel_type: str - :type occupancy: int - :type seating: str - :type passengers: int - :type roundtrip: bool - :return: Emissions of the business trip in co2 equivalents, - Distance of the business trip, - Range category of the business trip [very short haul, short haul, medium haul, long haul] - Range description (i.e., what range of distances does to category correspond to) - :rtype: tuple[Kilogram, Kilometer, str, str] - """ - - # Evaluate if distance- or stop-based request. - # Rules: - # - `distance` is dominant; - # - if distance not provided, take stops; - # - if stops not available, raise error; - # In general: - # - If stop-based, calculate distance first, then continue only distance-based - - if not distance: - request = create_distance_request(start, destination, transportation_mode) - distance = get_distance(request) - - if transportation_mode == TransportationMode.CAR: - emissions = calc_co2_car( - distance=distance, - options={}, - ) - elif transportation_mode == TransportationMode.BUS: - emissions = calc_co2_bus( - distance=distance, - options={}, - ) - - elif transportation_mode == TransportationMode.TRAIN: - emissions = calc_co2_train( - distance=distance, - options={}, - ) - - elif transportation_mode == TransportationMode.PLANE: - emissions = calc_co2_plane(distance, options={}) - - elif transportation_mode == TransportationMode.FERRY: - emissions = calc_co2_ferry(distance, options={}) - - else: - raise ValueError( - f"No emission factor available for the specified mode of transport '{transportation_mode}'." - ) - if roundtrip is True: - emissions *= 2 - - # categorize according to distance (range) - range_category, range_description = range_categories(distance) - - return emissions, distance, range_category, range_description - - def calc_co2_commuting( transportation_mode: TransportationMode, weekly_distance: Kilometer, diff --git a/tests/functional/test_calculate.py b/tests/functional/test_calculate.py index db80145..1f8327a 100644 --- a/tests/functional/test_calculate.py +++ b/tests/functional/test_calculate.py @@ -24,125 +24,6 @@ # TODO: Mock all calls to openrouteservice.org (or openrouteservice package) -class TestCalculateBusinessTrip: - """Functional testing of `calc_co2_businesstrip` calls from backend""" - - @pytest.mark.parametrize( - "transportation_mode, expected_emissions", - [ - pytest.param("car", 9.03, id="transportation_mode: 'car'"), - pytest.param("bus", 1.65, id="transportation_mode: 'bus'"), - pytest.param("train", 1.38, id="transportation_mode: 'train'"), - ], - ) - def test_calc_co2_business_trip__distance_based( - self, transportation_mode: str, expected_emissions: float - ) -> None: - """Scenario: Backend asks for business trip calculation with distance input. - Test: co2 calculation for business trip - Expect: Happy path - """ - actual_emissions, _, _, _ = candidate.calc_co2_businesstrip( - transportation_mode=transportation_mode, - start=None, - destination=None, - distance=42.0, - size=None, - fuel_type=None, - occupancy=None, - seating=None, - passengers=None, - roundtrip=False, - ) - - assert round(actual_emissions, 2) == expected_emissions - - @pytest.mark.parametrize( - "transportation_mode, start, destination, expected_emissions", - [ - pytest.param( - "car", - { - "address": "Im Neuenheimer Feld 348", - "locality": "Heidelberg", - "country": "Germany", - }, - { - "country": "Germany", - "locality": "Berlin", - "address": "Alexanderplatz 1", - }, - 134.71, - id="transportation_mode: 'car'", - ), - pytest.param( - "bus", - { - "address": "Im Neuenheimer Feld 348", - "locality": "Heidelberg", - "country": "Germany", - }, - { - "country": "Germany", - "locality": "Berlin", - "address": "Alexanderplatz 1", - }, - 28.3, - id="transportation_mode: 'bus'", - ), - pytest.param( - "train", - {"station_name": "Heidelberg Hbf", "country": "DE"}, - {"station_name": "Berlin Hbf", "country": "DE"}, - 24.66, - id="transportation_mode: 'train'", - ), - pytest.param( - "plane", - "FRA", - "BER", - 129.16, - id="transportation_mode: 'plane'", - ), - pytest.param( - "ferry", - {"locality": "Friedrichshafen", "country": "DE"}, - {"locality": "Konstanz", "country": "DE"}, - 2.57, - id="transportation_mode: 'ferry'", - ), - ], - ) - @pytest.mark.skip(reason="API Key missing for test setup. TODO: Mock Response") - def test_calc_co2_business_trip__stops_based( - self, - transportation_mode: str, - start: Dict, - destination: Dict, - expected_emissions: float, - ) -> None: - """Scenario: Backend asks for business trip calculation with distance input. - Test: co2 calculation for business trip - Expect: Happy path - """ - # NOTE: IMPORTANT - Test currently makes real web calls! - # TODO: Record responses and mock external calls! - actual_emissions, _, _, _ = candidate.calc_co2_businesstrip( - transportation_mode=transportation_mode, - start=start, - destination=destination, - distance=None, - size=None, - fuel_type=None, - occupancy=None, - seating=None, - passengers=None, - roundtrip=False, - ) - - assert round(actual_emissions, 2) == expected_emissions - - class TestCalculateCommuting: """Functional testing of `calc_co2_commuting` calls from backend""" diff --git a/tests/unit/test_calculate.py b/tests/unit/test_calculate.py index 3ab581e..7e7a372 100644 --- a/tests/unit/test_calculate.py +++ b/tests/unit/test_calculate.py @@ -179,48 +179,3 @@ def test_range_categories_negative_distance(): """ with pytest.raises(ValueError): candidate.range_categories(-20) - - -@pytest.mark.parametrize( - "transportation_mode, expected_method", - [ - pytest.param("car", "calc_co2_car", id="Car"), - pytest.param("bus", "calc_co2_bus", id="Bus"), - pytest.param("train", "calc_co2_train", id="Train"), - pytest.param("plane", "calc_co2_plane", id="Plane"), - pytest.param("ferry", "calc_co2_ferry", id="Ferry"), - ], -) -def test_calc_co2_businesstrip( - mocker: MockerFixture, transportation_mode: str, expected_method: str -) -> None: - """Scenario: calc_co2_businesstrip is the interface to calculate co2emissions - for different types of transportation modes. - Test: Business trip calculation interface - Expect: co2calculations for specific transportation mode is called - """ - # Patch the expected method to assert if it was called - patched_method = mocker.patch.object( - candidate, expected_method, return_value=(0.42, 42) - ) - - # Patch other methods called by the test candidate - mocker.patch.object( - candidate, "range_categories", return_value=("very short haul", "below 500 km") - ) - - # Call and assert - candidate.calc_co2_businesstrip( - transportation_mode=transportation_mode, - start=None, - destination=None, - distance=42, - size=None, - fuel_type=None, - occupancy=None, - seating=None, - passengers=None, - roundtrip=False, - ) - - patched_method.assert_called_once() From f3544831eb0a1b63a823fc8e03516ac98539486b Mon Sep 17 00:00:00 2001 From: codingfabi Date: Sun, 5 May 2024 17:27:38 +0200 Subject: [PATCH 5/5] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6e0d6a1..9019076 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "co2calculator" -version = "0.1.2b" +version = "0.1.5b" description = "Calculates CO2e emissions from travel and commuting as well as heating and electricity consumption of buildings" authors = ["pledge4future Team "] license = "GPL 3"