diff --git a/README.rst b/README.rst index a769cee..a62a6b0 100644 --- a/README.rst +++ b/README.rst @@ -31,7 +31,7 @@ A package for calculating electricity-related emissions and costs for optimizati Useful Commands =============== -1. ``pip install -e .`` +1. ``pip install -e .`` (or ``pip install -e .[test]`` for development) This will install your package in editable mode. diff --git a/eeco/costs.py b/eeco/costs.py index 8133c4b..c575bb8 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -124,10 +124,11 @@ def create_charge_array( # calculate months, days, and hours weekdays = datetime.dt.weekday.values months = datetime.dt.month.values - hours = datetime.dt.hour.astype(float).values # Make sure hours are being incremented by XX-minute increments - minutes = datetime.dt.minute.astype(float).values - hours += minutes / 60 + hours = ( + datetime.dt.hour.astype(float).values + + datetime.dt.minute.astype(float).values / 60 + ) # create an empty charge array apply_charge = ( @@ -153,6 +154,7 @@ def create_charge_array( # if not specified, assume the old format (imperial units) if utility == GAS: # convert from therms to default units of cubic meters + charge = charge.copy() charge[CHARGE] /= 2.83168 # therm per cubic meter of CH4 charge_array = apply_charge * charge[CHARGE] return charge_array @@ -246,10 +248,6 @@ def get_charge_dict(start_dt, end_dt, rate_data, resolution="15m"): ), columns=[DATETIME], ) - hours = datetime[DATETIME].dt.hour.astype(float).values - # Make sure hours are being incremented by XX-minute increments - minutes = datetime[DATETIME].dt.minute.astype(float).values - hours += minutes / 60 for utility in [GAS, ELECTRIC]: for charge_type in [CUSTOMER, ENERGY, DEMAND, EXPORT]: @@ -1522,6 +1520,7 @@ def calculate_itemized_cost( demand_scale_factor=1, model=None, decomposition_type=None, + by_charge_key=False, varstr_alias_func=default_varstr_alias_func, ): """Calculates itemized costs as a nested dictionary @@ -1582,6 +1581,15 @@ def calculate_itemized_cost( The model object associated with the problem. Only used in the case of Pyomo, so `None` by default. + decomposition_type : str or None + Type of decomposition to use for consumption data. + - "absolute_value": Linear problem using absolute value + - "binary_variable": `NotImplementedError` + - None (default): No decomposition, treats all consumption as imports + + by_charge_key : bool + Whether to further itemize the results by charge key. Default is False. + Returns ------- dict @@ -1589,33 +1597,33 @@ def calculate_itemized_cost( "electric": { - "customer": `float` + "customer": `float` or dict (key -> cost) if by_charge_key - "energy": `float` + "energy": `float` or dict (key -> cost) if by_charge_key - "demand": `float` + "demand": `float` or dict (key -> cost) if by_charge_key - "export": `float` + "export": `float` or dict (key -> cost) if by_charge_key - "total": `float` + "total": `float` or dict (key -> cost) if by_charge_key } "gas": { - "customer": `float`, + "customer": `float` or dict (key -> cost) if by_charge_key - "energy": `float` + "energy": `float` or dict (key -> cost) if by_charge_key - "demand": `float` + "demand": `float` or dict (key -> cost) if by_charge_key - "export": `float` + "export": `float` or dict (key -> cost) if by_charge_key - "total": `float` + "total": `float` or dict (key -> cost) if by_charge_key } - "total": `float` + "total": `float` or dict (key -> cost) if by_charge_key } @@ -1638,19 +1646,25 @@ def calculate_itemized_cost( consumption_data_dict, conversion_factors, decomposition_type, model ) - total_cost = 0 results_dict = {} - - if desired_utility is None: - for utility in [ELECTRIC, GAS]: - results_dict[utility] = {} - total_utility_cost = 0 - for charge_type in [CUSTOMER, ENERGY, DEMAND, EXPORT]: + utilities = [ELECTRIC, GAS] if desired_utility is None else [desired_utility] + charge_types = [CUSTOMER, ENERGY, DEMAND, EXPORT] + charge_items = list(charge_dict.items()) if by_charge_key else [(None, charge_dict)] + + for utility in utilities: + results_dict[utility] = {} + for charge_type in charge_types: + if by_charge_key: + results_dict[utility][charge_type] = {} + + for charge_key, charges in charge_items: + charge_input = charges if charge_key is None else {charge_key: charges} cost, model = calculate_cost( - charge_dict, + charge_input, consumption_data_dict, resolution=resolution, prev_demand_dict=prev_demand_dict, + prev_consumption_dict=prev_consumption_dict, consumption_estimate=consumption_estimate, desired_utility=utility, desired_charge_type=charge_type, @@ -1659,38 +1673,35 @@ def calculate_itemized_cost( decomposition_type=decomposition_type, varstr_alias_func=varstr_alias_func, ) - - results_dict[utility][charge_type] = cost - total_utility_cost += cost - - results_dict[utility]["total"] = total_utility_cost - total_cost += total_utility_cost - else: - results_dict[desired_utility] = {} - total_utility_cost = 0 - for charge_type in [CUSTOMER, ENERGY, DEMAND, EXPORT]: - cost, model = calculate_cost( - charge_dict, - consumption_data_dict, - resolution=resolution, - prev_demand_dict=prev_demand_dict, - prev_consumption_dict=prev_consumption_dict, - consumption_estimate=consumption_estimate, - desired_utility=desired_utility, - desired_charge_type=charge_type, - demand_scale_factor=demand_scale_factor, - model=model, - decomposition_type=decomposition_type, - varstr_alias_func=varstr_alias_func, + if by_charge_key: + results_dict[utility][charge_type][charge_key] = cost + else: + results_dict[utility][charge_type] = cost + + if by_charge_key: + results_dict[utility]["total"] = {} + for charge_type in charge_types: + for charge_key, cost in results_dict[utility][charge_type].items(): + results_dict[utility]["total"][charge_key] = ( + results_dict[utility]["total"].get(charge_key, 0) + cost + ) + else: + results_dict[utility]["total"] = sum( + results_dict[utility][charge_type] for charge_type in charge_types ) - results_dict[desired_utility][charge_type] = cost - total_utility_cost += cost - - results_dict[desired_utility]["total"] = total_utility_cost - total_cost += total_utility_cost + if by_charge_key: + results_dict["total"] = {} + for utility in utilities: + for charge_key, cost in results_dict[utility]["total"].items(): + results_dict["total"][charge_key] = ( + results_dict["total"].get(charge_key, 0) + cost + ) + else: + results_dict["total"] = sum( + results_dict[utility]["total"] for utility in utilities + ) - results_dict["total"] = total_cost return results_dict, model diff --git a/eeco/data/emissions_datetime_local.csv b/eeco/data/emissions_datetime_local.csv new file mode 100644 index 0000000..9aea370 --- /dev/null +++ b/eeco/data/emissions_datetime_local.csv @@ -0,0 +1,50 @@ +datetime_local,co2_eq_kg_per_MWh +2024-07-31 00:00:00,395.532224 +2024-07-31 01:00:00,415.03668 +2024-07-31 02:00:00,424.10852 +2024-07-31 03:00:00,427.737256 +2024-07-31 04:00:00,425.015704 +2024-07-31 05:00:00,410.50076 +2024-07-31 06:00:00,393.264264 +2024-07-31 07:00:00,366.955928 +2024-07-31 08:00:00,355.616128 +2024-07-31 09:00:00,351.987392 +2024-07-31 10:00:00,350.626616 +2024-07-31 11:00:00,352.440984 +2024-07-31 12:00:00,353.348168 +2024-07-31 13:00:00,346.99788 +2024-07-31 14:00:00,339.740408 +2024-07-31 15:00:00,337.018856 +2024-07-31 16:00:00,337.92604 +2024-07-31 17:00:00,346.99788 +2024-07-31 18:00:00,357.430496 +2024-07-31 19:00:00,359.698456 +2024-07-31 20:00:00,360.152048 +2024-07-31 21:00:00,362.420008 +2024-07-31 22:00:00,369.223888 +2024-07-31 23:00:00,375.574176 +2024-08-01 00:00:00,414.583088 +2024-08-01 01:00:00,433.18036 +2024-08-01 02:00:00,439.98424 +2024-08-01 03:00:00,443.612976 +2024-08-01 04:00:00,443.159384 +2024-08-01 05:00:00,419.119008 +2024-08-01 06:00:00,405.96484 +2024-08-01 07:00:00,378.295728 +2024-08-01 08:00:00,366.955928 +2024-08-01 09:00:00,364.234376 +2024-08-01 10:00:00,362.420008 +2024-08-01 11:00:00,364.234376 +2024-08-01 12:00:00,364.234376 +2024-08-01 13:00:00,355.162536 +2024-08-01 14:00:00,347.451472 +2024-08-01 15:00:00,345.637104 +2024-08-01 16:00:00,346.99788 +2024-08-01 17:00:00,358.791272 +2024-08-01 18:00:00,365.595152 +2024-08-01 19:00:00,366.048744 +2024-08-01 20:00:00,369.223888 +2024-08-01 21:00:00,376.934952 +2024-08-01 22:00:00,385.099608 +2024-08-01 23:00:00,393.264264 +2024-08-02 00:00:00,414.583088 diff --git a/eeco/emissions.py b/eeco/emissions.py index 3e85ef0..7bafb81 100644 --- a/eeco/emissions.py +++ b/eeco/emissions.py @@ -217,8 +217,8 @@ def get_carbon_intensity( rollover = ( end_month != start_month ) # should we extend this for multiple months/years? + old_end_day = end_day if rollover: - old_end_day = end_day end_day = calendar.monthrange(start_year, start_month)[1] old_end_hour = end_hour end_hour = 24 @@ -275,17 +275,27 @@ def get_carbon_intensity( # reset start index to first hour of the month # can assume start hour is zero since we are rolling over start_hour = 0 + end_month_day = i + 1 if no_day_var: - start_index = emissions_data.loc[ - (emissions_data[HOUR_VARNAME] == start_hour) - & (emissions_data[MONTH_VARNAME] == end_month) - ].idxmax()[0] + start_index = ( + emissions_data.loc[ + (emissions_data[HOUR_VARNAME] == start_hour) + & (emissions_data[MONTH_VARNAME] == end_month) + ] + .idxmax() + .iloc[0] + ) else: - start_index = emissions_data.loc[ - (emissions_data[HOUR_VARNAME] == start_hour) - & (emissions_data[DAY_VARNAME] == start_day) - & (emissions_data[MONTH_VARNAME] == end_month) - ].idxmax()[0] + start_index = ( + emissions_data.loc[ + (emissions_data[HOUR_VARNAME] == start_hour) + # & (emissions_data[DAY_VARNAME] == start_day) + & (emissions_data[DAY_VARNAME] == end_month_day) + & (emissions_data[MONTH_VARNAME] == end_month) + ] + .idxmax() + .iloc[0] + ) if i == int(old_end_day - 1): # don't include last hour as it may not be a full hour for j in range(0, int(end_hour)): @@ -310,6 +320,8 @@ def get_carbon_intensity( leftover = (ntsteps - num_first_min) % n_per_hour if leftover == 0: leftover = n_per_hour + # use last day of range if there is rollover + last_day = old_end_day if rollover else end_day if no_day_var: end_index = ( emissions_data.loc[ @@ -323,7 +335,7 @@ def get_carbon_intensity( end_index = ( emissions_data.loc[ (emissions_data[HOUR_VARNAME] == end_hour) - & (emissions_data[DAY_VARNAME] == end_day) + & (emissions_data[DAY_VARNAME] == last_day) & (emissions_data[MONTH_VARNAME] == end_month) ] .idxmax() diff --git a/eeco/tests/test_costs.py b/eeco/tests/test_costs.py index 3c4e03c..af37d80 100644 --- a/eeco/tests/test_costs.py +++ b/eeco/tests/test_costs.py @@ -104,6 +104,7 @@ def solve_pyo_problem( decomposition_type=None, charge_dict=None, consumption_data_dict=None, + by_charge_key=False, ): """Helper function to solve Pyomo optimization problem.""" model.obj = pyo.Objective(expr=objective) @@ -210,9 +211,6 @@ def test_create_charge_array( np.array([start_dt + np.timedelta64(i * 15, "m") for i in range(ntsteps)]), columns=["DateTime"], ) - hours = datetime["DateTime"].dt.hour.astype(float).values - n_hours = int((end_dt - start_dt) / np.timedelta64(1, "h")) - hours += np.tile(np.arange(n_per_hour) / n_per_hour, n_hours) result = costs.create_charge_array( charge, datetime, effective_start_date, effective_end_date @@ -1516,7 +1514,12 @@ def test_calculate_cost_pyo( ) solve_pyo_problem( - model, result, decomposition_type, charge_dict, consumption_data_dict + model, + result, + decomposition_type, + charge_dict, + consumption_data_dict, + by_charge_key=False, ) assert pyo.value(result) == expected_cost assert model is not None @@ -3093,6 +3096,7 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): "consumption_data_dict, " "resolution, " "decomposition_type, " + "by_charge_key, " "expected_cost, " "expected_itemized", [ @@ -3102,6 +3106,7 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): {ELECTRIC: np.ones(96), GAS: np.ones(96)}, "15m", None, + False, pytest.approx(1.2), { "electric": { @@ -3129,6 +3134,7 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): }, "15m", "absolute_value", + False, pytest.approx(-1.5), { "electric": { @@ -3172,6 +3178,7 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): }, "15m", None, + False, pytest.approx(2720.68223669), { "electric": { @@ -3188,6 +3195,31 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): }, }, ), + # by_charge_key=True + ( + {"electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05}, + {ELECTRIC: np.ones(96), GAS: np.ones(96)}, + "15m", + None, + True, + pytest.approx(1.2), + { + "electric": { + "energy": { + "electric_energy_0_2024-07-10_2024-07-10_0": pytest.approx(1.2) + }, + "export": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + "customer": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + "demand": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + }, + "gas": { + "energy": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + "export": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + "customer": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + "demand": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + }, + }, + ), ], ) def test_calculate_itemized_cost_np( @@ -3195,6 +3227,7 @@ def test_calculate_itemized_cost_np( consumption_data_dict, resolution, decomposition_type, + by_charge_key, expected_cost, expected_itemized, ): @@ -3206,9 +3239,11 @@ def test_calculate_itemized_cost_np( decomposition_type=decomposition_type, electric_consumption_units=u.kW, gas_consumption_units=u.meter**3 / u.day, + by_charge_key=by_charge_key, ) - assert result["total"] == expected_cost + total = sum(result["total"].values()) if by_charge_key else result["total"] + assert total == expected_cost for utility in expected_itemized: for charge_type in expected_itemized[utility]: print(f"utility: {utility} & charge_type: {charge_type}") @@ -3224,6 +3259,7 @@ def test_calculate_itemized_cost_np( "resolution, " "decomposition_type, " "consumption_estimate, " + "by_charge_key, " "expected_cost, " "expected_itemized", [ @@ -3234,6 +3270,7 @@ def test_calculate_itemized_cost_np( "15m", None, 0, + False, pytest.approx(120.0), { "electric": { @@ -3263,9 +3300,36 @@ def test_calculate_itemized_cost_np( "15m", "absolute_value", 240, + False, None, # No expected cost - should raise NotImplementedError None, # No expected itemized - should raise NotImplementedError ), + # by_charge_key=True + ( + {"electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05}, + {ELECTRIC: np.ones(96), GAS: np.ones(96)}, + "15m", + None, + 0, + True, + pytest.approx(1.2), + { + "electric": { + "energy": { + "electric_energy_0_2024-07-10_2024-07-10_0": pytest.approx(1.2) + }, + "export": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + "customer": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + "demand": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + }, + "gas": { + "energy": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + "export": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + "customer": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + "demand": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + }, + }, + ), ], ) def test_calculate_itemized_cost_cvx( @@ -3274,6 +3338,7 @@ def test_calculate_itemized_cost_cvx( resolution, decomposition_type, consumption_estimate, + by_charge_key, expected_cost, expected_itemized, ): @@ -3288,6 +3353,7 @@ def test_calculate_itemized_cost_cvx( resolution=resolution, decomposition_type=decomposition_type, consumption_estimate=consumption_estimate, + by_charge_key=by_charge_key, ) else: result, model = costs.calculate_itemized_cost( @@ -3296,16 +3362,28 @@ def test_calculate_itemized_cost_cvx( resolution=resolution, decomposition_type=decomposition_type, consumption_estimate=consumption_estimate, + by_charge_key=by_charge_key, ) - solve_cvx_problem(result["total"], constraints) + total_expr = sum(result["total"].values()) if by_charge_key else result["total"] + solve_cvx_problem(total_expr, constraints) - assert result["total"].value == expected_cost + total_value = ( + sum(getattr(v, "value", v) for v in result["total"].values()) + if by_charge_key + else getattr(result["total"], "value", result["total"]) + ) + assert total_value == expected_cost for utility in expected_itemized: for charge_type in expected_itemized[utility]: expected_value = expected_itemized[utility][charge_type] actual_value = result[utility][charge_type] - if hasattr(actual_value, "value"): - actual_value = actual_value.value + if by_charge_key: + # Compare value for each key + actual_value = { + k: getattr(v, "value", v) for k, v in actual_value.items() + } + else: + actual_value = getattr(actual_value, "value", actual_value) assert actual_value == expected_value @@ -3318,6 +3396,7 @@ def test_calculate_itemized_cost_cvx( "consumption_estimate, " "electric_consumption_units, " "gas_consumption_units, " + "by_charge_key, " "expected_cost, " "expected_itemized", [ @@ -3345,6 +3424,7 @@ def test_calculate_itemized_cost_cvx( 2400, None, None, + False, pytest.approx(260), { "electric": { @@ -3376,6 +3456,7 @@ def test_calculate_itemized_cost_cvx( 240, None, None, + False, pytest.approx(6.0 - 1.5), # 48*10*0.05/4 - 48*5*0.025/4 = 6.0 - 1.5 = 4.5 { "electric": { @@ -3409,6 +3490,7 @@ def test_calculate_itemized_cost_cvx( 240, u.MW, u.meters**3 / u.hour, + False, pytest.approx(4.5), { "electric": { @@ -3440,6 +3522,7 @@ def test_calculate_itemized_cost_cvx( 240, u.MW, u.meters**3 / u.hour, + False, pytest.approx(4500), { "electric": { @@ -3471,8 +3554,37 @@ def test_calculate_itemized_cost_cvx( 240, None, None, + False, None, # No expected cost - should raise NotImplementedError - None, # No expected cost - should raise NotImplementedError + None, # No expected itemized - should raise NotImplementedError + ), + # by_charge_key=True + ( + {"electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05}, + {ELECTRIC: np.ones(96), GAS: np.ones(96)}, + "15m", + None, + 0, + None, + None, + True, + pytest.approx(1.2), + { + "electric": { + "energy": { + "electric_energy_0_2024-07-10_2024-07-10_0": pytest.approx(1.2) + }, + "export": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + "customer": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + "demand": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + }, + "gas": { + "energy": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + "export": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + "customer": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + "demand": {"electric_energy_0_2024-07-10_2024-07-10_0": 0.0}, + }, + }, ), ], ) @@ -3484,6 +3596,7 @@ def test_calculate_itemized_cost_pyo( consumption_estimate, electric_consumption_units, gas_consumption_units, + by_charge_key, expected_cost, expected_itemized, ): @@ -3495,6 +3608,7 @@ def test_calculate_itemized_cost_pyo( "decomposition_type": decomposition_type, "model": model, "consumption_estimate": consumption_estimate, + "by_charge_key": by_charge_key, } if electric_consumption_units is not None: kwargs["electric_consumption_units"] = electric_consumption_units @@ -3508,20 +3622,32 @@ def test_calculate_itemized_cost_pyo( ) else: result, model = costs.calculate_itemized_cost(charge_dict, pyo_vars, **kwargs) + total_expr = sum(result["total"].values()) if by_charge_key else result["total"] solve_pyo_problem( model, - result["total"], + total_expr, decomposition_type, charge_dict, consumption_data_dict, + by_charge_key, ) - assert pyo.value(result["total"]) == expected_cost + total_value = ( + sum(pyo.value(v) for v in result["total"].values()) + if by_charge_key + else pyo.value(result["total"]) + ) + assert total_value == expected_cost for utility in expected_itemized: for charge_type in expected_itemized[utility]: expected_value = expected_itemized[utility][charge_type] - actual_value = pyo.value(result[utility][charge_type]) - assert actual_value == expected_value + actual_value = result[utility][charge_type] + if by_charge_key: + # Compare pyo.value for each key + actual_value = {k: pyo.value(v) for k, v in actual_value.items()} + assert actual_value == expected_value + else: + assert pyo.value(actual_value) == expected_value @pytest.mark.parametrize( diff --git a/eeco/tests/test_emissions.py b/eeco/tests/test_emissions.py index 3b7206c..e378f96 100644 --- a/eeco/tests/test_emissions.py +++ b/eeco/tests/test_emissions.py @@ -10,7 +10,6 @@ from eeco import emissions from eeco import utils as ut - os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) skip_all_tests = False @@ -203,6 +202,14 @@ def electric_constraint(m, t): "1h", output_dir + "july_aug_len2d_res1h.csv", ), + # Same dates / expected output as previous, but datetime input format + ( + np.datetime64("2024-07-31T00:00"), + np.datetime64("2024-08-02T00:00"), + "data/emissions_datetime_local.csv", + "1h", + output_dir + "july_aug_len2d_res1h.csv", + ), ], ) def test_get_carbon_intensity( diff --git a/setup.py b/setup.py index 2b4a9f0..517dd8b 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,7 @@ "pyomo>=6.8", "gurobipy>=11.0", "pint>=0.19.2", + "pytz>=2025.1", ] extra_requirements = {