From 063606922f1e693471cac0ca7deb01d67b4dbf48 Mon Sep 17 00:00:00 2001 From: PH Tools Date: Sat, 16 Nov 2024 18:46:44 -0500 Subject: [PATCH] Update ADORB Cost - Update the ADORB cost calc to fix the off-by-1 year error - Update all tests with verification case data from Phius-GUI - Add new Present-Value calculation and supportint type --- ph_adorb/adorb_cost.py | 175 ++++++++++++----- ph_adorb/from_HBJSON/create_variant.py | 15 +- ph_adorb/tables/variant.py | 63 ++++-- ph_adorb/variant.py | 122 +++++++----- ph_adorb/yearly_values.py | 11 ++ tests/test_adorb_cost.py | 260 ++++++++++++++++++++++--- 6 files changed, 502 insertions(+), 144 deletions(-) diff --git a/ph_adorb/adorb_cost.py b/ph_adorb/adorb_cost.py index f8a0366..b3e847b 100644 --- a/ph_adorb/adorb_cost.py +++ b/ph_adorb/adorb_cost.py @@ -10,88 +10,165 @@ electrical service capacity. """ - +import logging import pandas as pd -from ph_adorb.yearly_values import YearlyCost +from ph_adorb.yearly_values import YearlyCost, YearlyPresentValueFactor + +logger = logging.getLogger(__name__) # -- Constants # TODO: Support non-USA countries. # TODO: Move these to be variables someplace... USA_NUM_YEARS_TO_TRANSITION = 30 -USA_NATIONAL_TRANSITION_COST = 4_500_000_000_000.00 -NAMEPLATE_CAPACITY_INCREASE_GW = 1_600.00 -USA_TRANSITION_COST_FACTOR = USA_NATIONAL_TRANSITION_COST / (NAMEPLATE_CAPACITY_INCREASE_GW * 1_000_000_000.00) +USA_NATIONAL_TRANSITION_COST = 4.5e12 +NAMEPLATE_CAPACITY_INCREASE_GW = 1_600 +USA_TRANSITION_COST_FACTOR = USA_NATIONAL_TRANSITION_COST / (NAMEPLATE_CAPACITY_INCREASE_GW * 1e9) # --------------------------------------------------------------------------------------- -def pv_direct_energy_cost( - _year: int, _annual_cost_electric: float, _annual_cost_gas: float, _discount_rate: float = 0.02 +def present_value_factor(_year: int, _discount_rate: float) -> YearlyPresentValueFactor: + """Calculate the present value factor for a given year.""" + rate = (1 + _discount_rate) ** (_year + 1) + return YearlyPresentValueFactor(rate, _year + 1) + + +def energy_purchase_cost_PV( + _pv_factor: YearlyPresentValueFactor, _annual_cost_electric: float, _annual_cost_gas: float ) -> float: """Calculate the total direct energy cost for a given year.""" - try: - return (_annual_cost_electric + _annual_cost_gas) / ((1 + _discount_rate) ** _year) - except ZeroDivisionError: + logger.info(f"energy_purchase_cost_PV(year={_pv_factor.year}, factor={_pv_factor.factor :.3f})") + + if _pv_factor.factor == 0: return 0.0 + annual_energy_cost = _annual_cost_electric + _annual_cost_gas + annual_energy_cost_PV = annual_energy_cost / _pv_factor.factor -def pv_operation_carbon_cost( - _year: int, + logger.debug( + f"Energy Actual Cost: ${_annual_cost_electric :,.0f}[Elec.] + ${_annual_cost_gas :,.0f}[Gas] = ${annual_energy_cost :,.0f}" + ) + logger.debug( + f"Energy PV Cost: ${annual_energy_cost :,.0f} / {_pv_factor.factor :.3f} = PV${annual_energy_cost_PV :,.0f}" + ) + + return annual_energy_cost_PV + + +def energy_CO2_cost_PV( + _pv_factor: YearlyPresentValueFactor, _future_annual_CO2_electric: list[float], _annual_CO2_gas: float, _price_of_carbon: float, - _discount_rate: float = 0.075, ) -> float: """Calculate the total operational carbon cost for a given year.""" - try: - return ((_future_annual_CO2_electric[_year] + _annual_CO2_gas) * _price_of_carbon) / ( - (1 + _discount_rate) ** _year - ) - except ZeroDivisionError: + logger.info(f"energy_CO2_cost_PV(year={_pv_factor.year}, factor={_pv_factor.factor :.3f})") + + if _pv_factor.factor == 0: return 0.0 + annual_elec_CO2 = _future_annual_CO2_electric[_pv_factor.year - 1] + annual_CO2 = annual_elec_CO2 + _annual_CO2_gas + annual_CO2_cost = annual_CO2 * _price_of_carbon + annual_CO2_cost_PV = annual_CO2_cost / _pv_factor.factor -def pv_install_cost(_year: int, _carbon_measure_yearly_costs: list[YearlyCost], _discount_rate: float = 0.02) -> float: - """Calculate the total direct maintenance cost for a given year.""" - try: - return sum( - row.cost / ((1 + _discount_rate) ** _year) for row in _carbon_measure_yearly_costs if row.year == _year - ) - except ZeroDivisionError: + logger.debug( + f"Energy CO2 Emissions: {annual_elec_CO2 :,.0f}[Elec.] + {_annual_CO2_gas :,.0f}[Gas] = {annual_CO2 :,.0f}" + ) + logger.debug( + f"Energy CO2 Actual Cost: {annual_CO2 :,.0f} * ${_price_of_carbon :,.2f}/unit = ${annual_CO2_cost :,.0f}" + ) + logger.debug( + f"Energy CO2 PV Cost [{_pv_factor.year}]: ${annual_CO2_cost :,.0f} / {_pv_factor.factor :.3f} = PV${annual_CO2_cost_PV :,.0f}" + ) + + return annual_CO2_cost_PV + + +def measure_purchase_cost_PV( + _pv_factor: YearlyPresentValueFactor, _carbon_measure_yearly_purchase_costs: list[YearlyCost] +) -> float: + """Calculate the total Measure purchase, install and maintenance cost for a single year.""" + logger.info(f"measure_purchase_cost_PV(year={_pv_factor.year}, factor={_pv_factor.factor :.3f})") + + if _pv_factor == 0: + return 0.0 + + measure_costs = [ + measure.cost for measure in _carbon_measure_yearly_purchase_costs if measure.year == _pv_factor.year - 1 + ] + if not measure_costs: return 0.0 + total_measure_cost = sum(measure_costs) + total_measure_PV_cost = total_measure_cost / _pv_factor.factor -def pv_embodied_CO2_cost( - _year: int, _carbon_measure_embodied_CO2_yearly_costs: list[YearlyCost], _discount_rate: float = 0.00 + logging.debug(f"Measure Actual Costs: {[f'${_ :,.0f}' for _ in measure_costs]} = {total_measure_cost :,.0f}") + logging.debug( + f"Measure PV Cost: ${total_measure_cost :,.0f} / {_pv_factor.factor :.3f} = PV${total_measure_PV_cost :,.0f}" + ) + + return total_measure_PV_cost + + +def measure_CO2_cost_PV( + _pv_factor: YearlyPresentValueFactor, _carbon_measure_embodied_CO2_yearly_costs: list[YearlyCost] ) -> float: - """Calculate the total embodied CO2 cost for a given year.""" + """Calculate the total Measure embodied CO2 cost for a given year.""" + logger.info(f"measure_CO2_cost_PV(year={_pv_factor.year}, factor={_pv_factor.factor :.3f})") + # TODO: What is this factor for? Why do we multiply by it? FACTOR = 0.75 - try: - return sum( - FACTOR * (yearly_cost.cost / ((1 + _discount_rate) ** _year)) - for yearly_cost in _carbon_measure_embodied_CO2_yearly_costs - if yearly_cost.year == _year - ) - except ZeroDivisionError: + + if _pv_factor.factor == 0: + return 0.0 + + measure_costs = [ + yearly_cost.cost + for yearly_cost in _carbon_measure_embodied_CO2_yearly_costs + if yearly_cost.year == _pv_factor.year + ] + if not measure_costs: return 0.0 + total_measure_cost = sum(measure_costs) + total_measure_PV_cost = sum(FACTOR * (cost / _pv_factor.factor) for cost in measure_costs) -def pv_grid_transition_cost(_year: int, _grid_transition_cost: float, _discount_rate: float = 0.02) -> float: + logging.debug(f"Measure CO2 Actual Cost: {[f'${_ :,.0f}' for _ in measure_costs]} = {total_measure_cost :,.0f}") + logging.debug( + f"Measure CO2 PV Cost: ${total_measure_cost :,.0f} / {_pv_factor.factor :.3f} = PV${total_measure_PV_cost :,.0f}" + ) + + return total_measure_PV_cost + + +def grid_transition_cost_PV(_pv_factor: YearlyPresentValueFactor, _grid_transition_cost: float) -> float: """Calculate the total grid transition cost for a given year.""" - if _year > USA_NUM_YEARS_TO_TRANSITION: + logger.info(f"grid_transition_PV_cost(year={_pv_factor.year}, factor={_pv_factor.factor :.3f})") + + if _pv_factor.year > USA_NUM_YEARS_TO_TRANSITION: year_transition_cost_factor = 0 # $/Watt-yr else: # TODO: Support non-USA countries. year_transition_cost_factor = USA_TRANSITION_COST_FACTOR / USA_NUM_YEARS_TO_TRANSITION # linear transition <- ? - try: - return (year_transition_cost_factor * _grid_transition_cost) / ((1 + _discount_rate) ** _year) - except ZeroDivisionError: + if _pv_factor.factor == 0: return 0.0 + transition_cost = year_transition_cost_factor * _grid_transition_cost + transition_PV_cost = transition_cost / _pv_factor.factor + + logger.debug( + f"Transition Cost [{_pv_factor.year}]: {year_transition_cost_factor: .0f} * ${_grid_transition_cost :,.0f} = ${transition_cost :,.0f}" + ) + logger.debug( + f"Transition PV Cost [{_pv_factor.year}]: ${transition_cost :,.0f} / {_pv_factor.factor :.3f} = PV${transition_PV_cost :,.0f}" + ) + + return transition_PV_cost + def calculate_annual_ADORB_costs( _analysis_duration_years: int, @@ -117,16 +194,20 @@ def calculate_annual_ADORB_costs( # -- Create the row data rows: list[pd.Series] = [] - for n in range(1, _analysis_duration_years + 1): + for n in range(0, _analysis_duration_years): + logger.info(f"Calculating year {n} costs:") + new_row: pd.Series[float] = pd.Series( { - columns[0]: pv_direct_energy_cost(n, _annual_total_cost_electric, _annual_total_cost_gas), - columns[1]: pv_operation_carbon_cost( - n, _annual_hourly_CO2_electric, _annual_total_CO2_gas, _price_of_carbon + columns[0]: energy_purchase_cost_PV( + present_value_factor(n, 0.02), _annual_total_cost_electric, _annual_total_cost_gas + ), + columns[1]: energy_CO2_cost_PV( + present_value_factor(n, 0.075), _annual_hourly_CO2_electric, _annual_total_CO2_gas, _price_of_carbon ), - columns[2]: pv_install_cost(n, _all_yearly_install_costs), - columns[3]: pv_embodied_CO2_cost(n, _all_yearly_embodied_kgCO2), - columns[4]: pv_grid_transition_cost(n, _grid_transition_cost), + columns[2]: measure_purchase_cost_PV(present_value_factor(n, 0.02), _all_yearly_install_costs), + columns[3]: measure_CO2_cost_PV(present_value_factor(n, 0.0), _all_yearly_embodied_kgCO2), + columns[4]: grid_transition_cost_PV(present_value_factor(n, 0.02), _grid_transition_cost), } ) rows.append(new_row) diff --git a/ph_adorb/from_HBJSON/create_variant.py b/ph_adorb/from_HBJSON/create_variant.py index b2a5a26..638eeda 100644 --- a/ph_adorb/from_HBJSON/create_variant.py +++ b/ph_adorb/from_HBJSON/create_variant.py @@ -49,7 +49,7 @@ from ph_adorb.equipment import PhAdorbEquipment, PhAdorbEquipmentCollection, PhAdorbEquipmentType from ph_adorb.fuel import PhAdorbFuel, PhAdorbFuelType from ph_adorb.grid_region import PhAdorbGridRegion, load_CO2_factors_from_json_file -from ph_adorb.measures import PhAdorbCO2MeasureCollection, PhAdorbCO2ReductionMeasure +from ph_adorb.measures import PhAdorbCO2MeasureCollection, PhAdorbCO2ReductionMeasure, CO2MeasureType from ph_adorb.national_emissions import PhAdorbNationalEmissions from ph_adorb.variant import PhAdorbVariant @@ -83,7 +83,7 @@ def get_PhAdorbCO2Measures_from_hb_model(_hb_model_prop: ModelReviveProperties) for co2_measure in _hb_model_prop.co2_measures: measure_collection_.add_measure( PhAdorbCO2ReductionMeasure( - measure_type=co2_measure.measure_type, + measure_type=CO2MeasureType(co2_measure.measure_type), name=co2_measure.name, year=co2_measure.year, cost=co2_measure.cost, @@ -199,17 +199,6 @@ def get_PhAdorbEquipment_from_hb_model(_hb_model: Model) -> PhAdorbEquipmentColl continue equipment_collection_.add_equipment(convert_hb_shade_pv(shade_prop_e.pv_properties)) - # TODO: Batteries.... - equipment_collection_.add_equipment( - PhAdorbEquipment( - name="Battery", - equipment_type=PhAdorbEquipmentType.BATTERY, - cost=3_894.54, - lifetime_years=10, - labor_fraction=0.5, - ) - ) - return equipment_collection_ diff --git a/ph_adorb/tables/variant.py b/ph_adorb/tables/variant.py index 59d357e..c783eec 100644 --- a/ph_adorb/tables/variant.py +++ b/ph_adorb/tables/variant.py @@ -49,6 +49,26 @@ def rich_table_to_html(_tbl: Table) -> str: return html_ +def add_total_row(tbl_: Table) -> None: + """Add a total row to the table if it contains numeric columns.""" + if tbl_.row_count == 0: + return + + total_row = ["Total"] + for col_idx in range(1, len(tbl_.columns)): + try: + total = sum( + float(str(tbl_.columns[col_idx]._cells[row_idx]).replace(",", "")) + for row_idx in range(tbl_.row_count) + if str(tbl_.columns[col_idx]._cells[row_idx]).replace(",", "").replace(".", "").isdigit() + ) + total_row.append(f"{total:,.0f}") + except ValueError: + total_row.append("-") + + tbl_.add_row(*total_row, style="bold") + + def preview_hourly_electric_and_CO2( _hourly_kwh: list[float], _hourly_CO2_factors: dict[int, list[float]], _output_path: Path | None ) -> None: @@ -63,9 +83,11 @@ def preview_hourly_electric_and_CO2( for i, kwh in enumerate(_hourly_kwh): factors_by_year: list[str] = [] for yearly_factors in _hourly_CO2_factors.values(): - factors_by_year.append(f"{yearly_factors[i]:,.2f}") + factors_by_year.append(f"{yearly_factors[i]:,.0f}") tbl_.add_row(f"{i:04d}", f"{kwh:,.2f}", *factors_by_year) + add_total_row(tbl_) + # Output the table to the console or write to a file if _output_path: html_table = rich_table_to_html(tbl_) @@ -105,14 +127,16 @@ def preview_yearly_energy_and_CO2( tbl_.add_row( f"{2023+i:02d}", - f"{_elec_kwh:,.2f}", - f"{co2:,.2f}", + f"{_elec_kwh:,.0f}", + f"{co2:,.0f}", f"{elec_grid_factor:,.3f}", - f"{_gas_kwh:,.2f}", - f"{_gas_CO2:,.2f}", + f"{_gas_kwh:,.0f}", + f"{_gas_CO2:,.0f}", f"{gas_grid_factor:,.3f}", ) + add_total_row(tbl_) + # Output the table to the console or write to a file if _output_path: html_table = rich_table_to_html(tbl_) @@ -142,12 +166,14 @@ def preview_variant_co2_measures( measure.name, measure.measure_type.name, f"{measure.year}", - f"{measure.cost:,.2f}", + f"{measure.cost:,.0f}", f"{measure.kg_CO2:.0f}" if measure.kg_CO2 is not None else "-", f"{measure.country_name}", f"{measure.labor_fraction * 100.0 :.0f}", ) + add_total_row(tbl_) + # Output the table to the console or write to a file if _output_path: html_table = rich_table_to_html(tbl_) @@ -159,6 +185,7 @@ def preview_variant_co2_measures( def preview_variant_equipment(_equipment_collection: PhAdorbEquipmentCollection, _output_path: Path | None) -> None: + """Preview the variant equipment in a table.""" # Create the table tbl_ = Table(title="Variant Equipment", show_lines=True) tbl_.add_column("Equipment/Appliance", style="cyan", justify="center", min_width=20, no_wrap=True) @@ -172,11 +199,13 @@ def preview_variant_equipment(_equipment_collection: PhAdorbEquipmentCollection, tbl_.add_row( equipment.name, equipment.equipment_type.name, - f"{equipment.cost:,.2f}", + f"{equipment.cost:,.0f}", f"{equipment.lifetime_years:.0f}", f"{equipment.labor_fraction * 100.0 :.0f}", ) + add_total_row(tbl_) + # Output the table to the console or write to a file if _output_path: html_table = rich_table_to_html(tbl_) @@ -206,15 +235,17 @@ def preview_variant_constructions( tbl_.add_row( construction.display_name, - f"{construction.area_m2:,.2f}", + f"{construction.area_m2:,.1f}", f"{construction.cost_per_m2:,.2f}", - f"{construction.cost:,.2f}", + f"{construction.cost:,.0f}", f"{construction.CO2_kg_per_m2:,.2f}", - f"{construction.CO2_kg:,.2f}", + f"{construction.CO2_kg:,.0f}", f"{construction.lifetime_years:.0f}", f"{construction.labor_fraction * 100.0 :.0f}", ) + add_total_row(tbl_) + # Output the table to the console or write to a file if _output_path: html_table = rich_table_to_html(tbl_) @@ -246,10 +277,12 @@ def preview_yearly_install_costs(_input: list[YearlyCost], _output_path: Path | for description, costs in grouped_data.items(): row = [description] + [ - f"{costs.get(year, 0):,.2f}" if costs.get(year, 0) != 0 else "-" for year in sorted_years + f"{costs.get(year, 0):,.0f}" if costs.get(year, 0) != 0 else "-" for year in sorted_years ] tbl_.add_row(*row) + add_total_row(tbl_) + if _output_path: html_table = rich_table_to_html(tbl_) with open(Path(_output_path / "yearly_install_costs.html"), "w") as f: @@ -280,10 +313,12 @@ def preview_yearly_embodied_kgCO2(_input: list[YearlyKgCO2], _output_path: Path for description, costs in grouped_data.items(): row = [description] + [ - f"{costs.get(year, 0):,.2f}" if costs.get(year, 0) != 0 else "-" for year in sorted_years + f"{costs.get(year, 0):,.0f}" if costs.get(year, 0) != 0 else "-" for year in sorted_years ] tbl_.add_row(*row) + add_total_row(tbl_) + if _output_path: html_table = rich_table_to_html(tbl_) with open(Path(_output_path / "yearly_embodied_CO2_kg.html"), "w") as f: @@ -314,10 +349,12 @@ def preview_yearly_embodied_CO2_costs(_input: list[YearlyCost], _output_path: Pa for description, costs in grouped_data.items(): row = [description] + [ - f"{costs.get(year, 0):,.2f}" if costs.get(year, 0) != 0 else "-" for year in sorted_years + f"{costs.get(year, 0):,.0f}" if costs.get(year, 0) != 0 else "-" for year in sorted_years ] tbl_.add_row(*row) + add_total_row(tbl_) + if _output_path: html_table = rich_table_to_html(tbl_) with open(Path(_output_path / "yearly_embodied_CO2_costs.html"), "w") as f: diff --git a/ph_adorb/variant.py b/ph_adorb/variant.py index 6166f19..e89e4b0 100644 --- a/ph_adorb/variant.py +++ b/ph_adorb/variant.py @@ -97,13 +97,13 @@ def calc_annual_total_electric_cost( # ------------------------------------------------------------------------------------------------------------------ logger.debug( - f"total_purchased_electric_cost: {total_purchased_electric_cost} = {_purchased_electricity_kwh} * {_electric_purchase_price_per_kwh}" + f"Electric Purchased: {_purchased_electricity_kwh :,.0f}kWh * ${_electric_purchase_price_per_kwh :,.2f}/kWh = ${total_purchased_electric_cost :,.0f}" ) logger.debug( - f"total_sold_electric_cost: {total_sold_electric_cost} = {_sold_electricity_kwh} * {_electric_sell_price_per_kwh}" + f"Electric Sold: {_sold_electricity_kwh :,.0f}kWh * ${_electric_sell_price_per_kwh :,.2f}/kWh = ${total_sold_electric_cost :,.0f}" ) logger.debug( - f"total_annual_electric_cost: {total_annual_electric_cost} = {total_purchased_electric_cost} - {total_sold_electric_cost} + {_electric_annual_base_price}" + f"Electric Net Cost: ${total_purchased_electric_cost :,.0f} - ${total_sold_electric_cost :,.0f} + ${_electric_annual_base_price :,.0f} = ${total_annual_electric_cost :,.0f}" ) return total_annual_electric_cost @@ -139,9 +139,8 @@ def calc_annual_total_gas_cost( total_annual_gas_cost = (_total_purchased_gas_kwh * _gas_purchase_price_per_kwh) + _gas_annual_base_price - # ------------------------------------------------------------------------------------------------------------------ logger.debug( - f"total_annual_gas_cost: {total_annual_gas_cost} = {_total_purchased_gas_kwh} * {_gas_purchase_price_per_kwh} + {_gas_annual_base_price}" + f"Gas Cost: {_total_purchased_gas_kwh :,.0f}kWh * ${_gas_purchase_price_per_kwh :,.2f}/kWh + ${_gas_annual_base_price :,.0f} = ${total_annual_gas_cost :,.0f}" ) return total_annual_gas_cost @@ -157,13 +156,14 @@ def calc_annual_total_gas_CO2( TONS_CO2_PER_KWH = 0.0341 if not _gas_used: - logger.debug("annual_tons_gas_CO2=0.0 [_gas_used=False]") return 0.0 annual_tons_gas_CO2 = TONS_CO2_PER_KWH * _total_purchased_gas_kwh # TODO: is this needed? * SOME_CONSTANT - # ------------------------------------------------------------------------------------------------------------------ - logger.debug(f"annual_tons_gas_CO2: {annual_tons_gas_CO2} = {TONS_CO2_PER_KWH} * {_total_purchased_gas_kwh}") + logger.debug( + f"Gas CO2: {TONS_CO2_PER_KWH} tCO2/kWh * {_total_purchased_gas_kwh :,.0f}kWh = {annual_tons_gas_CO2 :,.0f}" + ) + return annual_tons_gas_CO2 @@ -177,9 +177,8 @@ def calc_CO2_reduction_measures_yearly_embodied_kgCO2( ) -> list[YearlyKgCO2]: """Return a list of all the Yearly-Embodied-kgCO2 for all the Variant's CO2-Reduction-Measures.""" - logger.info("calc_CO2_reduction_measures_yearly_embodied_kgCO2()") + logger.info(f"calc_CO2_reduction_measures_yearly_embodied_kgCO2({len(_variant_CO2_measures)} measures)") - # ------------------------------------------------------------------------------- # TODO: CHANGE TO USE COUNTRY INDEX, 0 for US, yearly_embodied_kgCO2_: list[YearlyKgCO2] = [] @@ -187,6 +186,10 @@ def calc_CO2_reduction_measures_yearly_embodied_kgCO2( measure_kgCO2 = measure.cost * _kg_CO2_per_USD yearly_embodied_kgCO2_.append(YearlyKgCO2(measure_kgCO2, measure.year, measure.name)) + logger.debug( + f"CO2 Measure {measure.name} [YR-{measure.year}]: ${measure.cost :,.0f} * {_kg_CO2_per_USD :,.0f} kgCO2/USD = {measure_kgCO2 :,.0f}" + ) + # TODO: Labor fraction should be subtracted out and have USA EF applied return yearly_embodied_kgCO2_ @@ -198,10 +201,16 @@ def calc_CO2_reduction_measures_yearly_embodied_CO2_cost( """Return a list of all the Yearly-Embodied-CO2-Costs for all the Variant's CO2-Reduction-Measures.""" logger.info("calc_CO2_reduction_measures_yearly_embodied_CO2_cost()") - return [ - YearlyCost(yearly_kgCO2.kg_CO2 * _USD_per_kgCO2, yearly_kgCO2.year, yearly_kgCO2.description) - for yearly_kgCO2 in _yearly_embodied_kgCO2_ - ] + yearly_CO2_costs_ = [] + for yearly_kgCO2 in _yearly_embodied_kgCO2_: + CO2_cost = yearly_kgCO2.kg_CO2 * _USD_per_kgCO2 + logger.debug( + f"CO2 Measure {yearly_kgCO2.description} [YR-{yearly_kgCO2.year}]: {yearly_kgCO2.kg_CO2 :,.0f} kgCO2 * ${_USD_per_kgCO2 :,.2f}/kgCO2 = ${CO2_cost :,.0f}" + ) + + yearly_CO2_costs_.append(YearlyCost(CO2_cost, yearly_kgCO2.year, yearly_kgCO2.description)) + + return yearly_CO2_costs_ # TODO: Do we need this? What is the '_envelope_labor_cost_fraction' doing here? @@ -234,7 +243,7 @@ def calc_CO2_reduction_measures_yearly_install_costs( def calc_constructions_yearly_embodied_kgCO2( - _construction_collection: PhAdorbConstructionCollection, _analysis_duration, _kg_CO2_per_USD, _USD_per_kgCO2=0.25 + _construction_collection: PhAdorbConstructionCollection, _analysis_duration, _kg_CO2_per_USD ) -> list[YearlyKgCO2]: """Return a list of all the Yearly-Embodied-CO2-Costs for all the Variant's Construction Materials.""" logger.info("calc_constructions_yearly_embodied_kgCO2()") @@ -242,13 +251,18 @@ def calc_constructions_yearly_embodied_kgCO2( yearly_embodied_kgCO2_: list[YearlyKgCO2] = [] for const in _construction_collection: const_material_dollar_cost: float = const.cost * const.material_fraction - const_material_embodied_kgCO2: float = const_material_dollar_cost * _kg_CO2_per_USD # * _price_of_carbon + const_material_embodied_kgCO2: float = const_material_dollar_cost * _kg_CO2_per_USD + + logger.debug( + f"Construction {const.display_name}: ${const_material_dollar_cost :,.0f} * {_kg_CO2_per_USD :,.2f} kgCO2/USD = {const_material_embodied_kgCO2 :,.0f} kgCO2" + ) + + for year in range(0, _analysis_duration + 1, const.lifetime_years or (_analysis_duration + 1)): + logger.debug( + f"Adding Construction {const.display_name} Embodied CO2 [lifetime={const.lifetime_years}yrs] {const_material_embodied_kgCO2 :,.0f} kgCO2 for year-{year}" + ) + yearly_embodied_kgCO2_.append(YearlyKgCO2(const_material_embodied_kgCO2, year, const.display_name)) - if const.lifetime_years == 0: - yearly_embodied_kgCO2_.append(YearlyKgCO2(const_material_embodied_kgCO2, 0, const.display_name)) - else: - for year in range(0, _analysis_duration, const.lifetime_years): - yearly_embodied_kgCO2_.append(YearlyKgCO2(const_material_embodied_kgCO2, year, const.display_name)) return yearly_embodied_kgCO2_ @@ -258,10 +272,16 @@ def calc_constructions_yearly_embodied_CO2_cost( """Return a list of all the Yearly-Embodied-CO2-Costs for all the Variant's Construction Materials.""" logger.info("calc_constructions_yearly_embodied_CO2_cost()") - return [ - YearlyCost(yearly_kgCO2.kg_CO2 * _USD_per_kgCO2, yearly_kgCO2.year, yearly_kgCO2.description) - for yearly_kgCO2 in _yearly_embodied_kgCO2_ - ] + yearly_embodied_CO2_ = [] + for yearly_kgCO2 in _yearly_embodied_kgCO2_: + CO2_cost = yearly_kgCO2.kg_CO2 * _USD_per_kgCO2 + logger.debug( + f"Construction {yearly_kgCO2.description} Embodied CO2-Cost [YR-{yearly_kgCO2.year}]: {yearly_kgCO2.kg_CO2 :,.0f} kgCO2 * ${_USD_per_kgCO2 :,.2f}/kgCO2 = ${CO2_cost :,.0f}" + ) + + yearly_embodied_CO2_.append(YearlyCost(CO2_cost, yearly_kgCO2.year, yearly_kgCO2.description)) + + return yearly_embodied_CO2_ def calc_constructions_yearly_install_costs( @@ -273,11 +293,11 @@ def calc_constructions_yearly_install_costs( yearly_install_costs_ = [] for const in _construction_collection: - if const.lifetime_years == 0: - yearly_install_costs_.append(YearlyCost(const.cost, 0, const.display_name)) - else: - for year in range(0, _analysis_duration, const.lifetime_years): - yearly_install_costs_.append(YearlyCost(const.cost, year, const.display_name)) + for year in range(0, _analysis_duration + 1, const.lifetime_years or (_analysis_duration + 1)): + logger.debug( + f"Adding Construction {const.display_name} Install Cost: [lifetime={const.lifetime_years}yrs] ${const.cost :,.0f} for year-{year}" + ) + yearly_install_costs_.append(YearlyCost(const.cost, year, const.display_name)) return yearly_install_costs_ @@ -294,13 +314,18 @@ def calc_equipment_yearly_embodied_kgCO2_( yearly_embodied_kgCO2_: list[YearlyKgCO2] = [] for equip in _equipment_collection: equip_material_cost: float = equip.cost * equip.material_fraction - equip_material_embodied_CO2_cost: float = equip_material_cost * _kg_CO2_per_USD # * _price_of_carbon + equip_material_embodied_CO2_cost: float = equip_material_cost * _kg_CO2_per_USD + + logger.debug( + f"Equipment {equip.name}: ${equip_material_cost :,.0f} * {_kg_CO2_per_USD :,.2f} kgCO2/USD = {equip_material_embodied_CO2_cost :,.0f} kgCO2" + ) + + for year in range(0, _analysis_duration + 1, equip.lifetime_years or (_analysis_duration + 1)): + logger.debug( + f"Adding Equipment {equip.name} Embodied CO2 [lifetime={equip.lifetime_years}yrs] {equip_material_embodied_CO2_cost :,.0f} kgCO2 for year-{year}" + ) + yearly_embodied_kgCO2_.append(YearlyKgCO2(equip_material_embodied_CO2_cost, year, equip.name)) - if equip.lifetime_years == 0: - yearly_embodied_kgCO2_.append(YearlyKgCO2(equip_material_embodied_CO2_cost, 0, equip.name)) - else: - for year in range(0, _analysis_duration, equip.lifetime_years): - yearly_embodied_kgCO2_.append(YearlyKgCO2(equip_material_embodied_CO2_cost, year, equip.name)) return yearly_embodied_kgCO2_ @@ -310,10 +335,16 @@ def calc_equipment_yearly_embodied_CO2_cost( """Return a list of all the Yearly-Embodied-CO2-Costs for all the Variant's Equipment.""" logger.info("calc_equipment_yearly_embodied_CO2_cost()") - return [ - YearlyCost(yearly_kgCO2.kg_CO2 * _USD_per_kgCO2, yearly_kgCO2.year, yearly_kgCO2.description) - for yearly_kgCO2 in _yearly_embodied_kgCO2_ - ] + yearly_embodied_CO2_cost_ = [] + for yearly_kgCO2 in _yearly_embodied_kgCO2_: + CO2_cost = yearly_kgCO2.kg_CO2 * _USD_per_kgCO2 + logger.debug( + f"Equipment {yearly_kgCO2.description} Embodied CO2-Cost [YR-{yearly_kgCO2.year}]: {yearly_kgCO2.kg_CO2 :,.0f} kgCO2 * ${_USD_per_kgCO2 :,.2f}/kgCO2 = ${CO2_cost :,.0f}" + ) + + yearly_embodied_CO2_cost_.append(YearlyCost(CO2_cost, yearly_kgCO2.year, yearly_kgCO2.description)) + + return yearly_embodied_CO2_cost_ def calc_equipment_yearly_install_costs( @@ -325,11 +356,12 @@ def calc_equipment_yearly_install_costs( yearly_install_costs_ = [] for equip in _equipment_collection: - if equip.lifetime_years == 0: - yearly_install_costs_.append(YearlyCost(equip.cost, 0, equip.name)) - else: - for year in range(0, _analysis_duration, equip.lifetime_years): - yearly_install_costs_.append(YearlyCost(equip.cost, year, equip.name)) + for year in range(0, _analysis_duration + 1, equip.lifetime_years or (_analysis_duration + 1)): + logger.debug( + f"Adding Equipment {equip.name} Install Cost: [lifetime={equip.lifetime_years}yrs] ${equip.cost :,.0f} for year-{year}" + ) + yearly_install_costs_.append(YearlyCost(equip.cost, year, equip.name)) + return yearly_install_costs_ diff --git a/ph_adorb/yearly_values.py b/ph_adorb/yearly_values.py index e476942..f6177f7 100644 --- a/ph_adorb/yearly_values.py +++ b/ph_adorb/yearly_values.py @@ -28,3 +28,14 @@ class YearlyKgCO2: def __repr__(self) -> str: return f"YearlyKgCO2(kg_CO2={self.kg_CO2 :.1f}, year={self.year}, description={self.description})" + + +@dataclass +class YearlyPresentValueFactor: + """A single Yearly Present Value Factor for a building design.""" + + factor: float + year: int + + def __repr__(self) -> str: + return f"YearlyPresentValueFactor(pv_factor={self.factor :.3f}, year={self.year})" diff --git a/tests/test_adorb_cost.py b/tests/test_adorb_cost.py index f7c0397..b1d8d51 100644 --- a/tests/test_adorb_cost.py +++ b/tests/test_adorb_cost.py @@ -1,48 +1,256 @@ from pytest import approx from ph_adorb.adorb_cost import ( - pv_direct_energy_cost, - pv_embodied_CO2_cost, - pv_grid_transition_cost, - pv_install_cost, - pv_operation_carbon_cost, + present_value_factor, + energy_purchase_cost_PV, + measure_CO2_cost_PV, + grid_transition_cost_PV, + measure_purchase_cost_PV, + energy_CO2_cost_PV, + calculate_annual_ADORB_costs, ) from ph_adorb.variant import YearlyCost +# -- Sample Data created from the Phius GUI Calculator to test against +phius_gui_annual_total_cost_electric = 1473.4188631811944 +phius_gui_annual_total_cost_gas = 1414.5608710406113 +phius_gui_annual_hourly_CO2_electric = [ + 2978.655155240317, + 2978.655155240317, + 2625.0052682194573, + 2625.0052682194573, + 2170.890662041063, + 2170.890662041063, + 2239.323754255612, + 2239.323754255612, + 2408.991854512229, + 2408.991854512229, + 2408.991854512229, + 2408.991854512229, + 2408.991854512229, + 2602.3522388332867, + 2602.3522388332867, + 2602.3522388332867, + 2602.3522388332867, + 2602.3522388332867, + 2625.336537281845, + 2625.336537281845, + 2625.336537281845, + 2625.336537281845, + 2625.336537281845, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, + 2593.221517493905, +] +phius_gui_annual_total_CO2_gas = 11177.480479866495 +phius_gui_all_yearly_install_costs = [ + YearlyCost(23562.69, 0), + YearlyCost(641.7, 0), + YearlyCost(1717.96, 0), + YearlyCost(820.55, 0), + YearlyCost(861.21, 0), + YearlyCost(861.21, 30), + YearlyCost(795.83, 0), + YearlyCost(1949.67, 0), + YearlyCost(1949.67, 30), + YearlyCost(5.0, 0), + YearlyCost(5.0, 25), + YearlyCost(3458.91, 0), + YearlyCost(3458.91, 10), + YearlyCost(3458.91, 20), + YearlyCost(3458.91, 30), + YearlyCost(3458.91, 40), + YearlyCost(7500.0, 0), + YearlyCost(7500.0, 20), + YearlyCost(7500.0, 40), + YearlyCost(1000.0, 0), + YearlyCost(1000.0, 13), + YearlyCost(1000.0, 26), + YearlyCost(1000.0, 39), + YearlyCost(87.87, 0), + YearlyCost(611.0, 0), + YearlyCost(611.0, 17), + YearlyCost(611.0, 34), + YearlyCost(662.0, 0), + YearlyCost(662.0, 14), + YearlyCost(662.0, 28), + YearlyCost(662.0, 42), + YearlyCost(1000.0, 0), + YearlyCost(1000.0, 13), + YearlyCost(1000.0, 26), + YearlyCost(1000.0, 39), + YearlyCost(992.0, 0), + YearlyCost(992.0, 13), + YearlyCost(992.0, 26), + YearlyCost(992.0, 39), + YearlyCost(959.0, 0), + YearlyCost(959.0, 11), + YearlyCost(959.0, 22), + YearlyCost(959.0, 33), + YearlyCost(959.0, 44), + YearlyCost(250.0, 0), +] +phius_gui_all_yearly_embodied_kgCO2 = [ + YearlyCost(5513.66946, 0), + YearlyCost(26.277615, 0), + YearlyCost(70.350462, 0), + YearlyCost(33.601522499999994, 0), + YearlyCost(35.2665495, 0), + YearlyCost(35.2665495, 30), + YearlyCost(32.5892385, 0), + YearlyCost(57.02784750000001, 0), + YearlyCost(57.02784750000001, 30), + YearlyCost(0.21937500000000001, 0), + YearlyCost(0.21937500000000001, 25), + YearlyCost(101.1731175, 0), + YearlyCost(101.1731175, 10), + YearlyCost(101.1731175, 20), + YearlyCost(101.1731175, 30), + YearlyCost(101.1731175, 40), + YearlyCost(263.25, 0), + YearlyCost(263.25, 20), + YearlyCost(263.25, 40), + YearlyCost(11.699999999999998, 0), + YearlyCost(11.699999999999998, 13), + YearlyCost(11.699999999999998, 26), + YearlyCost(11.699999999999998, 39), + YearlyCost(0, 0), # <-------------------- (nan, 0)? + YearlyCost(30.381975000000004, 0), + YearlyCost(30.381975000000004, 17), + YearlyCost(30.381975000000004, 34), + YearlyCost(32.91795, 0), + YearlyCost(32.91795, 14), + YearlyCost(32.91795, 28), + YearlyCost(32.91795, 42), + YearlyCost(49.725, 0), + YearlyCost(49.725, 13), + YearlyCost(49.725, 26), + YearlyCost(49.725, 39), + YearlyCost(52.22880000000001, 0), + YearlyCost(52.22880000000001, 13), + YearlyCost(52.22880000000001, 26), + YearlyCost(52.22880000000001, 39), + YearlyCost(39.27105, 0), + YearlyCost(39.27105, 11), + YearlyCost(39.27105, 22), + YearlyCost(39.27105, 33), + YearlyCost(39.27105, 44), + YearlyCost(10.2375, 0), +] +phius_gui_grid_transition_cost = 3009.76 + + +def test__adorb_cost_works_with_phius_gui_data(): + result = calculate_annual_ADORB_costs( + 50, + phius_gui_annual_total_cost_electric, + phius_gui_annual_total_cost_gas, + phius_gui_annual_hourly_CO2_electric, + phius_gui_annual_total_CO2_gas, + phius_gui_all_yearly_install_costs, + phius_gui_all_yearly_embodied_kgCO2, + phius_gui_grid_transition_cost, + 0.25, + ) + + # Test against the known results from the PHIUS GUI tool + assert result["pv_direct_energy"].sum() == approx(90_750.73699703957) + assert result["pv_operational_CO2"].sum() == approx(44_516.49666105372) + assert result["pv_direct_MR"].sum() == approx(73_733.73329487) + assert result["pv_embodied_CO2"].sum() == approx(1_260.9522315000002) + assert result["pv_e_trans"].sum() == approx(6_319.495880549157) + def test_pv_direct_energy_cost(): - assert pv_direct_energy_cost(0, 0, 0) == 0 - assert pv_direct_energy_cost(1, 1, 1) == approx(1.9607843) - assert pv_direct_energy_cost(-1, 1, 1) == approx(2.04) - assert pv_direct_energy_cost(1, 0, 0, _discount_rate=-1) == 0 + assert energy_purchase_cost_PV(present_value_factor(0, 0), 0, 0) == 0 + assert energy_purchase_cost_PV(present_value_factor(1, 0.02), 1, 0) == approx(0.9611687812379854) + assert energy_purchase_cost_PV(present_value_factor(-1, 0.02), 1, 0) == 1.0 def test_pv_operation_carbon_cost(): - assert pv_operation_carbon_cost(0, [0, 1, 2, 3], 0, 0.25) == 0 - assert pv_operation_carbon_cost(1, [0, 1, 2, 3], 1, 0.25) == approx(0.46511627906976744) - assert pv_operation_carbon_cost(-1, [0, 1, 2, 3], 1, 0.25) == approx(1.075) - assert pv_operation_carbon_cost(1, [0, 1, 2, 3], 0, 0.25, _discount_rate=-1) == 0 + assert energy_CO2_cost_PV(present_value_factor(0, 0.02), [0, 1, 2, 3], 0, 0.25) == 0 + assert energy_CO2_cost_PV(present_value_factor(1, 0.02), [0, 1, 2, 3], 0, 0.25) == approx(0.24029219530949636) + assert energy_CO2_cost_PV(present_value_factor(-1, 0.02), [0, 1, 2, 3], 0, 0.25) == 0.75 def test_pv_direct_maintenance_cost(): costs = [YearlyCost(0, 0), YearlyCost(1, 1), YearlyCost(2, 2), YearlyCost(3, 3)] - assert pv_install_cost(0, costs) == 0 - assert pv_install_cost(1, costs) == approx(0.9803921568627451) - assert pv_install_cost(-1, costs) == approx(0.0) - assert pv_install_cost(1, costs, _discount_rate=-1) == 0 + assert measure_purchase_cost_PV(present_value_factor(0, 0.02), costs) == 0 + assert measure_purchase_cost_PV(present_value_factor(1, 0.02), costs) == approx(0.9611687812379854) + assert measure_purchase_cost_PV(present_value_factor(-1, 0.02), costs) == 0 def test_pv_embodied_CO2_cost(): costs = [YearlyCost(0, 0), YearlyCost(1, 1), YearlyCost(2, 2), YearlyCost(3, 3)] - assert pv_embodied_CO2_cost(0, costs) == 0 - assert pv_embodied_CO2_cost(1, costs) == approx(0.75) - assert pv_embodied_CO2_cost(-1, costs) == approx(0.0) - assert pv_embodied_CO2_cost(1, costs, _discount_rate=-1) == 0 + assert measure_CO2_cost_PV(present_value_factor(0, 0.02), costs) == approx(0.7352941176470588) + assert measure_CO2_cost_PV(present_value_factor(1, 0.02), costs) == approx(1.4417531718569783) + assert measure_CO2_cost_PV(present_value_factor(-1, 0.02), costs) == 0 def test_pv_grid_transition_cost(): - assert pv_grid_transition_cost(0, 0) == 0 - assert pv_grid_transition_cost(100, 0) == 0 - assert pv_grid_transition_cost(1, 1) == approx(0.09191176470588235) - assert pv_grid_transition_cost(-1, 1) == approx(0.095625) - assert pv_grid_transition_cost(1, 0, _discount_rate=-1) == 0 + assert grid_transition_cost_PV(present_value_factor(0, 0.02), 0) == 0 + assert grid_transition_cost_PV(present_value_factor(1, 0.02), 1) == approx(0.09010957324106113) + assert grid_transition_cost_PV(present_value_factor(-1, 0.02), 1) == 0.09375