From d7cdf4f61e7939fee79543e3cbd34c6b3ff7ad93 Mon Sep 17 00:00:00 2001 From: dalyw Date: Fri, 8 Aug 2025 14:00:35 -0700 Subject: [PATCH 01/26] Initializing branch for decomposing positive/negative charges (branched off of feature/parametrize_charges) In utils: - Updating sum() util function to use pyo.summation - Adding decompose_consumption function to split a np array, pyo variable, or cvxpy variable into positive and negative components - Adding initialize_decomposed_pyo_vars to help with initializing the decomposed elements in a pyomo model Modifying existing tests in test_costs.py to include decompositino Adding test_decompose_consumption_np, test_decompose_consumption_cvxpy, test_decompose_consumption_pyo to test_utils.py Adding documentation notes in how_to_advanced Adding test coverage for negative values input to energy / demand calculation Removing ValueError raised in calculate_cost for non-np.array/pyo/cvx object. Since this code is never actually reached because the valueerror will be raised from ut.multiply() first Consolidating consumption_data_dict and consumption_object_dict arguments Adding magnitude constraint so that positive_var[t] + negative_var[t] == abs(expression[t]) to prevent both variables becoming large when export rate is higher than energy rate Reformatted with black Updated test_calculate_cost_pyo to use ipopt rather than gurobi when running decompose_exports, since abs() function is nonlinear / nonconvex --- docs/how_to_advanced.rst | 88 ++++++++++--- docs/how_to_cost.rst | 1 + eeco/costs.py | 267 +++++++++++++++++++++++++++++++++++---- eeco/tests/test_costs.py | 228 ++++++++++++++++++++++++++++----- eeco/tests/test_utils.py | 95 ++++++++++++++ eeco/utils.py | 207 +++++++++++++++++++++++++++++- 6 files changed, 808 insertions(+), 78 deletions(-) diff --git a/docs/how_to_advanced.rst b/docs/how_to_advanced.rst index c89b1d4..48b1f41 100644 --- a/docs/how_to_advanced.rst +++ b/docs/how_to_advanced.rst @@ -135,32 +135,88 @@ the previous consumption during this billing period in conjunction with `consump How to Use `demand_scale_factor` ================================ -By default `demand_scale_factor=1`, meaning that there will be no modifications applied to the demand or energy charges. -The purpose of the scale factor is to modify the demand charges proportional to energy charges when performing moving horizon optimization. +The `demand_scale_factor` parameter allows you to scale demand charges to reflect shorter optimization horizons or to prioritize demand differently across sequential optimization horizons. -There are various heuristics that could be used to calculate the scale factor (see :ref:`why-scale-demand`), -but for now let's assume that we just want to scale the demand charge down by the length of the horizon window proportional to billing period. +By default, `demand_scale_factor=1.0`. Use values less than 1.0 when solving for a subset of the billing period, or to adjust demand charge weighting in sequential optimization. + +When `demand_scale_factor < 1.0`, demand charges are proportionally reduced to reflect the shorter optimization horizon. This is useful for: +- Moving horizon optimization where you solve for sub-periods of the billing cycle +- Sequential optimization where you want to reduce demand charge weighting as time goes on in the month .. code-block:: python - from eeco import costs + from electric_emission_cost import costs - # load necessary data - start_dt = np.datetime64("2024-07-10") - end_dt = np.datetime64("2024-07-11") - charge_dict = costs.get_charge_dict(start_dt, end_dt, tariff_df) - num_timesteps_horizon = 96 - num_timesteps_billing = 96 * 31 + # E.g. solving for 3 days out of a 30-day billing period + demand_scale_factor = 3 / 30 + + result, model = costs.calculate_cost( + charge_dict, + consumption_data, + demand_scale_factor=demand_scale_factor + # ... + ) - # this is just a CVXPY variable, but a user would provide constraints to the optimization problem - consumption_data_dict = {"electric": cp.Variable(num_timesteps), "gas": cp.Variable(num_timesteps)} +For more details on applying the sequential optimization strategy, see: - total_monthly_bill, _ = costs.calculate_costs( +  Bolorinos, J., Mauter, M.S. & Rajagopal, R. Integrated Energy Flexibility Management at Wastewater Treatment Facilities. *Environ. Sci. Technol.* **57**, 46, 18362–18371 (2023). DOI: [10.1021/acs.est.3c00365](https://doi.org/10.1021/acs.est.3c00365) + +In `bibtex` format: + +.. code-block:: bibtex + + @Article{Bolorinos2023, + author={Bolorinos, Jose + and Mauter, Meagan S. + and Rajagopal, Ram}, + title={Integrated Energy Flexibility Management at Wastewater Treatment Facilities}, + journal={Environmental Science & Technology}, + year={2023}, + month={Jun}, + day={16}, + volume={57}, + number={46}, + pages={18362--18371}, + doi={10.1021/acs.est.3c00365}, + url={https://doi.org/10.1021/acs.est.3c00365} + } + + +.. _decompose-exports: + +How to Use `decompose_exports` +============================== + +The `decompose_exports` parameter allows you to decompose consumption data into positive (imports) and negative (exports) components. This is useful when you have export charges or credits in your rate structure. + +By default, `decompose_exports=False`. Set to `True` when your charge dictionary contains export-related charges. + +.. code-block:: python + + from electric_emission_cost import costs + + # Example with export charges + charge_dict = { + "electric_export_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.025, + } + + consumption_data = { + "electric": np.concatenate([np.ones(48) * 10, -np.ones(48) * 5]), + "gas": np.ones(96), + } + + # Decompose consumption into imports and exports + result, model = costs.calculate_cost( charge_dict, - consumption_data_dict, - demand_scale_factor=num_timesteps_horizon/num_timesteps_billing + consumption_data, + decompose_exports=True ) +When `decompose_exports=True`, the function creates separate variables for positive consumption (imports) and negative consumption (exports) +and applies export charges only to the export component. +For Pyomo models, decompose_exports adds a constraint total_consumption = imports - exports + + .. _varstr-alias: How to Use `varstr_alias_func` diff --git a/docs/how_to_cost.rst b/docs/how_to_cost.rst index 0688971..1100660 100644 --- a/docs/how_to_cost.rst +++ b/docs/how_to_cost.rst @@ -73,6 +73,7 @@ NumPy # one month of 15-min intervals num_timesteps = 24 * 4 * 31 # this is synthetic consumption data, but a user could provide real historical meter data + # Positive values represent imports, negative values represent exports in consumption data consumption_data_dict = {"electric": np.ones(num_timesteps) * 100, "gas": np.ones(num_timesteps))} total_monthly_bill, _ = costs.calculate_cost(charge_dict, consumption_data_dict) diff --git a/eeco/costs.py b/eeco/costs.py index 224bcca..1712a86 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -628,10 +628,16 @@ def calculate_demand_cost( consumption_max = max(max(consumption_estimate), prev_demand) if isinstance(consumption_data, np.ndarray): - if (np.max(consumption_data) >= limit) or ( + if np.any(consumption_data < 0): + warnings.warn( + "UserWarning: Demand calculation includes negative values. " + "Pass in only positive values or " + "run calculate_cost with decompose_exports=True" + ) + if (ut.max(consumption_data)[0] >= limit) or ( (prev_demand >= limit) and (prev_demand <= next_limit) ): - if np.max(consumption_data) >= next_limit: + if ut.max(consumption_data)[0] >= next_limit: demand_charged, model = ut.multiply(next_limit - limit, charge_array) else: demand_charged, model = ut.multiply( @@ -644,7 +650,7 @@ def calculate_demand_cost( if consumption_max <= next_limit: model.add_component( varstr + "_limit", - pyo.Var(model.t, initialize=0, bounds=(0, None)), + pyo.Var(model.t, initialize=0, bounds=(None, None)), ) var = model.find_component(varstr + "_limit") @@ -777,6 +783,13 @@ def calculate_energy_cost( n_steps = len(consumption_data) if isinstance(consumption_data, np.ndarray): + if np.any(consumption_data < 0): + warnings.warn( + "UserWarning: Energy calculation includes negative values. " + "Pass in only positive values or " + "run calculate_cost with decompose_exports=True" + ) + energy = prev_consumption # set the flag if we are starting with previous consumption that lands us # within the current tier of charge limits @@ -790,14 +803,19 @@ def calculate_energy_cost( if energy >= float(next_limit): within_limit_flag = False cost += ( - float(next_limit) + consumption_data[i] / divisor - energy - ) * charge_array[i] + max( + float(next_limit) + consumption_data[i] / divisor - energy, + 0, + ) + * charge_array[i] + ) else: cost += consumption_data[i] / divisor * charge_array[i] # went over existing charge limit on this iteration elif energy >= float(limit) and energy < float(next_limit): within_limit_flag = True - cost += (energy - float(limit)) * charge_array[i] + cost += max(energy - float(limit), 0) * charge_array[i] + elif isinstance(consumption_data, (cp.Expression, pyo.Var, pyo.Param)): # assume consumption is split evenly as an approximation # NOTE: this convex approximation breaks global optimality guarantees @@ -834,12 +852,11 @@ def calculate_energy_cost( "consumption_data must be of type numpy.ndarray, " "cvxpy.Expression, or pyomo.environ.Var" ) - return cost, model -def calculate_export_revenues( - charge_array, export_data, divisor, model=None, varstr="" +def calculate_export_revenue( + charge_array, consumption_data, divisor, model=None, varstr="" ): """Calculates the export revenues for the given billing rate structure, utility, and consumption information. @@ -853,7 +870,9 @@ def calculate_export_revenues( array with price per kWh sold back to the grid consumption_data : numpy.ndarray, cvxpy.Expression, or pyomo.environ.Var - Baseline electrical or gas usage data as an optimization variable object + Magnitude of exported electrical or gas usage data + as an optimization variable object. + Should be positive values. divisor : int Divisor for the export revenue, based on the timeseries resolution @@ -865,21 +884,38 @@ def calculate_export_revenues( varstr : str Name of the variable to be created if using a Pyomo `model` + Raises + ------ + ValueError + When invalid `utility`, `charge_type`, or `assessed` + is provided in `charge_arrays` + Returns ------- (cvxpy.Expression, pyomo.environ.Var, or float), pyomo.Model tuple with the first entry being a float, cvxpy Expression, or pyomo Var representing export revenues - in USD for the given `charge_array` and `consumption_data` + in USD for the given `charge_array` and `export_data` and the second entry being the pyomo model object (or None) """ - varstr_mul = varstr + "_multiply" if varstr is not None else None - varstr_sum = varstr + "_sum" if varstr is not None else None - result, model = ut.multiply( - charge_array, export_data, model=model, varstr=varstr_mul - ) - revenues, model = ut.sum(result, model=model, varstr=varstr_sum) - return revenues / divisor, model + if isinstance(consumption_data, np.ndarray): + return np.sum(consumption_data * charge_array) / divisor, model + + elif isinstance(consumption_data, (cp.Expression, pyo.Var, pyo.Param)): + cost_expr, model = ut.multiply( + consumption_data, + charge_array, + model=model, + varstr=varstr + "_multiply", + ) + export_revenue, model = ut.sum(cost_expr, model=model, varstr=varstr + "_sum") + + return export_revenue / divisor, model + else: + raise ValueError( + "consumption_data must be of type numpy.ndarray, " + "cvxpy.Expression, or pyomo.environ.Var" + ) def get_charge_array_duration(key): @@ -938,6 +974,7 @@ def calculate_cost( desired_charge_type=None, demand_scale_factor=1, model=None, + decompose_exports=False, varstr_alias_func=default_varstr_alias_func, ): """Calculates the cost of given charges (demand or energy) for the given @@ -955,8 +992,17 @@ def calculate_cost( consumption_data_dict : dict Baseline electrical and gas usage data as an optimization variable object - with keys "electric" and "gas". Values of the dictionary must be of type - numpy.ndarray, cvxpy.Expression, or pyomo.environ.Var + with keys "electric" and "gas". Supports two formats: + + Default format: + Values are cumulative consumption data + Example: {"electric": np.array([]), "gas": np.array([])} + + Extended format for imports/exports: + Values are dictionaries with decomposed "imports" and "exports" keys + Example: {"electric": {"imports": np.array([]), "exports": np.array([])}} + + Values must be of type numpy.ndarray, cvxpy.Expression, or pyomo.environ.Var. electric_consumption_units : pint.Unit Units for the electricity consumption data. Default is kW @@ -1260,6 +1306,10 @@ def build_pyomo_costing( Additional terms to be added to the objective function. Must be a list of pyomo Expressions. + decompose_exports : indicates whether to add additional optimization variables + indicating positive or negative consumption. Set to "True" if electricity + or gas exports are possible. Default "False" + varstr_alias_func: function Function to generate variable name for pyomo, should take in a 6 inputs and generate a string output. @@ -1306,12 +1356,130 @@ def build_pyomo_costing( varstr_alias_func=varstr_alias_func, ) - model.objective = pyo.Objective(expr=model.electricity_cost, sense=pyo.minimize) + # Initialize definition of conversion factors for each utility type + conversion_factors = {} + conversion_factors[ELECTRIC] = (1 * electric_consumption_units).to(u.kW).magnitude + conversion_factors[GAS] = ( + (1 * gas_consumption_units).to(u.meter**3 / u.day).magnitude + ) + + # Ensure consumption_data_dict has imports/exports structure for each utility + for utility in consumption_data_dict.keys(): + # Check if this utility already has imports/exports structure + if ( + isinstance(consumption_data_dict[utility], dict) + and "imports" in consumption_data_dict[utility] + and "exports" in consumption_data_dict[utility] + ): + continue + else: # create imports/exports + conversion_factor = conversion_factors[utility] + + converted_consumption, model = ut.multiply( + consumption_data_dict[utility], + conversion_factor, + model=model, + varstr=utility + "_converted", + ) + + if decompose_exports: + # Decompose consumption data into positive and negative components + # with constraint that total = positive - negative + # (where negative is stored as positive magnitude) + imports, exports, model = ut.decompose_consumption( + converted_consumption, + model=model, + varstr=utility, + ) + + consumption_data_dict[utility] = { + "imports": imports, + "exports": exports, + } + else: + consumption_data_dict[utility] = { + "imports": converted_consumption, + "exports": converted_consumption, + } + + for key, charge_array in charge_dict.items(): + utility, charge_type, name, eff_start, eff_end, limit_str = key.split("_") + varstr = ut.sanitize_varstr( + varstr_alias_func(utility, charge_type, name, eff_start, eff_end, limit_str) + ) + + # if we want itemized costs skip irrelvant portions of the bill + if (desired_utility and utility not in desired_utility) or ( + desired_charge_type and charge_type not in desired_charge_type + ): + continue + + if utility == ELECTRIC: + divisor = n_per_hour + elif utility == GAS: + divisor = n_per_day / conversion_factors[utility] + else: + raise ValueError("Invalid utility: " + utility) + + charge_limit = int(limit_str) + key_substr = "_".join([utility, charge_type, name, eff_start, eff_end]) + next_limit = get_next_limit(key_substr, charge_limit, charge_dict.keys()) + + # Only apply demand_scale_factor if charge spans more than one day + charge_duration_days = get_charge_array_duration(key) + effective_scale_factor = demand_scale_factor if charge_duration_days > 1 else 1 - if additional_objective_terms is not None: - for term in additional_objective_terms: - model.objective.expr += term - return model + if charge_type == DEMAND: + if prev_demand_dict is not None: + prev_demand = prev_demand_dict[key][DEMAND] + prev_demand_cost = prev_demand_dict[key]["cost"] + else: + prev_demand = 0 + prev_demand_cost = 0 + new_cost, model = calculate_demand_cost( + charge_array, + consumption_data_dict[utility]["imports"], + limit=charge_limit, + next_limit=next_limit, + prev_demand=prev_demand, + prev_demand_cost=prev_demand_cost, + consumption_estimate=consumption_estimate, + scale_factor=effective_scale_factor, + model=model, + varstr=varstr, + ) + cost += new_cost + elif charge_type == ENERGY: + if prev_consumption_dict is not None: + prev_consumption = prev_consumption_dict[key] + else: + prev_consumption = 0 + new_cost, model = calculate_energy_cost( + charge_array, + consumption_data_dict[utility]["imports"], + divisor, + limit=charge_limit, + next_limit=next_limit, + prev_consumption=prev_consumption, + consumption_estimate=consumption_estimate, + model=model, + varstr=varstr, + ) + cost += new_cost + elif charge_type == EXPORT: + new_cost, model = calculate_export_revenue( + charge_array, + consumption_data_dict[utility]["exports"], + divisor, + model=model, + varstr=varstr, + ) + cost -= new_cost + elif charge_type == CUSTOMER: + cost += charge_array.sum() + else: + raise ValueError("Invalid charge_type: " + charge_type) + return cost, model def calculate_itemized_cost( @@ -1326,6 +1494,7 @@ def calculate_itemized_cost( desired_utility=None, demand_scale_factor=1, model=None, + decompose_exports=False, varstr_alias_func=default_varstr_alias_func, ): """Calculates itemized costs as a nested dictionary @@ -1343,6 +1512,8 @@ def calculate_itemized_cost( Baseline electrical and gas usage data as an optimization variable object with keys "electric" and "gas". Values of the dictionary must be of type numpy.ndarray, cvxpy.Expression, or pyomo.environ.Var + Positive values represent energy imports (consumption from the grid) + Negative values represent energy exports (generation sent to the grid) electric_consumption_units : pint.Unit Units for the electricity consumption data. Default is kW @@ -1425,6 +1596,50 @@ def calculate_itemized_cost( total_cost = 0 results_dict = {} + # Create consumption objects once to avoid recreating in each calculate_cost call + for utility in consumption_data_dict.keys(): + # Check if this utility already has imports/exports structure + if ( + isinstance(consumption_data_dict[utility], dict) + and "imports" in consumption_data_dict[utility] + and "exports" in consumption_data_dict[utility] + ): + continue + else: # create imports/exports + if utility == ELECTRIC: + conversion_factor = (1 * electric_consumption_units).to(u.kW).magnitude + elif utility == GAS: + conversion_factor = ( + (1 * gas_consumption_units).to(u.meter**3 / u.day).magnitude + ) + else: + raise ValueError("Invalid utility: " + utility) + + converted_consumption, model = ut.multiply( + consumption_data_dict[utility], + conversion_factor, + model=model, + varstr=utility + "_converted", + ) + + if decompose_exports: + # Decompose consumption data into positive and negative components + # with constraint that total = positive - negative + # (where negative is stored as positive magnitude) + imports, exports, model = ut.decompose_consumption( + converted_consumption, model=model, varstr=utility + "_decomposed" + ) + + consumption_data_dict[utility] = { + "imports": imports, + "exports": exports, + } + else: + consumption_data_dict[utility] = { + "imports": converted_consumption, + "exports": converted_consumption, + } + if desired_utility is None: for utility in [ELECTRIC, GAS]: results_dict[utility] = {} @@ -1442,6 +1657,7 @@ def calculate_itemized_cost( desired_charge_type=charge_type, demand_scale_factor=demand_scale_factor, model=model, + decompose_exports=decompose_exports, varstr_alias_func=varstr_alias_func, ) @@ -1467,6 +1683,7 @@ def calculate_itemized_cost( desired_charge_type=charge_type, demand_scale_factor=demand_scale_factor, model=model, + decompose_exports=decompose_exports, varstr_alias_func=varstr_alias_func, ) diff --git a/eeco/tests/test_costs.py b/eeco/tests/test_costs.py index a0ec68a..c964059 100644 --- a/eeco/tests/test_costs.py +++ b/eeco/tests/test_costs.py @@ -535,7 +535,8 @@ def test_get_charge_dict(start_dt, end_dt, billing_path, resolution, expected): @pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") @pytest.mark.parametrize( "charge_dict, consumption_data_dict, resolution, prev_demand_dict, " - "consumption_estimate, desired_utility, desired_charge_type, expected_cost", + "consumption_estimate, desired_utility, desired_charge_type, expected_cost, " + "expect_warning, expect_error", [ # single energy charge with flat consumption ( @@ -547,6 +548,8 @@ def test_get_charge_dict(start_dt, end_dt, billing_path, resolution, expected): None, None, pytest.approx(1.2), + False, + False, ), # single energy charge with increasing consumption ( @@ -558,6 +561,8 @@ def test_get_charge_dict(start_dt, end_dt, billing_path, resolution, expected): None, None, np.sum(np.arange(96)) * 0.05 / 4, + False, + False, ), # energy charge with charge limit ( @@ -584,6 +589,58 @@ def test_get_charge_dict(start_dt, end_dt, billing_path, resolution, expected): None, None, 260, + False, + False, + ), + # single energy charge with negative consumption values - should warn + ( + {"electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05}, + { + ELECTRIC: np.concatenate([np.ones(48) * 10, -np.ones(48) * 5]), + GAS: np.ones(96), + }, + "15m", + None, + 0, + None, + None, + pytest.approx( + 3.0 + ), # (48*10 + 48*5) * 0.05 / 4 = 3.0 (negative values treated as magnitude) + True, + False, + ), + # list input instead of numpy array + ( + {"electric_energy_0_2024-07-10_2024-07-10_0": np.ones(4) * 0.05}, + { + ELECTRIC: [1, 2, 3, 4], + GAS: [1, 1, 1, 1], + }, # Lists instead of numpy arrays + "15m", + None, + 0, + None, + None, + None, # No expected cost + False, + True, + ), + # predefined consumption_data_dict format with invalid import/export types + ( + {"electric_energy_0_2024-07-10_2024-07-10_0": np.ones(4) * 0.05}, + { + ELECTRIC: {"imports": [1, 2, 3, 4], "exports": [1, 2, 3, 4]}, + GAS: np.ones(4), + }, # Extended format with invalid list types + "15m", + None, + 0, + None, + None, + None, # No expected cost since error is raised + False, + True, # AttributeError ), ], ) @@ -596,18 +653,70 @@ def test_calculate_cost_np( desired_utility, desired_charge_type, expected_cost, + expect_warning, + expect_error, ): - result, model = costs.calculate_cost( - charge_dict, - consumption_data_dict, - resolution=resolution, - prev_demand_dict=prev_demand_dict, - consumption_estimate=consumption_estimate, - desired_utility=desired_utility, - desired_charge_type=desired_charge_type, - ) - assert result == expected_cost - assert model is None + if expect_error: + if ( + isinstance(consumption_data_dict.get(ELECTRIC), dict) + and "imports" in consumption_data_dict[ELECTRIC] + ): + # Import/export format with invalid list types + with pytest.raises( + AttributeError, match="'list' object has no attribute 'shape'" + ): + costs.calculate_cost( + charge_dict, + consumption_data_dict, + resolution=resolution, + prev_demand_dict=prev_demand_dict, + consumption_estimate=consumption_estimate, + desired_utility=desired_utility, + desired_charge_type=desired_charge_type, + ) + else: + # Invalid list types + with pytest.raises( + TypeError, + match="Only CVXPY or Pyomo variables and NumPy arrays " + "are currently supported", + ): + costs.calculate_cost( + charge_dict, + consumption_data_dict, + resolution=resolution, + prev_demand_dict=prev_demand_dict, + consumption_estimate=consumption_estimate, + desired_utility=desired_utility, + desired_charge_type=desired_charge_type, + ) + elif expect_warning: + with pytest.warns( + UserWarning, match="Energy calculation includes negative values" + ): + result, model = costs.calculate_cost( + charge_dict, + consumption_data_dict, + resolution=resolution, + prev_demand_dict=prev_demand_dict, + consumption_estimate=consumption_estimate, + desired_utility=desired_utility, + desired_charge_type=desired_charge_type, + ) + assert result == expected_cost + assert model is None + else: + result, model = costs.calculate_cost( + charge_dict, + consumption_data_dict, + resolution=resolution, + prev_demand_dict=prev_demand_dict, + consumption_estimate=consumption_estimate, + desired_utility=desired_utility, + desired_charge_type=desired_charge_type, + ) + assert result == expected_cost + assert model is None @pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") @@ -1039,7 +1148,7 @@ def test_calculate_cost_cvx( 0, None, None, - pytest.approx(140), + pytest.approx(138), ), # demand charge with no previous consumption ( @@ -1083,7 +1192,23 @@ def test_calculate_cost_cvx( 0, None, None, - pytest.approx(1191), + pytest.approx(1188), + ), + # export charges + ( + { + "electric_export_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.025, + }, + { + ELECTRIC: np.concatenate([np.ones(48) * 10, -np.ones(48) * 5]), + GAS: np.ones(96), + }, + "15m", + None, + 0, + None, + None, + pytest.approx(-1.5), ), # energy charge with charge limit and time-varying consumption estimate ( @@ -1205,21 +1330,25 @@ def test_calculate_cost_pyo( expected_cost, ): model = pyo.ConcreteModel() - model.T = len(consumption_data_dict["electric"]) - model.t = range(model.T) - pyo_vars = {} - for key, val in consumption_data_dict.items(): - var = pyo.Var(range(len(val)), initialize=np.zeros(len(val)), bounds=(0, None)) - model.add_component(key, var) - pyo_vars[key] = var + model.T = len(consumption_data_dict[ELECTRIC]) + model.t = pyo.RangeSet(0, model.T - 1) + model.electric_consumption = pyo.Var(model.t, bounds=(None, None)) + model.gas_consumption = pyo.Var(model.t, bounds=(None, None)) - @model.Constraint(model.t) - def electric_constraint(m, t): - return consumption_data_dict["electric"][t] == m.electric[t] + # Constrain variables to initialized values + def electric_constraint_rule(model, t): + return model.electric_consumption[t] == consumption_data_dict[ELECTRIC][t - 1] - @model.Constraint(model.t) - def gas_constraint(m, t): - return consumption_data_dict["gas"][t] == m.gas[t] + def gas_constraint_rule(model, t): + return model.gas_consumption[t] == consumption_data_dict[GAS][t - 1] + + model.electric_constraint = pyo.Constraint(model.t, rule=electric_constraint_rule) + model.gas_constraint = pyo.Constraint(model.t, rule=gas_constraint_rule) + + pyo_vars = { + "electric": model.electric_consumption, + "gas": model.gas_consumption, + } result, model = costs.calculate_cost( charge_dict, @@ -1230,10 +1359,28 @@ def gas_constraint(m, t): desired_utility=desired_utility, desired_charge_type=desired_charge_type, model=model, + decompose_exports=any("export" in key for key in charge_dict.keys()), ) - model.objective = pyo.Objective(expr=result) - solver = pyo.SolverFactory("gurobi") + # Initialize Pyomo variables if decompose_exports is True + decompose_exports = any("export" in key for key in charge_dict.keys()) + if decompose_exports: + init_consumption_data = { + "electric": np.array( + [consumption_data_dict[ELECTRIC][t - 1] for t in model.t] + ), + "gas": np.array([consumption_data_dict[GAS][t - 1] for t in model.t]), + } + utils.initialize_decomposed_pyo_vars(init_consumption_data, model, charge_dict) + + model.obj = pyo.Objective(expr=result) + + # Use IPOPT for nonlinear constraints when decompose_exports=True + if decompose_exports: + solver = pyo.SolverFactory("ipopt") + else: # Gurobi otherwise + solver = pyo.SolverFactory("gurobi") + solver.solve(model) assert pyo.value(result) == expected_cost assert model is not None @@ -1698,13 +1845,28 @@ def test_calculate_energy_costs( @pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") @pytest.mark.parametrize( - "charge_array, export_data, divisor, expected", + "charge_array, export_data, divisor, expected, expect_warning", [ - (np.ones(96), np.arange(96), 4, 1140), + ( + np.ones(96), + np.arange(96), + 4, + 1140, + False, + ), # positive values (export magnitude) + ( + np.ones(96), + np.arange(96), + 4, + 1140, + False, + ), # positive values (export magnitude) ], ) -def test_calculate_export_revenues(charge_array, export_data, divisor, expected): - result, model = costs.calculate_export_revenues(charge_array, export_data, divisor) +def test_calculate_export_revenue( + charge_array, export_data, divisor, expected, expect_warning +): + result, model = costs.calculate_export_revenue(charge_array, export_data, divisor) assert result == expected assert model is None diff --git a/eeco/tests/test_utils.py b/eeco/tests/test_utils.py index d5ccaf2..48cb10b 100644 --- a/eeco/tests/test_utils.py +++ b/eeco/tests/test_utils.py @@ -2,6 +2,7 @@ import pytest import numpy as np import pyomo.environ as pyo +import cvxpy as cp from eeco import utils as ut @@ -158,3 +159,97 @@ def gas_constraint(m, t): solver.solve(model) assert pyo.value(result) == expected assert model is not None + + +@pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") +@pytest.mark.parametrize( + "consumption_data, expected_positive, expected_negative", + [ + ( + np.array([1, -2, 3, -4, 0]), + np.array([1, 0, 3, 0, 0]), + np.array([0, 2, 0, 4, 0]), + ), + ( + np.array([5, 0, -3, 7, -1]), + np.array([5, 0, 0, 7, 0]), + np.array([0, 0, 3, 0, 1]), + ), + (np.array([0, 0, 0]), np.array([0, 0, 0]), np.array([0, 0, 0])), + (np.array([-10, -5, -1]), np.array([0, 0, 0]), np.array([10, 5, 1])), + (np.array([10, 5, 1]), np.array([10, 5, 1]), np.array([0, 0, 0])), + ], +) +def test_decompose_consumption_np( + consumption_data, expected_positive, expected_negative +): + """Test decompose_consumption with numpy arrays.""" + positive_values, negative_values, model = ut.decompose_consumption(consumption_data) + + assert np.array_equal(positive_values, expected_positive) + assert np.array_equal(negative_values, expected_negative) + assert model is None + assert np.array_equal(consumption_data, positive_values - negative_values) + + +@pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") +def test_decompose_consumption_cvx(): + """Test decompose_consumption with cvxpy expressions.""" + x = cp.Variable(5) + positive_values, negative_values, model = ut.decompose_consumption(x) + assert isinstance(positive_values, cp.Expression) + assert isinstance(negative_values, cp.Expression) + # TODO: add value checks + + +@pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") +@pytest.mark.parametrize( + "consumption_data, expected_positive_sum, expected_negative_sum", + [ + (np.array([1, -2, 3, -4, 0]), 4, 6), # positive: 1+3=4, negative: 2+4=6 + (np.array([5, 0, -3, 7, -1]), 12, 4), # positive: 5+7=12, negative: 3+1=4 + (np.array([0, 0, 0]), 0, 0), + (np.array([-10, -5, -1]), 0, 16), # positive: 0, negative: 10+5+1=16 + (np.array([10, 5, 1]), 16, 0), # positive: 10+5+1=16, negative: 0 + ], +) +def test_decompose_consumption_pyo( + consumption_data, expected_positive_sum, expected_negative_sum +): + """Test decompose_consumption with pyomo variables.""" + model = pyo.ConcreteModel() + model.T = len(consumption_data) + model.t = range(1, model.T + 1) # Pyomo uses 1-indexed + + model.electric_consumption = pyo.Var(model.t, initialize=0) + for t in model.t: + model.electric_consumption[t].value = consumption_data[t - 1] + + positive_var, negative_var, model = ut.decompose_consumption( + model.electric_consumption, model=model, varstr="electric" + ) + + init_consumption_data = { + "electric": consumption_data, + } + ut.initialize_decomposed_pyo_vars(init_consumption_data, model, None) + + # Verify the expected sums from the initialized values + assert ( + abs(sum(pyo.value(positive_var[t]) for t in model.t) - expected_positive_sum) + < 1e-6 + ) + assert ( + abs(sum(pyo.value(negative_var[t]) for t in model.t) - expected_negative_sum) + < 1e-6 + ) + + # Verify the decomposition constraint is satisfied + for t in model.t: + assert ( + abs( + pyo.value(model.electric_consumption[t]) + - (pyo.value(positive_var[t]) - pyo.value(negative_var[t])) + ) + < 1e-6 + ) diff --git a/eeco/utils.py b/eeco/utils.py index 79daaed..738dcd4 100644 --- a/eeco/utils.py +++ b/eeco/utils.py @@ -240,10 +240,7 @@ def sum(expression, axis=0, model=None, varstr=None): var = model.find_component(varstr) def const_rule(model): - total = 0 - for i in range(len(expression)): - total += expression[i] - return var == total + return var == pyo.summation(expression) constraint = pyo.Constraint(rule=const_rule) model.add_component(varstr + "_constraint", constraint) @@ -333,6 +330,208 @@ def const_rule(model, t): ) +def initialize_decomposed_pyo_vars(consumption_data_dict, model, charge_dict): + """Helper function to initialize Pyomo variables with baseline consumption values. + + This function takes consumption data as numpy arrays, decomposes them using + the numpy version of decompose_consumption, and then initializes the corresponding + Pyomo variables with those values. + + Parameters + ---------- + consumption_data_dict : dict + Dictionary with keys "electric" and "gas" containing numpy arrays + of consumption data. + + model : pyomo.environ.Model + The Pyomo model containing the variables to initialize. + + charge_dict : dict + Dictionary containing charge arrays for different utilities and charge types. + Used to extract the correct charge rate for export calculations. + + Returns + ------- + dict + Dictionary with initialized consumption objects for each utility. + """ + consumption_object_dict = {} + + # Initialize the basic consumption variables and converted variables + for utility in consumption_data_dict.keys(): + consumption_data = consumption_data_dict[utility] + consumption_var = model.find_component(f"{utility}_consumption") + if consumption_var is not None: + for t in model.t: + consumption_var[t].value = consumption_data[t - 1] # Pyomo 1-indexed + converted_var = model.find_component(f"{utility}_converted") + if converted_var is not None: + for t in model.t: + converted_var[t].value = consumption_data[t - 1] + + consumption_object_dict[utility] = {} + consumption_data = consumption_data_dict[utility] + + # Decompose using numpy version + positive_values, negative_values, _ = decompose_consumption(consumption_data) + + # Find and initialize the corresponding Pyomo variables + positive_var = model.find_component(f"{utility}_positive") + negative_var = model.find_component(f"{utility}_negative") + + for t in model.t: + positive_var[t].value = positive_values[t - 1] + negative_var[t].value = negative_values[t - 1] + + consumption_object_dict[utility]["imports"] = positive_var + consumption_object_dict[utility]["exports"] = negative_var + + # # Initialize export-related variables created by calculate_export_revenue + for component_name in model.component_map(): + if "_multiply" in component_name: + component = model.find_component(component_name) + if hasattr(component, "__iter__") and hasattr( + component[list(component.keys())[0]], "value" + ): + for i in component: + export_var = model.find_component("electric_negative") + if export_var is not None and hasattr(export_var[i], "value"): + charge_rate = 0.0 # Default export + if charge_dict is not None: + export_keys = [ + key for key in charge_dict.keys() if "export" in key + ] + if export_keys: + charge_rate = charge_dict[export_keys[0]][0] + component[i].value = export_var[i].value * charge_rate + else: + component[i].value = 0.0 + if "_sum" in component_name: + component = model.find_component(component_name) + if hasattr(component, "value"): + multiply_var = model.find_component( + component_name.replace("_sum", "_multiply") + ) + if multiply_var is not None and hasattr(multiply_var, "__iter__"): + total = 0.0 + for i in multiply_var: + if hasattr(multiply_var[i], "value"): + total += multiply_var[i].value + component.value = total + else: + component.value = 0.0 + + return consumption_object_dict + + +def decompose_consumption(expression, model=None, varstr=None): + """Decomposes consumption data into positive and negative components + And adds constraint such that total consumption equals + positive values minus negative values + (where negative values are stored as positive magnitudes). + + Parameters + ---------- + expression : [ + numpy.Array, + cvxpy.Expression, + pyomo.core.expr.numeric_expr.NumericExpression, + pyomo.core.expr.numeric_expr.NumericNDArray, + pyomo.environ.Param, + pyomo.environ.Var + ] + Expression representing consumption data + + model : pyomo.environ.Model + The model object associated with the problem. + Only used in the case of Pyomo, so `None` by default. + + varstr : str + Name prefix for the variables to be created if using a Pyomo `model` + + Returns + ------- + tuple + (positive_values, negative_values, model) where + positive_values and negative_values are both positive + with the constraint that total = positive - negative + """ + if isinstance(expression, np.ndarray): + positive_values = np.maximum(expression, 0) + negative_values = np.maximum(-expression, 0) # magnitude as positive + return positive_values, negative_values, model + elif isinstance(expression, cp.Expression): + positive_values = cp.maximum(expression, 0) + negative_values = cp.maximum(-expression, 0) # magnitude as positive + return positive_values, negative_values, model + elif isinstance(expression, (pyo.Var, pyo.Param)): + # Create positive consumption variable + model.add_component(f"{varstr}_positive", pyo.Var(model.t, bounds=(0, None))) + positive_var = model.find_component(f"{varstr}_positive") + + def positive_lower_bound_rule(model, t): + return positive_var[t] >= 0 + + def positive_expr_bound_rule(model, t): + return positive_var[t] >= expression[t] + + model.add_component( + f"{varstr}_positive_lower_bound", + pyo.Constraint(model.t, rule=positive_lower_bound_rule), + ) + model.add_component( + f"{varstr}_positive_expr_bound", + pyo.Constraint(model.t, rule=positive_expr_bound_rule), + ) + + # Create negative consumption magnitude variable + model.add_component(f"{varstr}_negative", pyo.Var(model.t, bounds=(0, None))) + negative_var = model.find_component(f"{varstr}_negative") + + def negative_lower_bound_rule(model, t): + return negative_var[t] >= 0 + + def negative_expr_bound_rule(model, t): + return ( + negative_var[t] >= -expression[t] + ) # Flips sign of the negative consumption component + + model.add_component( + f"{varstr}_negative_lower_bound", + pyo.Constraint(model.t, rule=negative_lower_bound_rule), + ) + model.add_component( + f"{varstr}_negative_expr_bound", + pyo.Constraint(model.t, rule=negative_expr_bound_rule), + ) + + # Add constraint: expression = positive_var - negative_var + # Balances import and export decomposed values + def decomposition_rule(model, t): + return expression[t] == positive_var[t] - negative_var[t] + + model.add_component( + f"{varstr}_decomposition_constraint", + pyo.Constraint(model.t, rule=decomposition_rule), + ) + + # Add constraint to ensure positive_var + negative_var = |expression| + # This both variables becoming larger due to artificial arbitrage + def magnitude_rule(model, t): + return positive_var[t] + negative_var[t] == abs(expression[t]) + + model.add_component( + f"{varstr}_magnitude_constraint", + pyo.Constraint(model.t, rule=magnitude_rule), + ) + + return positive_var, negative_var, model + else: + raise TypeError( + "Only CVXPY or Pyomo variables and NumPy arrays are currently supported." + ) + + def multiply( expression1, expression2, From 710c62fcb757ae64039d12d019247495848ab5d8 Mon Sep 17 00:00:00 2001 From: dalyw Date: Mon, 8 Sep 2025 15:46:53 -0700 Subject: [PATCH 02/26] Renaming scale_ratios to percent_change_dict in parametrize_charge_dict and parametrize_rate_data Adding test_calculate_itemized_costs for np, cvx, and pyo with one non-decomposed and one decomposed example Adding a warning test to calculate_export_revenue Adding helper functions for pyo and cvx problems to be used by their versions of both test_calculate_cost and test_calculate_itemized_cost Adding warning that decompose_exports isn't supported with cvxpy due to non-DCP exports-imports issues Reformatted with black --- eeco/costs.py | 62 ++- eeco/tests/test_costs.py | 508 ++++++++++++++++----- eeco/utils.py | 10 +- examples/example_parametrize_charges.ipynb | 10 +- 4 files changed, 454 insertions(+), 136 deletions(-) diff --git a/eeco/costs.py b/eeco/costs.py index 1712a86..b74bbfc 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -899,6 +899,12 @@ def calculate_export_revenue( and the second entry being the pyomo model object (or None) """ if isinstance(consumption_data, np.ndarray): + if np.any(consumption_data < 0): + warnings.warn( + "UserWarning: Export revenue calculation includes negative values. " + "Pass in only positive values or " + "run calculate_cost with decompose_exports=True" + ) return np.sum(consumption_data * charge_array) / divisor, model elif isinstance(consumption_data, (cp.Expression, pyo.Var, pyo.Param)): @@ -1386,6 +1392,7 @@ def build_pyomo_costing( # Decompose consumption data into positive and negative components # with constraint that total = positive - negative # (where negative is stored as positive magnitude) + pos_name, neg_name = ut._get_decomposed_var_names(utility) imports, exports, model = ut.decompose_consumption( converted_consumption, model=model, @@ -1593,6 +1600,16 @@ def calculate_itemized_cost( } """ + # Check if decompose_exports=True is used with CVXPY objects + # (not yet supported because imports - exports creates non-DCP issues) + if decompose_exports: + for utility in consumption_data_dict.keys(): + if isinstance(consumption_data_dict[utility], cp.Variable): + raise ValueError( + "decompose_exports=True is not supported with CVXPY objects. " + "Use Pyomo instead for problems requiring decompose_exports." + ) + total_cost = 0 results_dict = {} @@ -1626,8 +1643,9 @@ def calculate_itemized_cost( # Decompose consumption data into positive and negative components # with constraint that total = positive - negative # (where negative is stored as positive magnitude) + pos_name, neg_name = ut._get_decomposed_var_names(utility) imports, exports, model = ut.decompose_consumption( - converted_consumption, model=model, varstr=utility + "_decomposed" + converted_consumption, model=model, varstr=utility ) consumption_data_dict[utility] = { @@ -1828,7 +1846,7 @@ def detect_charge_periods( def parametrize_rate_data( rate_data, - scale_ratios={}, + percent_change_dict={}, shift_peak_hours_before=0, shift_peak_hours_after=0, variant_name=None, @@ -1842,8 +1860,7 @@ def parametrize_rate_data( ---------- rate_data : pandas.DataFrame Tariff data with required columns - - scale_ratios : dict, optional + percent_change_dict : dict, optional Dictionary for charge scaling. Can be one of three formats: Format 1 - Nested dictionary with structure for charge scaling: @@ -1914,10 +1931,10 @@ def parametrize_rate_data( Raises ------ ValueError - If scale_ratios contains both period-based scaling and individual charge + If percent_change_dict contains both period-based scaling and individual charge scaling for the same charge type UserWarning - If scale_ratios contains exact charge keys that are not found in the data + If percent_change_dict contains exact charge keys that are not found in the data """ variant_data = rate_data.copy(deep=True) # deep copy required for variants variant_data[HOUR_START] = variant_data[HOUR_START].astype(float) @@ -1929,25 +1946,26 @@ def parametrize_rate_data( else [CHARGE] ) - # Determine which format scale_ratios was passed in - has_exact_keys = len(scale_ratios) > 0 and any( + # Determine which format percent_change_dict was passed in + has_exact_keys = len(percent_change_dict) > 0 and any( isinstance(k, str) and ("electric_" in k or "gas_" in k) - for k in scale_ratios.keys() + for k in percent_change_dict.keys() ) - has_global_scaling = len(scale_ratios) > 0 and any( + has_global_scaling = len(percent_change_dict) > 0 and any( k in [DEMAND, ENERGY] and isinstance(v, (int, float)) - for k, v in scale_ratios.items() + for k, v in percent_change_dict.items() ) - has_period_scaling = len(scale_ratios) > 0 and any( - isinstance(v, dict) and k in [DEMAND, ENERGY] for k, v in scale_ratios.items() + has_period_scaling = len(percent_change_dict) > 0 and any( + isinstance(v, dict) and k in [DEMAND, ENERGY] + for k, v in percent_change_dict.items() ) # Check for conflicts between period/global scaling and exact keys if has_exact_keys and (has_period_scaling or has_global_scaling): raise ValueError( - "scale_ratios cannot contain both exact charge keys" + "percent_change_dict cannot contain both exact charge keys" " and global/period-based scaling" ) @@ -1965,11 +1983,11 @@ def parametrize_rate_data( for charge_type in [ENERGY, DEMAND]: if ( has_global_scaling - and charge_type in scale_ratios - and isinstance(scale_ratios[charge_type], (int, float)) + and charge_type in percent_change_dict + and isinstance(percent_change_dict[charge_type], (int, float)) ): # Format 3: Global scaling for all charges of this type - scale_factor = scale_ratios[charge_type] + scale_factor = percent_change_dict[charge_type] charge_ratios = { PEAK: scale_factor, HALF_PEAK: scale_factor, @@ -1978,10 +1996,10 @@ def parametrize_rate_data( } # Format 2: Exact charge keys elif has_exact_keys: - charge_ratios = scale_ratios + charge_ratios = percent_change_dict # Format 1: Period-based scaling - elif has_period_scaling and charge_type in scale_ratios: - charge_ratios = scale_ratios[charge_type] + elif has_period_scaling and charge_type in percent_change_dict: + charge_ratios = percent_change_dict[charge_type] else: # No scaling - window shifting only charge_ratios = { PEAK: 1.0, @@ -2150,7 +2168,7 @@ def parametrize_rate_data( if missing_keys and has_exact_keys: warnings.warn( - f"The following charge keys were not found in scale_ratios and " + f"The following charge keys were not found in percent_change_dict and " f"will use default ratio of 1.0: {sorted(list(missing_keys))}", UserWarning, ) @@ -2174,7 +2192,7 @@ def parametrize_charge_dict(start_dt, end_dt, rate_data, variants=None): tariff data with required columns variants : list[dict] List of dictionaries containing variation parameters with keys: - - scale_ratios: dict for charge scaling (see parametrize_rate_data for options) + - percent_change_dict: dict for charge scaling (see parametrize_rate_data) - shift_peak_hours_before: float to shift peak start, in hours - shift_peak_hours_after: float to shift peak end, in hours - variant_name: str (optional) variant name diff --git a/eeco/tests/test_costs.py b/eeco/tests/test_costs.py index c964059..e35535f 100644 --- a/eeco/tests/test_costs.py +++ b/eeco/tests/test_costs.py @@ -33,6 +33,74 @@ output_dir = "tests/data/output/" +def setup_cvx_vars_constraints(consumption_data_dict): + """Helper to set up CVXPY variables and constraints.""" + cvx_vars = {} + constraints = [] + for key, val in consumption_data_dict.items(): + cvx_vars[key] = cp.Variable(len(val)) + constraints.append(cvx_vars[key] == val) + return cvx_vars, constraints + + +def solve_cvx_problem(objective, constraints): + """Helper to solve CVXPY optimization problem.""" + prob = cp.Problem(cp.Minimize(objective), constraints) + prob.solve() + return prob + + +def setup_pyo_vars_constraints(consumption_data_dict): + """Helper function to set up Pyomo model, variables and constraints.""" + model = pyo.ConcreteModel() + model.T = len(consumption_data_dict[ELECTRIC]) + model.t = pyo.RangeSet(0, model.T - 1) + model.electric_consumption = pyo.Var(model.t, bounds=(None, None)) + model.gas_consumption = pyo.Var(model.t, bounds=(None, None)) + + # Constrain variables to initialized values + def electric_constraint_rule(model, t): + return model.electric_consumption[t] == consumption_data_dict[ELECTRIC][t - 1] + + def gas_constraint_rule(model, t): + return model.gas_consumption[t] == consumption_data_dict[GAS][t - 1] + + model.electric_constraint = pyo.Constraint(model.t, rule=electric_constraint_rule) + model.gas_constraint = pyo.Constraint(model.t, rule=gas_constraint_rule) + + pyo_vars = { + "electric": model.electric_consumption, + "gas": model.gas_consumption, + } + + return model, pyo_vars + + +def solve_pyo_problem( + model, + objective, + decompose_exports=False, + charge_dict=None, + consumption_data_dict=None, +): + """Helper function to solve Pyomo optimization problem.""" + + # Initialize decomposed variables if needed + # TODO: check if always needed + if decompose_exports and consumption_data_dict is not None: + utils.initialize_decomposed_pyo_vars(consumption_data_dict, model, charge_dict) + + model.obj = pyo.Objective(expr=objective) + + if decompose_exports: # Nonlinear constraints when decompose_exports=True + solver = pyo.SolverFactory("ipopt") + else: # Gurobi otherwise + solver = pyo.SolverFactory("gurobi") + + solver.solve(model) + return solver + + @pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") @pytest.mark.parametrize( "charge, start_dt, end_dt, n_per_hour, effective_start_date, " @@ -626,22 +694,6 @@ def test_get_charge_dict(start_dt, end_dt, billing_path, resolution, expected): False, True, ), - # predefined consumption_data_dict format with invalid import/export types - ( - {"electric_energy_0_2024-07-10_2024-07-10_0": np.ones(4) * 0.05}, - { - ELECTRIC: {"imports": [1, 2, 3, 4], "exports": [1, 2, 3, 4]}, - GAS: np.ones(4), - }, # Extended format with invalid list types - "15m", - None, - 0, - None, - None, - None, # No expected cost since error is raised - False, - True, # AttributeError - ), ], ) def test_calculate_cost_np( @@ -1052,11 +1104,7 @@ def test_calculate_cost_cvx( desired_charge_type, expected_cost, ): - cvx_vars = {} - constraints = [] - for key, val in consumption_data_dict.items(): - cvx_vars[key] = cp.Variable(len(val)) - constraints.append(cvx_vars[key] == val) + cvx_vars, constraints = setup_cvx_vars_constraints(consumption_data_dict) result, model = costs.calculate_cost( charge_dict, @@ -1067,8 +1115,7 @@ def test_calculate_cost_cvx( desired_utility=desired_utility, desired_charge_type=desired_charge_type, ) - prob = cp.Problem(cp.Minimize(result), constraints) - prob.solve() + solve_cvx_problem(result, constraints) assert result.value == expected_cost assert model is None @@ -1200,7 +1247,7 @@ def test_calculate_cost_cvx( "electric_export_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.025, }, { - ELECTRIC: np.concatenate([np.ones(48) * 10, -np.ones(48) * 5]), + ELECTRIC: np.concatenate([np.ones(48), -np.ones(48)]), GAS: np.ones(96), }, "15m", @@ -1208,7 +1255,24 @@ def test_calculate_cost_cvx( 0, None, None, - pytest.approx(-1.5), + pytest.approx(-0.3), + ), + # energy and export charges + ( + { + "electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05, + "electric_export_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.025, + }, + { + ELECTRIC: np.concatenate([np.ones(48), -np.ones(48)]), + GAS: np.ones(96), + }, + "15m", + None, + 0, + None, + None, + pytest.approx(0.6 - 0.3), # 48*1*0.05/4 - 48*1*0.025/4 = 0.6 - 0.3 = 0.3 ), # energy charge with charge limit and time-varying consumption estimate ( @@ -1329,26 +1393,7 @@ def test_calculate_cost_pyo( desired_charge_type, expected_cost, ): - model = pyo.ConcreteModel() - model.T = len(consumption_data_dict[ELECTRIC]) - model.t = pyo.RangeSet(0, model.T - 1) - model.electric_consumption = pyo.Var(model.t, bounds=(None, None)) - model.gas_consumption = pyo.Var(model.t, bounds=(None, None)) - - # Constrain variables to initialized values - def electric_constraint_rule(model, t): - return model.electric_consumption[t] == consumption_data_dict[ELECTRIC][t - 1] - - def gas_constraint_rule(model, t): - return model.gas_consumption[t] == consumption_data_dict[GAS][t - 1] - - model.electric_constraint = pyo.Constraint(model.t, rule=electric_constraint_rule) - model.gas_constraint = pyo.Constraint(model.t, rule=gas_constraint_rule) - - pyo_vars = { - "electric": model.electric_consumption, - "gas": model.gas_consumption, - } + model, pyo_vars = setup_pyo_vars_constraints(consumption_data_dict) result, model = costs.calculate_cost( charge_dict, @@ -1362,26 +1407,10 @@ def gas_constraint_rule(model, t): decompose_exports=any("export" in key for key in charge_dict.keys()), ) - # Initialize Pyomo variables if decompose_exports is True decompose_exports = any("export" in key for key in charge_dict.keys()) - if decompose_exports: - init_consumption_data = { - "electric": np.array( - [consumption_data_dict[ELECTRIC][t - 1] for t in model.t] - ), - "gas": np.array([consumption_data_dict[GAS][t - 1] for t in model.t]), - } - utils.initialize_decomposed_pyo_vars(init_consumption_data, model, charge_dict) - - model.obj = pyo.Objective(expr=result) - - # Use IPOPT for nonlinear constraints when decompose_exports=True - if decompose_exports: - solver = pyo.SolverFactory("ipopt") - else: # Gurobi otherwise - solver = pyo.SolverFactory("gurobi") - - solver.solve(model) + solve_pyo_problem( + model, result, decompose_exports, charge_dict, consumption_data_dict + ) assert pyo.value(result) == expected_cost assert model is not None @@ -1856,17 +1885,25 @@ def test_calculate_energy_costs( ), # positive values (export magnitude) ( np.ones(96), - np.arange(96), + np.concatenate([np.ones(48), -np.ones(48)]), 4, - 1140, - False, - ), # positive values (export magnitude) + 0, # values treated as magnitude so expectation is 0 + True, + ), # negative values (export magnitude) - should warn ], ) def test_calculate_export_revenue( charge_array, export_data, divisor, expected, expect_warning ): - result, model = costs.calculate_export_revenue(charge_array, export_data, divisor) + if expect_warning: + with pytest.warns(UserWarning): + result, model = costs.calculate_export_revenue( + charge_array, export_data, divisor + ) + else: + result, model = costs.calculate_export_revenue( + charge_array, export_data, divisor + ) assert result == expected assert model is None @@ -1997,7 +2034,7 @@ def test_detect_charge_periods( ( "billing_pge.csv", { - "scale_ratios": { + "percent_change_dict": { DEMAND: { PEAK: 2.0, HALF_PEAK: 2.0, @@ -2027,7 +2064,7 @@ def test_detect_charge_periods( ( "billing_pge.csv", { - "scale_ratios": { + "percent_change_dict": { "demand": 1.5, "energy": 1.0, }, @@ -2046,7 +2083,7 @@ def test_detect_charge_periods( ( "billing_demand_2.csv", { - "scale_ratios": { + "percent_change_dict": { DEMAND: { PEAK: 3.0, HALF_PEAK: 1.0, @@ -2072,7 +2109,7 @@ def test_detect_charge_periods( ( "billing_demand_2.csv", { - "scale_ratios": { + "percent_change_dict": { "demand": 1.0, "energy": 2.0, }, @@ -2088,7 +2125,7 @@ def test_detect_charge_periods( ( "billing_pge.csv", { - "scale_ratios": { + "percent_change_dict": { "electric_demand_peak-summer": 2.0, "electric_energy_0": 3.0, "electric_demand_all-day": 1.5, @@ -2105,7 +2142,7 @@ def test_detect_charge_periods( ( "billing_pge.csv", { - "scale_ratios": { + "percent_change_dict": { "demand": 0.0, "energy": -2.0, }, @@ -2120,11 +2157,11 @@ def test_detect_charge_periods( None, False, ), - # Individual zero scale_ratios + # Individual zero percent_change_dict ( "billing_pge.csv", { - "scale_ratios": { + "percent_change_dict": { DEMAND: { PEAK: 0.0, HALF_PEAK: 0.0, @@ -2153,7 +2190,7 @@ def test_detect_charge_periods( ( "billing_pge.csv", { - "scale_ratios": { + "percent_change_dict": { DEMAND: { PEAK: 1.0, HALF_PEAK: 1.0, @@ -2235,7 +2272,7 @@ def test_detect_charge_periods( ( "billing_pge.csv", { - "scale_ratios": { + "percent_change_dict": { "electric_demand_nonexistent1": 2.0, "electric_demand_nonexistent2": 3.0, "electric_energy_nonexistent1": 1.5, @@ -2256,7 +2293,7 @@ def test_detect_charge_periods( ( "billing_pge.csv", { - "scale_ratios": { + "percent_change_dict": { "electric_demand_peak-summer": 2.0, DEMAND: 3.0, # Conflicts with the exact key above }, @@ -2290,7 +2327,7 @@ def test_detect_charge_periods( ( "billing_energy_super_off_peak.csv", { - "scale_ratios": { + "percent_change_dict": { "energy": { "peak": 1.0, "half_peak": 1.0, @@ -2516,7 +2553,7 @@ def test_parametrize_rate_data( ( [ { - "scale_ratios": { + "percent_change_dict": { DEMAND: { PEAK: 2.0, HALF_PEAK: 2.0, @@ -2545,7 +2582,7 @@ def test_parametrize_rate_data( ( [ { - "scale_ratios": { + "percent_change_dict": { "demand": 1.5, "energy": 1.0, }, @@ -2564,7 +2601,7 @@ def test_parametrize_rate_data( ( [ { - "scale_ratios": { + "percent_change_dict": { "electric_demand_peak-summer": 2.0, "electric_energy_0": 3.0, "electric_demand_all-day": 1.5, @@ -2592,9 +2629,9 @@ def test_parametrize_rate_data( # Duplicate variant names ( [ - {"scale_ratios": {"demand": 2.0}, "variant_name": "test"}, + {"percent_change_dict": {"demand": 2.0}, "variant_name": "test"}, { - "scale_ratios": {"energy": 3.0}, + "percent_change_dict": {"energy": 3.0}, "variant_name": "test", }, # Duplicate name ], @@ -2609,8 +2646,8 @@ def test_parametrize_rate_data( # Variants without names ( [ - {"scale_ratios": {"demand": 2.0}}, # No variant_name - {"scale_ratios": {"energy": 3.0}}, # No variant_name + {"percent_change_dict": {"demand": 2.0}}, # No variant_name + {"percent_change_dict": {"energy": 3.0}}, # No variant_name ], "variant_0", { @@ -2624,7 +2661,7 @@ def test_parametrize_rate_data( ( [ { - "scale_ratios": {"demand": 2.0}, + "percent_change_dict": {"demand": 2.0}, "variant_name": "double_demand", } ], @@ -2640,7 +2677,7 @@ def test_parametrize_rate_data( ( [ { - "scale_ratios": {"energy": 3.0}, + "percent_change_dict": {"energy": 3.0}, "variant_name": "triple_energy", } ], @@ -2656,15 +2693,15 @@ def test_parametrize_rate_data( ( [ { - "scale_ratios": {"demand": 2.0}, + "percent_change_dict": {"demand": 2.0}, "variant_name": "double_demand", }, { - "scale_ratios": {"energy": 3.0}, + "percent_change_dict": {"energy": 3.0}, "variant_name": "triple_energy", }, { - "scale_ratios": { + "percent_change_dict": { DEMAND: { PEAK: 1.5, HALF_PEAK: 1.0, @@ -2811,12 +2848,12 @@ def test_parametrize_charge_dict(variant_params, key_subset, expected): "billing_file, variant_params", [ # Test with different billing files - ("billing_energy_1.csv", {"scale_ratios": {"energy": 2.0}}), - ("billing_demand_2.csv", {"scale_ratios": {"demand": 2.0}}), - ("billing_export.csv", {"scale_ratios": {"energy": 1.5}}), - ("billing_customer.csv", {"scale_ratios": {"energy": 1.0}}), + ("billing_energy_1.csv", {"percent_change_dict": {"energy": 2.0}}), + ("billing_demand_2.csv", {"percent_change_dict": {"demand": 2.0}}), + ("billing_export.csv", {"percent_change_dict": {"energy": 1.5}}), + ("billing_customer.csv", {"percent_change_dict": {"energy": 1.0}}), # Test with complex rate structures - ("billing.csv", {"scale_ratios": {"demand": 2.0, "energy": 1.5}}), + ("billing.csv", {"percent_change_dict": {"demand": 2.0, "energy": 1.5}}), ], ) def test_parametrize_rate_data_different_files(billing_file, variant_params): @@ -2835,10 +2872,10 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): # Check that at least some charges were modified if scaling was applied if ( - "scale_ratios" in variant_params - and "demand" in variant_params["scale_ratios"] - and isinstance(variant_params["scale_ratios"]["demand"], (int, float)) - and variant_params["scale_ratios"]["demand"] != 1.0 + "percent_change_dict" in variant_params + and "demand" in variant_params["percent_change_dict"] + and isinstance(variant_params["percent_change_dict"]["demand"], (int, float)) + and variant_params["percent_change_dict"]["demand"] != 1.0 ): demand_charges = variant_data[variant_data[TYPE] == costs.DEMAND] if not demand_charges.empty: @@ -2848,10 +2885,10 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): ), "Demand charges should be modified" if ( - "scale_ratios" in variant_params - and "energy" in variant_params["scale_ratios"] - and isinstance(variant_params["scale_ratios"]["energy"], (int, float)) - and variant_params["scale_ratios"]["energy"] != 1.0 + "percent_change_dict" in variant_params + and "energy" in variant_params["percent_change_dict"] + and isinstance(variant_params["percent_change_dict"]["energy"], (int, float)) + and variant_params["percent_change_dict"]["energy"] != 1.0 ): energy_charges = variant_data[variant_data[TYPE] == costs.ENERGY] if not energy_charges.empty: @@ -2861,7 +2898,264 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): ), "Energy charges should be modified" -# TODO: write test_calculate_itemized_cost +@pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") +@pytest.mark.parametrize( + "charge_dict, " + "consumption_data_dict, " + "resolution, " + "decompose_exports, " + "expected_cost, " + "expected_itemized", + [ + # single energy charge + ( + {"electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05}, + {ELECTRIC: np.ones(96), GAS: np.ones(96)}, + "15m", + False, + pytest.approx(1.2), + { + "electric": { + "energy": pytest.approx(1.2), + "export": 0.0, + "customer": 0.0, + "demand": 0.0, + }, + "gas": { + "energy": 0.0, + "export": 0.0, + "customer": 0.0, + "demand": 0.0, + }, + }, + ), + # energy and export charges with decompose_exports=True + ( + { + "electric_export_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.025, + }, + { + ELECTRIC: np.concatenate([np.ones(48) * 10, -np.ones(48) * 5]), + GAS: np.ones(96), + }, + "15m", + True, + pytest.approx(-1.5), + { + "electric": { + "energy": 0.0, + "export": pytest.approx(-1.5), + "customer": 0.0, + "demand": 0.0, + }, + "gas": { + "energy": 0.0, + "export": 0.0, + "customer": 0.0, + "demand": 0.0, + }, + }, + ), + ], +) +def test_calculate_itemized_cost_np( + charge_dict, + consumption_data_dict, + resolution, + decompose_exports, + expected_cost, + expected_itemized, +): + """Test calculate_itemized_cost with and without decompose_exports.""" + result, model = costs.calculate_itemized_cost( + charge_dict, + consumption_data_dict, + resolution=resolution, + decompose_exports=decompose_exports, + ) + + assert result["total"] == 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] + assert actual_value == expected_value + + +@pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") +@pytest.mark.parametrize( + "charge_dict, " + "consumption_data_dict, " + "resolution, " + "decompose_exports, " + "expected_cost, " + "expected_itemized", + [ + # single energy charge + ( + {"electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05}, + {ELECTRIC: np.ones(96), GAS: np.ones(96)}, + "15m", + False, + pytest.approx(1.2), + { + "electric": { + "energy": pytest.approx(1.2), + "export": 0.0, + "customer": 0.0, + "demand": 0.0, + }, + "gas": { + "energy": 0.0, + "export": 0.0, + "customer": 0.0, + "demand": 0.0, + }, + }, + ), + # energy and export charges with decompose_exports=True (non-DCP as constructed) + ( + { + "electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05, + "electric_export_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.025, + }, + { + ELECTRIC: np.concatenate([np.ones(48) * 10, -np.ones(48) * 5]), + GAS: np.ones(96), + }, + "15m", + True, + None, # No expected cost - should raise error + None, # No expected itemized - should raise error + ), + ], +) +def test_calculate_itemized_cost_cvx( + charge_dict, + consumption_data_dict, + resolution, + decompose_exports, + expected_cost, + expected_itemized, +): + """Test calculate_itemized_cost with CVXPY variables.""" + cvx_vars, constraints = setup_cvx_vars_constraints(consumption_data_dict) + + if decompose_exports: + with pytest.raises(ValueError): + costs.calculate_itemized_cost( + charge_dict, + cvx_vars, + resolution=resolution, + decompose_exports=decompose_exports, + ) + else: + result, model = costs.calculate_itemized_cost( + charge_dict, + cvx_vars, + resolution=resolution, + decompose_exports=decompose_exports, + ) + solve_cvx_problem(result["total"], constraints) + + assert result["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 + assert actual_value == expected_value + + +@pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") +@pytest.mark.parametrize( + "charge_dict, " + "consumption_data_dict, " + "resolution, " + "decompose_exports, " + "expected_cost, " + "expected_itemized", + [ + # single energy charge + ( + {"electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05}, + {ELECTRIC: np.ones(96), GAS: np.ones(96)}, + "15m", + False, + pytest.approx(1.2), + { + "electric": { + "energy": pytest.approx(1.2), + "export": 0.0, + "customer": 0.0, + "demand": 0.0, + }, + "gas": { + "energy": 0.0, + "export": 0.0, + "customer": 0.0, + "demand": 0.0, + }, + }, + ), + # energy and export charges + ( + { + "electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05, + "electric_export_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.025, + }, + { + ELECTRIC: np.concatenate([np.ones(48) * 10, -np.ones(48) * 5]), + GAS: np.ones(96), + }, + "15m", + True, + pytest.approx(6.0 - 1.5), # 48*10*0.05/4 - 48*5*0.025/4 = 6.0 - 1.5 = 4.5 + { + "electric": { + "energy": pytest.approx(6.0), # 48*10*0.05/4 = 6.0 + "export": pytest.approx(-1.5), # -48*5*0.025/4 = 1.5 + "customer": 0.0, + "demand": 0.0, + }, + "gas": { + "energy": 0.0, + "export": 0.0, + "customer": 0.0, + "demand": 0.0, + }, + }, + ), + ], +) +def test_calculate_itemized_cost_pyo( + charge_dict, + consumption_data_dict, + resolution, + decompose_exports, + expected_cost, + expected_itemized, +): + """Test calculate_itemized_cost with Pyomo variables.""" + model, pyo_vars = setup_pyo_vars_constraints(consumption_data_dict) + result, model = costs.calculate_itemized_cost( + charge_dict, + pyo_vars, + resolution=resolution, + decompose_exports=decompose_exports, + model=model, + ) + solve_pyo_problem( + model, result["total"], decompose_exports, charge_dict, consumption_data_dict + ) + + assert pyo.value(result["total"]) == 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 @pytest.mark.parametrize( diff --git a/eeco/utils.py b/eeco/utils.py index 738dcd4..b450555 100644 --- a/eeco/utils.py +++ b/eeco/utils.py @@ -330,6 +330,11 @@ def const_rule(model, t): ) +def _get_decomposed_var_names(utility): + """Get consistent variable names for decomposed consumption variables.""" + return f"{utility}_positive", f"{utility}_negative" + + def initialize_decomposed_pyo_vars(consumption_data_dict, model, charge_dict): """Helper function to initialize Pyomo variables with baseline consumption values. @@ -376,8 +381,9 @@ def initialize_decomposed_pyo_vars(consumption_data_dict, model, charge_dict): positive_values, negative_values, _ = decompose_consumption(consumption_data) # Find and initialize the corresponding Pyomo variables - positive_var = model.find_component(f"{utility}_positive") - negative_var = model.find_component(f"{utility}_negative") + pos_name, neg_name = _get_decomposed_var_names(utility) + positive_var = model.find_component(pos_name) + negative_var = model.find_component(neg_name) for t in model.t: positive_var[t].value = positive_values[t - 1] diff --git a/examples/example_parametrize_charges.ipynb b/examples/example_parametrize_charges.ipynb index f58aae2..638952f 100644 --- a/examples/example_parametrize_charges.ipynb +++ b/examples/example_parametrize_charges.ipynb @@ -69,7 +69,7 @@ "source": [ "## Specify charge variants as a list of dictionaries\n", "### Each with entries for:\n", - "- 'scale_ratios': nested dictionary for charge scaling by period type\n", + "- 'percent_change_dict': nested dictionary for charge scaling by period type\n", "- 'scale_all_demand': global scaling for all demand charges\n", "- 'scale_all_energy': global scaling for all energy charges \n", "- 'shift_peak_hours_before': hours to shift peak window start (must be multiple of 0.25)\n", @@ -88,7 +88,7 @@ "variants = [\n", " # Double peak charges\n", " {\n", - " 'scale_ratios': {\n", + " 'percent_change_dict': {\n", " DEMAND: {PEAK: 2.0, HALF_PEAK: 2.0, OFF_PEAK: 1.0, SUPER_OFF_PEAK: 1.0},\n", " ENERGY: {PEAK: 2.0, HALF_PEAK: 2.0, OFF_PEAK: 1.0, SUPER_OFF_PEAK: 1.0}\n", " },\n", @@ -96,7 +96,7 @@ " },\n", " # Increse peak charges from specific keys\n", " {\n", - " \"scale_ratios\": {\n", + " \"percent_change_dict\": {\n", " \"electric_demand_peak-summer\": 2.2,\n", " \"electric_demand_half_peak-summer\": 2.2,\n", " },\n", @@ -104,7 +104,7 @@ " },\n", " # Halve peak charges\n", " {\n", - " 'scale_ratios': {\n", + " 'percent_change_dict': {\n", " DEMAND: {PEAK: 0.5, HALF_PEAK: 0.5, OFF_PEAK: 1.0, SUPER_OFF_PEAK: 1.0},\n", " ENERGY: {PEAK: 0.5, HALF_PEAK: 0.5, OFF_PEAK: 1.0, SUPER_OFF_PEAK: 1.0}\n", " },\n", @@ -112,7 +112,7 @@ " },\n", " # Increase all demand/energy charges\n", " {\n", - " 'scale_ratios': {\n", + " 'percent_change_dict': {\n", " DEMAND: 1.15,\n", " ENERGY: 1.15\n", " },\n", From 276c0e6819bbdea89d5ee660d05cf13355dc3b07 Mon Sep 17 00:00:00 2001 From: dalyw Date: Mon, 8 Sep 2025 16:36:56 -0700 Subject: [PATCH 03/26] Simplifying test_decompose_consumption_pyo with approx --- eeco/tests/test_utils.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/eeco/tests/test_utils.py b/eeco/tests/test_utils.py index 48cb10b..b5c8bc3 100644 --- a/eeco/tests/test_utils.py +++ b/eeco/tests/test_utils.py @@ -207,7 +207,6 @@ def test_decompose_consumption_cvx(): "consumption_data, expected_positive_sum, expected_negative_sum", [ (np.array([1, -2, 3, -4, 0]), 4, 6), # positive: 1+3=4, negative: 2+4=6 - (np.array([5, 0, -3, 7, -1]), 12, 4), # positive: 5+7=12, negative: 3+1=4 (np.array([0, 0, 0]), 0, 0), (np.array([-10, -5, -1]), 0, 16), # positive: 0, negative: 10+5+1=16 (np.array([10, 5, 1]), 16, 0), # positive: 10+5+1=16, negative: 0 @@ -234,22 +233,16 @@ def test_decompose_consumption_pyo( } ut.initialize_decomposed_pyo_vars(init_consumption_data, model, None) - # Verify the expected sums from the initialized values - assert ( - abs(sum(pyo.value(positive_var[t]) for t in model.t) - expected_positive_sum) - < 1e-6 + # Verify expected sums + assert sum(pyo.value(positive_var[t]) for t in model.t) == pytest.approx( + expected_positive_sum ) - assert ( - abs(sum(pyo.value(negative_var[t]) for t in model.t) - expected_negative_sum) - < 1e-6 + assert sum(pyo.value(negative_var[t]) for t in model.t) == pytest.approx( + expected_negative_sum ) - # Verify the decomposition constraint is satisfied + # Verify decomposition constraint for t in model.t: - assert ( - abs( - pyo.value(model.electric_consumption[t]) - - (pyo.value(positive_var[t]) - pyo.value(negative_var[t])) - ) - < 1e-6 + assert pyo.value(model.electric_consumption[t]) == pytest.approx( + pyo.value(positive_var[t]) - pyo.value(negative_var[t]) ) From d9873aa16aedff8df2e20bfad696952fde61c552 Mon Sep 17 00:00:00 2001 From: dalyw Date: Wed, 10 Sep 2025 07:14:47 -0700 Subject: [PATCH 04/26] Changing decompose_exports argument to decomposition_type, which can take None, "absolute_value", "binary", or other types in the future Using max_pos for creating positive and negative pyomo variables (WIP: Index issue failing test in test_utils) --- docs/how_to_advanced.rst | 15 +++-- eeco/costs.py | 44 ++++++------ eeco/tests/test_costs.py | 62 +++++++++-------- eeco/tests/test_utils.py | 9 +++ eeco/utils.py | 140 +++++++++++++++++++++------------------ 5 files changed, 151 insertions(+), 119 deletions(-) diff --git a/docs/how_to_advanced.rst b/docs/how_to_advanced.rst index 48b1f41..cae20ea 100644 --- a/docs/how_to_advanced.rst +++ b/docs/how_to_advanced.rst @@ -184,12 +184,15 @@ In `bibtex` format: .. _decompose-exports: -How to Use `decompose_exports` +How to Use `decomposition_type` ============================== -The `decompose_exports` parameter allows you to decompose consumption data into positive (imports) and negative (exports) components. This is useful when you have export charges or credits in your rate structure. +The `decomposition_type` parameter allows you to decompose consumption data into positive (imports) and negative (exports) components. This is useful when you have export charges or credits in your rate structure. -By default, `decompose_exports=False`. Set to `True` when your charge dictionary contains export-related charges. +Options: +- Default `None` +- `"binary_variable"`: To be implemented +- `"absolute_value"` .. code-block:: python @@ -209,12 +212,12 @@ By default, `decompose_exports=False`. Set to `True` when your charge dictionary result, model = costs.calculate_cost( charge_dict, consumption_data, - decompose_exports=True + decomposition_type="absolute_value" ) -When `decompose_exports=True`, the function creates separate variables for positive consumption (imports) and negative consumption (exports) +When decomposition_type is not None the function creates separate variables for positive consumption (imports) and negative consumption (exports) and applies export charges only to the export component. -For Pyomo models, decompose_exports adds a constraint total_consumption = imports - exports +For Pyomo models, decomposition_type adds a constraint total_consumption = imports - exports .. _varstr-alias: diff --git a/eeco/costs.py b/eeco/costs.py index b74bbfc..308c620 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -632,7 +632,7 @@ def calculate_demand_cost( warnings.warn( "UserWarning: Demand calculation includes negative values. " "Pass in only positive values or " - "run calculate_cost with decompose_exports=True" + "run calculate_cost with a decomposition_type" ) if (ut.max(consumption_data)[0] >= limit) or ( (prev_demand >= limit) and (prev_demand <= next_limit) @@ -787,7 +787,7 @@ def calculate_energy_cost( warnings.warn( "UserWarning: Energy calculation includes negative values. " "Pass in only positive values or " - "run calculate_cost with decompose_exports=True" + "run calculate_cost with a decomposition_type" ) energy = prev_consumption @@ -903,7 +903,7 @@ def calculate_export_revenue( warnings.warn( "UserWarning: Export revenue calculation includes negative values. " "Pass in only positive values or " - "run calculate_cost with decompose_exports=True" + "run calculate_cost with a decomposition_type" ) return np.sum(consumption_data * charge_array) / divisor, model @@ -980,7 +980,7 @@ def calculate_cost( desired_charge_type=None, demand_scale_factor=1, model=None, - decompose_exports=False, + decomposition_type=None, varstr_alias_func=default_varstr_alias_func, ): """Calculates the cost of given charges (demand or energy) for the given @@ -1312,9 +1312,11 @@ def build_pyomo_costing( Additional terms to be added to the objective function. Must be a list of pyomo Expressions. - decompose_exports : indicates whether to add additional optimization variables - indicating positive or negative consumption. Set to "True" if electricity - or gas exports are possible. Default "False" + decomposition_type : str or None + Type of decomposition to use for consumption data. + - "absolute_value": Linear problem using absolute value + - "binary_variable": To be implemented + - Default None: No decomposition, treats all consumption as imports varstr_alias_func: function Function to generate variable name for pyomo, @@ -1388,7 +1390,7 @@ def build_pyomo_costing( varstr=utility + "_converted", ) - if decompose_exports: + if decomposition_type == "absolute_value": # Decompose consumption data into positive and negative components # with constraint that total = positive - negative # (where negative is stored as positive magnitude) @@ -1397,13 +1399,14 @@ def build_pyomo_costing( converted_consumption, model=model, varstr=utility, + decomposition_type="absolute_value", ) consumption_data_dict[utility] = { "imports": imports, "exports": exports, } - else: + elif decomposition_type is None: consumption_data_dict[utility] = { "imports": converted_consumption, "exports": converted_consumption, @@ -1501,7 +1504,7 @@ def calculate_itemized_cost( desired_utility=None, demand_scale_factor=1, model=None, - decompose_exports=False, + decomposition_type=None, varstr_alias_func=default_varstr_alias_func, ): """Calculates itemized costs as a nested dictionary @@ -1600,14 +1603,14 @@ def calculate_itemized_cost( } """ - # Check if decompose_exports=True is used with CVXPY objects + # Check if decomposition_type is used with CVXPY objects # (not yet supported because imports - exports creates non-DCP issues) - if decompose_exports: + if decomposition_type is not None: for utility in consumption_data_dict.keys(): if isinstance(consumption_data_dict[utility], cp.Variable): raise ValueError( - "decompose_exports=True is not supported with CVXPY objects. " - "Use Pyomo instead for problems requiring decompose_exports." + "decomposition types are not supported with CVXPY objects. " + "Use Pyomo instead for problems requiring decomposition_type." ) total_cost = 0 @@ -1639,20 +1642,23 @@ def calculate_itemized_cost( varstr=utility + "_converted", ) - if decompose_exports: + if decomposition_type == "absolute_value": # Decompose consumption data into positive and negative components # with constraint that total = positive - negative # (where negative is stored as positive magnitude) pos_name, neg_name = ut._get_decomposed_var_names(utility) imports, exports, model = ut.decompose_consumption( - converted_consumption, model=model, varstr=utility + converted_consumption, + model=model, + varstr=utility, + decomposition_type="absolute_value", ) consumption_data_dict[utility] = { "imports": imports, "exports": exports, } - else: + elif decomposition_type is None: consumption_data_dict[utility] = { "imports": converted_consumption, "exports": converted_consumption, @@ -1675,7 +1681,7 @@ def calculate_itemized_cost( desired_charge_type=charge_type, demand_scale_factor=demand_scale_factor, model=model, - decompose_exports=decompose_exports, + decomposition_type=decomposition_type, varstr_alias_func=varstr_alias_func, ) @@ -1701,7 +1707,7 @@ def calculate_itemized_cost( desired_charge_type=charge_type, demand_scale_factor=demand_scale_factor, model=model, - decompose_exports=decompose_exports, + decomposition_type=decomposition_type, varstr_alias_func=varstr_alias_func, ) diff --git a/eeco/tests/test_costs.py b/eeco/tests/test_costs.py index e35535f..c6eca12 100644 --- a/eeco/tests/test_costs.py +++ b/eeco/tests/test_costs.py @@ -79,7 +79,7 @@ def gas_constraint_rule(model, t): def solve_pyo_problem( model, objective, - decompose_exports=False, + decomposition_type=None, charge_dict=None, consumption_data_dict=None, ): @@ -87,12 +87,12 @@ def solve_pyo_problem( # Initialize decomposed variables if needed # TODO: check if always needed - if decompose_exports and consumption_data_dict is not None: + if decomposition_type is not None and consumption_data_dict is not None: utils.initialize_decomposed_pyo_vars(consumption_data_dict, model, charge_dict) model.obj = pyo.Objective(expr=objective) - if decompose_exports: # Nonlinear constraints when decompose_exports=True + if decomposition_type is not None: # Nonlinear when decomposition_type used solver = pyo.SolverFactory("ipopt") else: # Gurobi otherwise solver = pyo.SolverFactory("gurobi") @@ -1123,7 +1123,8 @@ def test_calculate_cost_cvx( @pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") @pytest.mark.parametrize( "charge_dict, consumption_data_dict, resolution, prev_demand_dict, " - "consumption_estimate, desired_utility, desired_charge_type, expected_cost", + "consumption_estimate, desired_utility, desired_charge_type, " + "decomposition_type, expected_cost", [ # energy charge with charge limit ( @@ -1149,6 +1150,7 @@ def test_calculate_cost_cvx( 2400, None, None, + None, pytest.approx(260), ), # demand charge with previous consumption @@ -1195,6 +1197,7 @@ def test_calculate_cost_cvx( 0, None, None, + None, pytest.approx(138), ), # demand charge with no previous consumption @@ -1239,6 +1242,7 @@ def test_calculate_cost_cvx( 0, None, None, + None, pytest.approx(1188), ), # export charges @@ -1255,6 +1259,7 @@ def test_calculate_cost_cvx( 0, None, None, + "absolute_value", pytest.approx(-0.3), ), # energy and export charges @@ -1272,6 +1277,7 @@ def test_calculate_cost_cvx( 0, None, None, + "absolute_value", pytest.approx(0.6 - 0.3), # 48*1*0.05/4 - 48*1*0.025/4 = 0.6 - 0.3 = 0.3 ), # energy charge with charge limit and time-varying consumption estimate @@ -1391,6 +1397,7 @@ def test_calculate_cost_pyo( consumption_estimate, desired_utility, desired_charge_type, + decomposition_type, expected_cost, ): model, pyo_vars = setup_pyo_vars_constraints(consumption_data_dict) @@ -1404,12 +1411,11 @@ def test_calculate_cost_pyo( desired_utility=desired_utility, desired_charge_type=desired_charge_type, model=model, - decompose_exports=any("export" in key for key in charge_dict.keys()), + decomposition_type=decomposition_type, ) - decompose_exports = any("export" in key for key in charge_dict.keys()) solve_pyo_problem( - model, result, decompose_exports, charge_dict, consumption_data_dict + model, result, decomposition_type, charge_dict, consumption_data_dict ) assert pyo.value(result) == expected_cost assert model is not None @@ -2903,7 +2909,7 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): "charge_dict, " "consumption_data_dict, " "resolution, " - "decompose_exports, " + "decomposition_type, " "expected_cost, " "expected_itemized", [ @@ -2912,7 +2918,7 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): {"electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05}, {ELECTRIC: np.ones(96), GAS: np.ones(96)}, "15m", - False, + None, pytest.approx(1.2), { "electric": { @@ -2929,7 +2935,7 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): }, }, ), - # energy and export charges with decompose_exports=True + # energy and export charges with decomposition_type "absolute_value" ( { "electric_export_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.025, @@ -2939,7 +2945,7 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): GAS: np.ones(96), }, "15m", - True, + "absolute_value", pytest.approx(-1.5), { "electric": { @@ -2962,16 +2968,16 @@ def test_calculate_itemized_cost_np( charge_dict, consumption_data_dict, resolution, - decompose_exports, + decomposition_type, expected_cost, expected_itemized, ): - """Test calculate_itemized_cost with and without decompose_exports.""" + """Test calculate_itemized_cost with and without decomposition_type.""" result, model = costs.calculate_itemized_cost( charge_dict, consumption_data_dict, resolution=resolution, - decompose_exports=decompose_exports, + decomposition_type=decomposition_type, ) assert result["total"] == expected_cost @@ -2987,7 +2993,7 @@ def test_calculate_itemized_cost_np( "charge_dict, " "consumption_data_dict, " "resolution, " - "decompose_exports, " + "decomposition_type, " "expected_cost, " "expected_itemized", [ @@ -2996,7 +3002,7 @@ def test_calculate_itemized_cost_np( {"electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05}, {ELECTRIC: np.ones(96), GAS: np.ones(96)}, "15m", - False, + None, pytest.approx(1.2), { "electric": { @@ -3013,7 +3019,7 @@ def test_calculate_itemized_cost_np( }, }, ), - # energy and export charges with decompose_exports=True (non-DCP as constructed) + # energy and export charges with decomposition_type="absolute_value" (non-DCP) ( { "electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05, @@ -3024,7 +3030,7 @@ def test_calculate_itemized_cost_np( GAS: np.ones(96), }, "15m", - True, + "absolute_value", None, # No expected cost - should raise error None, # No expected itemized - should raise error ), @@ -3034,27 +3040,27 @@ def test_calculate_itemized_cost_cvx( charge_dict, consumption_data_dict, resolution, - decompose_exports, + decomposition_type, expected_cost, expected_itemized, ): """Test calculate_itemized_cost with CVXPY variables.""" cvx_vars, constraints = setup_cvx_vars_constraints(consumption_data_dict) - if decompose_exports: + if decomposition_type: with pytest.raises(ValueError): costs.calculate_itemized_cost( charge_dict, cvx_vars, resolution=resolution, - decompose_exports=decompose_exports, + decomposition_type=decomposition_type, ) else: result, model = costs.calculate_itemized_cost( charge_dict, cvx_vars, resolution=resolution, - decompose_exports=decompose_exports, + decomposition_type=decomposition_type, ) solve_cvx_problem(result["total"], constraints) @@ -3073,7 +3079,7 @@ def test_calculate_itemized_cost_cvx( "charge_dict, " "consumption_data_dict, " "resolution, " - "decompose_exports, " + "decomposition_type, " "expected_cost, " "expected_itemized", [ @@ -3082,7 +3088,7 @@ def test_calculate_itemized_cost_cvx( {"electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05}, {ELECTRIC: np.ones(96), GAS: np.ones(96)}, "15m", - False, + None, pytest.approx(1.2), { "electric": { @@ -3110,7 +3116,7 @@ def test_calculate_itemized_cost_cvx( GAS: np.ones(96), }, "15m", - True, + "absolute_value", pytest.approx(6.0 - 1.5), # 48*10*0.05/4 - 48*5*0.025/4 = 6.0 - 1.5 = 4.5 { "electric": { @@ -3133,7 +3139,7 @@ def test_calculate_itemized_cost_pyo( charge_dict, consumption_data_dict, resolution, - decompose_exports, + decomposition_type, expected_cost, expected_itemized, ): @@ -3143,11 +3149,11 @@ def test_calculate_itemized_cost_pyo( charge_dict, pyo_vars, resolution=resolution, - decompose_exports=decompose_exports, + decomposition_type=decomposition_type, model=model, ) solve_pyo_problem( - model, result["total"], decompose_exports, charge_dict, consumption_data_dict + model, result["total"], decomposition_type, charge_dict, consumption_data_dict ) assert pyo.value(result["total"]) == expected_cost diff --git a/eeco/tests/test_utils.py b/eeco/tests/test_utils.py index b5c8bc3..e400f0b 100644 --- a/eeco/tests/test_utils.py +++ b/eeco/tests/test_utils.py @@ -157,7 +157,16 @@ def gas_constraint(m, t): model.objective = pyo.Objective(expr=0) solver = pyo.SolverFactory("gurobi") solver.solve(model) + assert pyo.value(result) == expected + + # # TODO: debug indexed issue + # if hasattr(result, 'index_set'): + # total_value = sum(pyo.value(result[t]) for t in result.index_set()) + # else: + # total_value = pyo.value(result) + # assert total_value == expected + assert model is not None diff --git a/eeco/utils.py b/eeco/utils.py index b450555..b841b52 100644 --- a/eeco/utils.py +++ b/eeco/utils.py @@ -309,21 +309,41 @@ def const_rule(model): model.add_component(varstr + "_constraint", constraint) return (var, model) elif isinstance(expression, (IndexedExpression, pyo.Param, pyo.Var)): - model.add_component(varstr, pyo.Var(bounds=(0, None))) - var = model.find_component(varstr) + # Check if expression is indexed + if hasattr(expression, "index_set"): + # Create indexed max_pos variable + model.add_component( + varstr, pyo.Var(expression.index_set(), bounds=(0, None)) + ) + var = model.find_component(varstr) - def const_rule(model, t): - return var >= expression[t] + def const_rule(model, *indices): + return var[indices] >= expression[indices] - constraint = pyo.Constraint(model.t, rule=const_rule) - model.add_component(varstr + "_constraint", constraint) - return (var, model) + constraint = pyo.Constraint(expression.index_set(), rule=const_rule) + model.add_component(varstr + "_constraint", constraint) + return (var, model) + else: + # Create scalar max_pos variable + model.add_component(varstr, pyo.Var(initialize=0, bounds=(0, None))) + var = model.find_component(varstr) + + def const_rule(model): + return var >= expression + + constraint = pyo.Constraint(rule=const_rule) + model.add_component(varstr + "_constraint", constraint) + return (var, model) elif isinstance( expression, (int, float, np.int32, np.int64, np.float32, np.float64, np.ndarray) ): return (np.max(expression), model) if np.max(expression) > 0 else (0, model) elif isinstance(expression, cp.Expression): - return cp.max(cp.vstack([expression, 0])), None + # Check if expression is indexed + if expression.shape == (): # Scalar + return cp.max(cp.vstack([expression, 0])), None + else: # Vector + return cp.maximum(expression, 0), None else: raise TypeError( "Only CVXPY or Pyomo variables and NumPy arrays are currently supported." @@ -430,7 +450,9 @@ def initialize_decomposed_pyo_vars(consumption_data_dict, model, charge_dict): return consumption_object_dict -def decompose_consumption(expression, model=None, varstr=None): +def decompose_consumption( + expression, model=None, varstr=None, decomposition_type="absolute_value" +): """Decomposes consumption data into positive and negative components And adds constraint such that total consumption equals positive values minus negative values @@ -455,6 +477,11 @@ def decompose_consumption(expression, model=None, varstr=None): varstr : str Name prefix for the variables to be created if using a Pyomo `model` + decomposition_type : str + Type of decomposition to use. + - "binary_variable": To be implemented + - "absolute_value": Creates nonlinear problem + Returns ------- tuple @@ -463,75 +490,56 @@ def decompose_consumption(expression, model=None, varstr=None): with the constraint that total = positive - negative """ if isinstance(expression, np.ndarray): - positive_values = np.maximum(expression, 0) - negative_values = np.maximum(-expression, 0) # magnitude as positive + if decomposition_type == "absolute_value": + positive_values = np.maximum(expression, 0) + negative_values = np.maximum(-expression, 0) # magnitude as positive + else: + pass return positive_values, negative_values, model elif isinstance(expression, cp.Expression): - positive_values = cp.maximum(expression, 0) - negative_values = cp.maximum(-expression, 0) # magnitude as positive + if decomposition_type == "absolute_value": + positive_values, _ = max_pos(expression) + negative_values, _ = max_pos(-expression) # magnitude as positive + else: + pass return positive_values, negative_values, model elif isinstance(expression, (pyo.Var, pyo.Param)): - # Create positive consumption variable - model.add_component(f"{varstr}_positive", pyo.Var(model.t, bounds=(0, None))) - positive_var = model.find_component(f"{varstr}_positive") + if decomposition_type == "absolute_value": - def positive_lower_bound_rule(model, t): - return positive_var[t] >= 0 + # Use max_pos to create positive_var and negative_var + pos_name, neg_name = _get_decomposed_var_names(varstr) + positive_var, model = max_pos(expression, model, pos_name) - def positive_expr_bound_rule(model, t): - return positive_var[t] >= expression[t] + # Create negative expression since pyomo won't take -expression directly + def negative_rule(model, t): + return -expression[t] - model.add_component( - f"{varstr}_positive_lower_bound", - pyo.Constraint(model.t, rule=positive_lower_bound_rule), - ) - model.add_component( - f"{varstr}_positive_expr_bound", - pyo.Constraint(model.t, rule=positive_expr_bound_rule), - ) + negative_expr = pyo.Expression(model.t, rule=negative_rule) + model.add_component(f"{varstr}_negative_expr", negative_expr) + negative_var, model = max_pos(negative_expr, model, neg_name) - # Create negative consumption magnitude variable - model.add_component(f"{varstr}_negative", pyo.Var(model.t, bounds=(0, None))) - negative_var = model.find_component(f"{varstr}_negative") + # Add constraint to balance import and export decomposed values + def decomposition_rule(model, t): + return expression[t] == positive_var[t] - negative_var[t] - def negative_lower_bound_rule(model, t): - return negative_var[t] >= 0 + model.add_component( + f"{varstr}_decomposition_constraint", + pyo.Constraint(model.t, rule=decomposition_rule), + ) - def negative_expr_bound_rule(model, t): - return ( - negative_var[t] >= -expression[t] - ) # Flips sign of the negative consumption component + # Add constraint to ensure positive_var + negative_var = |expression| + # This both variables becoming larger due to artificial arbitrage + def magnitude_rule(model, t): + return positive_var[t] + negative_var[t] == abs(expression[t]) - model.add_component( - f"{varstr}_negative_lower_bound", - pyo.Constraint(model.t, rule=negative_lower_bound_rule), - ) - model.add_component( - f"{varstr}_negative_expr_bound", - pyo.Constraint(model.t, rule=negative_expr_bound_rule), - ) + model.add_component( + f"{varstr}_magnitude_constraint", + pyo.Constraint(model.t, rule=magnitude_rule), + ) - # Add constraint: expression = positive_var - negative_var - # Balances import and export decomposed values - def decomposition_rule(model, t): - return expression[t] == positive_var[t] - negative_var[t] - - model.add_component( - f"{varstr}_decomposition_constraint", - pyo.Constraint(model.t, rule=decomposition_rule), - ) - - # Add constraint to ensure positive_var + negative_var = |expression| - # This both variables becoming larger due to artificial arbitrage - def magnitude_rule(model, t): - return positive_var[t] + negative_var[t] == abs(expression[t]) - - model.add_component( - f"{varstr}_magnitude_constraint", - pyo.Constraint(model.t, rule=magnitude_rule), - ) - - return positive_var, negative_var, model + return positive_var, negative_var, model + else: + pass else: raise TypeError( "Only CVXPY or Pyomo variables and NumPy arrays are currently supported." From ed883c2be40b055751370682f92082ef0201b85d Mon Sep 17 00:00:00 2001 From: dalyw Date: Fri, 12 Sep 2025 16:08:17 -0700 Subject: [PATCH 05/26] Adding missing arguments in test_costs.py --- eeco/costs.py | 1 + eeco/tests/test_costs.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/eeco/costs.py b/eeco/costs.py index 308c620..d6ac2a1 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -1103,6 +1103,7 @@ def calculate_cost( """ cost = 0 n_per_hour = int(60 / ut.get_freq_binsize_minutes(resolution)) + n_per_day = n_per_hour * 24 if consumption_estimate is None: consumption_estimate = 0 diff --git a/eeco/tests/test_costs.py b/eeco/tests/test_costs.py index c6eca12..a47152d 100644 --- a/eeco/tests/test_costs.py +++ b/eeco/tests/test_costs.py @@ -1304,6 +1304,7 @@ def test_calculate_cost_cvx( np.ones(96) * 100, None, None, + None, 260, ), # energy charge with charge limit and dictionary consumption estimate @@ -1330,6 +1331,7 @@ def test_calculate_cost_cvx( {ELECTRIC: np.ones(96) * 100, GAS: np.ones(96)}, None, None, + None, 260, ), # energy charge with charge limit and dictionary consumption estimate @@ -1356,6 +1358,7 @@ def test_calculate_cost_cvx( {ELECTRIC: 2400, GAS: np.ones(96)}, None, None, + None, 260, ), # energy charge that won't hit charge limit + time-varying consumption estimate @@ -1385,6 +1388,7 @@ def test_calculate_cost_cvx( 2400, None, None, + None, 260, ), ], From 00bfa15d2e20993b08e07767cd55381c3ebcb369 Mon Sep 17 00:00:00 2001 From: dalyw Date: Mon, 15 Sep 2025 09:10:18 -0700 Subject: [PATCH 06/26] COSTS Adding calculate_conversion_factors Modifying calculate_energy_cost to only apply convex approximation when there are charge tiers TESTS Adding consumption_estimate to test_calculate_cost_cvx to avoid default 0 issues Adding additional test case for cvx problem without charge limit to test_calculate_cost_cvx Updating test expectations in test_max_pos_pyo so that scalar inputs have scalar expectations, vector inputs have vector expectations --- eeco/costs.py | 97 ++++++++++++++++++++++++++++++++++------ eeco/tests/test_costs.py | 55 +++++++++++++++++++---- eeco/tests/test_utils.py | 16 +++---- eeco/utils.py | 10 +++-- 4 files changed, 142 insertions(+), 36 deletions(-) diff --git a/eeco/costs.py b/eeco/costs.py index d6ac2a1..2d1e93f 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -817,14 +817,17 @@ def calculate_energy_cost( cost += max(energy - float(limit), 0) * charge_array[i] elif isinstance(consumption_data, (cp.Expression, pyo.Var, pyo.Param)): - # assume consumption is split evenly as an approximation + # For tiered charges, approximate extimated consumption being split evenly # NOTE: this convex approximation breaks global optimality guarantees - if isinstance(consumption_estimate, (float, int)): - consumption_per_timestep = consumption_estimate / n_steps - consumption_estimate = np.ones(n_steps) * consumption_per_timestep + # Apply tiered logic if we have a finite next_limit OR if limit > 0 + # (indicating a tiered structure) + if not np.isinf(next_limit) or (not np.isinf(limit) and limit > 0): + if isinstance(consumption_estimate, (float, int)): + consumption_per_timestep = consumption_estimate / n_steps + consumption_estimate = np.ones(n_steps) * consumption_per_timestep - cumulative_consumption = np.cumsum(consumption_estimate) + prev_consumption - total_consumption = cumulative_consumption[-1] + cumulative_consumption = np.cumsum(consumption_estimate) + prev_consumption + total_consumption = cumulative_consumption[-1] start_idx = np.argmax(cumulative_consumption > float(limit)) # if not found argmax returns 0, but whole charge array should be zeroed @@ -924,6 +927,29 @@ def calculate_export_revenue( ) +def calculate_conversion_factors(electric_consumption_units, gas_consumption_units): + """Calculate conversion factors for electric and gas utilities. + + Parameters + ---------- + electric_consumption_units : pint.Unit + Units for the electricity consumption data + gas_consumption_units : pint.Unit + Units for the gas consumption data + + Returns + ------- + dict + Dictionary with conversion factors for each utility type + """ + conversion_factors = {} + conversion_factors[ELECTRIC] = (1 * electric_consumption_units).to(u.kW).magnitude + conversion_factors[GAS] = ( + (1 * gas_consumption_units).to(u.meter**3 / u.hour).magnitude + ) + return conversion_factors + + def get_charge_array_duration(key): """Parse a charge array key to determine the duration of the charge period. @@ -1108,6 +1134,52 @@ def calculate_cost( if consumption_estimate is None: consumption_estimate = 0 + # Initialize definition of conversion factors for each utility type + conversion_factors = calculate_conversion_factors( + electric_consumption_units, gas_consumption_units + ) + + # Ensure consumption_data_dict has imports/exports structure for each utility + for utility in consumption_data_dict.keys(): + # Check if this utility already has imports/exports structure + if ( + isinstance(consumption_data_dict[utility], dict) + and "imports" in consumption_data_dict[utility] + and "exports" in consumption_data_dict[utility] + ): + continue + else: # create imports/exports + conversion_factor = conversion_factors[utility] + + converted_consumption, model = ut.multiply( + consumption_data_dict[utility], + conversion_factor, + model=model, + varstr=utility + "_converted", + ) + + if decomposition_type == "absolute_value": + # Decompose consumption data into positive and negative components + # with constraint that total = positive - negative + # (where negative is stored as positive magnitude) + pos_name, neg_name = ut._get_decomposed_var_names(utility) + imports, exports, model = ut.decompose_consumption( + converted_consumption, + model=model, + varstr=utility, + decomposition_type="absolute_value", + ) + + consumption_data_dict[utility] = { + "imports": imports, + "exports": exports, + } + elif decomposition_type is None: + consumption_data_dict[utility] = { + "imports": converted_consumption, + "exports": converted_consumption, + } + for key, charge_array in charge_dict.items(): utility, charge_type, name, eff_start, eff_end, limit_str = key.split("_") varstr = ut.sanitize_varstr( @@ -1614,6 +1686,10 @@ def calculate_itemized_cost( "Use Pyomo instead for problems requiring decomposition_type." ) + conversion_factors = calculate_conversion_factors( + electric_consumption_units, gas_consumption_units + ) + total_cost = 0 results_dict = {} @@ -1627,14 +1703,7 @@ def calculate_itemized_cost( ): continue else: # create imports/exports - if utility == ELECTRIC: - conversion_factor = (1 * electric_consumption_units).to(u.kW).magnitude - elif utility == GAS: - conversion_factor = ( - (1 * gas_consumption_units).to(u.meter**3 / u.day).magnitude - ) - else: - raise ValueError("Invalid utility: " + utility) + conversion_factor = conversion_factors[utility] converted_consumption, model = ut.multiply( consumption_data_dict[utility], diff --git a/eeco/tests/test_costs.py b/eeco/tests/test_costs.py index a47152d..07461a3 100644 --- a/eeco/tests/test_costs.py +++ b/eeco/tests/test_costs.py @@ -1092,6 +1092,17 @@ def test_calculate_cost_np( None, pytest.approx(260), ), + # energy charge without charge limits + ( + {"electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05}, + {ELECTRIC: np.ones(96) * 100, GAS: np.ones(96)}, + "15m", + None, + 0, + None, + None, + pytest.approx(120.0), + ), ], ) def test_calculate_cost_cvx( @@ -2998,19 +3009,21 @@ def test_calculate_itemized_cost_np( "consumption_data_dict, " "resolution, " "decomposition_type, " + "consumption_estimate, " "expected_cost, " "expected_itemized", [ - # single energy charge + # simple energy charge without charge limits ( {"electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05}, - {ELECTRIC: np.ones(96), GAS: np.ones(96)}, + {ELECTRIC: np.ones(96) * 100, GAS: np.ones(96)}, "15m", None, - pytest.approx(1.2), + 0, + pytest.approx(120.0), { "electric": { - "energy": pytest.approx(1.2), + "energy": pytest.approx(120.0), "export": 0.0, "customer": 0.0, "demand": 0.0, @@ -3035,6 +3048,7 @@ def test_calculate_itemized_cost_np( }, "15m", "absolute_value", + 240, None, # No expected cost - should raise error None, # No expected itemized - should raise error ), @@ -3045,6 +3059,7 @@ def test_calculate_itemized_cost_cvx( consumption_data_dict, resolution, decomposition_type, + consumption_estimate, expected_cost, expected_itemized, ): @@ -3058,6 +3073,7 @@ def test_calculate_itemized_cost_cvx( cvx_vars, resolution=resolution, decomposition_type=decomposition_type, + consumption_estimate=consumption_estimate, ) else: result, model = costs.calculate_itemized_cost( @@ -3065,6 +3081,7 @@ def test_calculate_itemized_cost_cvx( cvx_vars, resolution=resolution, decomposition_type=decomposition_type, + consumption_estimate=consumption_estimate, ) solve_cvx_problem(result["total"], constraints) @@ -3084,19 +3101,36 @@ def test_calculate_itemized_cost_cvx( "consumption_data_dict, " "resolution, " "decomposition_type, " + "consumption_estimate, " "expected_cost, " "expected_itemized", [ - # single energy charge + # energy charge with charge limit ( - {"electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05}, - {ELECTRIC: np.ones(96), GAS: np.ones(96)}, + { + "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( + [ + np.ones(64) * 0.05, + np.ones(20) * 0.1, + np.ones(12) * 0.05, + ] + ), + "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( + [ + np.ones(64) * 0.1, + np.ones(20) * 0.15, + np.ones(12) * 0.1, + ] + ), + }, + {ELECTRIC: np.ones(96) * 100, GAS: np.ones(96)}, "15m", None, - pytest.approx(1.2), + 2400, + pytest.approx(260), { "electric": { - "energy": pytest.approx(1.2), + "energy": pytest.approx(260), "export": 0.0, "customer": 0.0, "demand": 0.0, @@ -3121,6 +3155,7 @@ def test_calculate_itemized_cost_cvx( }, "15m", "absolute_value", + 240, pytest.approx(6.0 - 1.5), # 48*10*0.05/4 - 48*5*0.025/4 = 6.0 - 1.5 = 4.5 { "electric": { @@ -3144,6 +3179,7 @@ def test_calculate_itemized_cost_pyo( consumption_data_dict, resolution, decomposition_type, + consumption_estimate, expected_cost, expected_itemized, ): @@ -3155,6 +3191,7 @@ def test_calculate_itemized_cost_pyo( resolution=resolution, decomposition_type=decomposition_type, model=model, + consumption_estimate=consumption_estimate, ) solve_pyo_problem( model, result["total"], decomposition_type, charge_dict, consumption_data_dict diff --git a/eeco/tests/test_utils.py b/eeco/tests/test_utils.py index e400f0b..a36ea65 100644 --- a/eeco/tests/test_utils.py +++ b/eeco/tests/test_utils.py @@ -130,8 +130,8 @@ def gas_constraint(m, t): @pytest.mark.parametrize( "consumption_data, varstr, expected", [ - ({"electric": np.ones(96) * 45, "gas": np.ones(96) * -1}, "electric", 45), - ({"electric": np.ones(96) * 100, "gas": np.ones(96) * -1}, "gas", 0), + ({"electric": np.ones(96) * 45, "gas": np.ones(96) * -1}, "electric", np.ones(96) * 45), + ({"electric": np.ones(96) * 100, "gas": np.ones(96) * -1}, "gas", np.zeros(96)), ], ) def test_max_pos_pyo(consumption_data, varstr, expected): @@ -158,14 +158,12 @@ def gas_constraint(m, t): solver = pyo.SolverFactory("gurobi") solver.solve(model) - assert pyo.value(result) == expected + # Check each element in returned vector + for t in result.index_set(): + expected_element = expected[t] + assert pyo.value(result[t]) == expected_element - # # TODO: debug indexed issue - # if hasattr(result, 'index_set'): - # total_value = sum(pyo.value(result[t]) for t in result.index_set()) - # else: - # total_value = pyo.value(result) - # assert total_value == expected + # TODO: add scalar test assert model is not None diff --git a/eeco/utils.py b/eeco/utils.py index b841b52..d6e54d2 100644 --- a/eeco/utils.py +++ b/eeco/utils.py @@ -258,8 +258,8 @@ def const_rule(model): def max_pos(expression, model=None, varstr=None): - """Returns the maximum positive scalar value of an expression. - I.e., max([x, 0]) where x is any element of the expression (if a matrix) + """Returns the element-wise maximum positive value of an expression. + Returns scalar for scalar input, indexed for indexed input. Parameters ---------- @@ -294,7 +294,8 @@ def max_pos(expression, model=None, varstr=None): [numpy.float, numpy.int, numpy.Array, cvxpy.Expression, or pyomo.environ.Var], pyomo.environ.Model ) - Expression representing maximum positive scalar value of `expression` + Expression representing element-wise maximum positive value of `expression`. + Scalar input returns scalar output, indexed input returns indexed output. """ if isinstance( expression, (LinearExpression, SumExpression, MonomialTermExpression, ScalarVar) @@ -311,6 +312,7 @@ def const_rule(model): elif isinstance(expression, (IndexedExpression, pyo.Param, pyo.Var)): # Check if expression is indexed if hasattr(expression, "index_set"): + # if hasattr(expression, "is_indexed") and expression.is_indexed(): # Create indexed max_pos variable model.add_component( varstr, pyo.Var(expression.index_set(), bounds=(0, None)) @@ -341,7 +343,7 @@ def const_rule(model): elif isinstance(expression, cp.Expression): # Check if expression is indexed if expression.shape == (): # Scalar - return cp.max(cp.vstack([expression, 0])), None + return cp.maximum(expression, 0), None else: # Vector return cp.maximum(expression, 0), None else: From a16404f97a69482f2b78e11ed1a7e27c41a20f82 Mon Sep 17 00:00:00 2001 From: dalyw Date: Mon, 15 Sep 2025 09:25:42 -0700 Subject: [PATCH 07/26] Fixing bug with duplicated conversion_factor in calculate_cost Reformatting with black --- eeco/costs.py | 10 ++++------ eeco/tests/test_utils.py | 6 +++++- eeco/utils.py | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/eeco/costs.py b/eeco/costs.py index 2d1e93f..0932b45 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -929,14 +929,14 @@ def calculate_export_revenue( def calculate_conversion_factors(electric_consumption_units, gas_consumption_units): """Calculate conversion factors for electric and gas utilities. - + Parameters ---------- electric_consumption_units : pint.Unit Units for the electricity consumption data gas_consumption_units : pint.Unit Units for the gas consumption data - + Returns ------- dict @@ -1129,15 +1129,13 @@ def calculate_cost( """ cost = 0 n_per_hour = int(60 / ut.get_freq_binsize_minutes(resolution)) - n_per_day = n_per_hour * 24 if consumption_estimate is None: consumption_estimate = 0 - # Initialize definition of conversion factors for each utility type conversion_factors = calculate_conversion_factors( electric_consumption_units, gas_consumption_units - ) + ) # Ensure consumption_data_dict has imports/exports structure for each utility for utility in consumption_data_dict.keys(): @@ -1688,7 +1686,7 @@ def calculate_itemized_cost( conversion_factors = calculate_conversion_factors( electric_consumption_units, gas_consumption_units - ) + ) total_cost = 0 results_dict = {} diff --git a/eeco/tests/test_utils.py b/eeco/tests/test_utils.py index a36ea65..6b84447 100644 --- a/eeco/tests/test_utils.py +++ b/eeco/tests/test_utils.py @@ -130,7 +130,11 @@ def gas_constraint(m, t): @pytest.mark.parametrize( "consumption_data, varstr, expected", [ - ({"electric": np.ones(96) * 45, "gas": np.ones(96) * -1}, "electric", np.ones(96) * 45), + ( + {"electric": np.ones(96) * 45, "gas": np.ones(96) * -1}, + "electric", + np.ones(96) * 45, + ), ({"electric": np.ones(96) * 100, "gas": np.ones(96) * -1}, "gas", np.zeros(96)), ], ) diff --git a/eeco/utils.py b/eeco/utils.py index d6e54d2..cf0af71 100644 --- a/eeco/utils.py +++ b/eeco/utils.py @@ -312,7 +312,7 @@ def const_rule(model): elif isinstance(expression, (IndexedExpression, pyo.Param, pyo.Var)): # Check if expression is indexed if hasattr(expression, "index_set"): - # if hasattr(expression, "is_indexed") and expression.is_indexed(): + # if hasattr(expression, "is_indexed") and expression.is_indexed(): # Create indexed max_pos variable model.add_component( varstr, pyo.Var(expression.index_set(), bounds=(0, None)) From 39267ea9b0202ba324b9e19b3a5698e0acf3f4da Mon Sep 17 00:00:00 2001 From: dalyw Date: Mon, 15 Sep 2025 09:42:25 -0700 Subject: [PATCH 08/26] Since conversion_factor is applied earlier during import/export decomposition, removing it from divisor in costs.py and renaming divisor to n_per_hour Moving application of conversion factor to helper function get_converted_consumption_data --- eeco/costs.py | 236 +++++++++++++++++++++++++------------------------- 1 file changed, 120 insertions(+), 116 deletions(-) diff --git a/eeco/costs.py b/eeco/costs.py index 0932b45..585b0bd 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -713,7 +713,7 @@ def const_rule(model, t): def calculate_energy_cost( charge_array, consumption_data, - divisor, + n_per_hour, limit=0, next_limit=float("inf"), prev_consumption=0, @@ -732,8 +732,9 @@ def calculate_energy_cost( consumption_data : numpy.ndarray cvxpy.Expression, or pyomo.environ.Var Baseline electrical or gas usage data as an optimization variable object - divisor : int - Divisor for the energy charges, based on the timeseries resolution + n_per_hour : int + Divisor for the indexed consumption data elements, + based on the timeseries resolution limit : float The total consumption, or limit, that this charge came into effect. @@ -795,7 +796,7 @@ def calculate_energy_cost( # within the current tier of charge limits within_limit_flag = energy >= float(limit) and energy < float(next_limit) for i in range(n_steps): - energy += consumption_data[i] / divisor + energy += consumption_data[i] / n_per_hour # only add to charges if already within correct charge limits if within_limit_flag: # went over next charge limit on this iteration @@ -804,13 +805,15 @@ def calculate_energy_cost( within_limit_flag = False cost += ( max( - float(next_limit) + consumption_data[i] / divisor - energy, + float(next_limit) + + consumption_data[i] / n_per_hour + - energy, 0, ) * charge_array[i] ) else: - cost += consumption_data[i] / divisor * charge_array[i] + cost += consumption_data[i] / n_per_hour * charge_array[i] # went over existing charge limit on this iteration elif energy >= float(limit) and energy < float(next_limit): within_limit_flag = True @@ -846,7 +849,7 @@ def calculate_energy_cost( ) sum_result, model = ut.sum(charge_expr, model=model, varstr=varstr + "_sum") cost, model = ut.max_pos( - sum_result / divisor, + sum_result / n_per_hour, model=model, varstr=varstr, ) @@ -859,7 +862,7 @@ def calculate_energy_cost( def calculate_export_revenue( - charge_array, consumption_data, divisor, model=None, varstr="" + charge_array, consumption_data, n_per_hour, model=None, varstr="" ): """Calculates the export revenues for the given billing rate structure, utility, and consumption information. @@ -877,8 +880,9 @@ def calculate_export_revenue( as an optimization variable object. Should be positive values. - divisor : int - Divisor for the export revenue, based on the timeseries resolution + n_per_hour : int + Divisor for the indexed consumption data elements, + based on the timeseries resolution model : pyomo.Model The model object associated with the problem. @@ -908,7 +912,7 @@ def calculate_export_revenue( "Pass in only positive values or " "run calculate_cost with a decomposition_type" ) - return np.sum(consumption_data * charge_array) / divisor, model + return np.sum(consumption_data * charge_array) / n_per_hour, model elif isinstance(consumption_data, (cp.Expression, pyo.Var, pyo.Param)): cost_expr, model = ut.multiply( @@ -919,7 +923,7 @@ def calculate_export_revenue( ) export_revenue, model = ut.sum(cost_expr, model=model, varstr=varstr + "_sum") - return export_revenue / divisor, model + return export_revenue / n_per_hour, model else: raise ValueError( "consumption_data must be of type numpy.ndarray, " @@ -927,7 +931,7 @@ def calculate_export_revenue( ) -def calculate_conversion_factors(electric_consumption_units, gas_consumption_units): +def get_conversion_factors(electric_consumption_units, gas_consumption_units): """Calculate conversion factors for electric and gas utilities. Parameters @@ -950,6 +954,85 @@ def calculate_conversion_factors(electric_consumption_units, gas_consumption_uni return conversion_factors +def get_converted_consumption_data( + consumption_data_dict, conversion_factors, decomposition_type, model=None +): + """Ensure consumption_data_dict has imports/exports structure + with proper unit conversion. + + Parameters + ---------- + consumption_data_dict : dict + Dictionary with utility keys and consumption data values + conversion_factors : dict + Dictionary with conversion factors for each utility type + decomposition_type : str or None + Type of decomposition to apply (e.g., "absolute_value") + model : pyomo.Model, optional + Pyomo model object for optimization variables + + Returns + ------- + dict + Updated consumption_data_dict with imports/exports structure + """ + for utility in consumption_data_dict.keys(): + conversion_factor = conversion_factors[utility] + + # Check if this utility already has imports/exports structure + if ( + isinstance(consumption_data_dict[utility], dict) + and "imports" in consumption_data_dict[utility] + and "exports" in consumption_data_dict[utility] + ): + # Apply conversion if conversion factor is nonzero + if conversion_factor != 1.0: + consumption_data_dict[utility]["imports"], model = ut.multiply( + consumption_data_dict[utility]["imports"], + conversion_factor, + model=model, + varstr=utility + "_imports_converted", + ) + consumption_data_dict[utility]["exports"], model = ut.multiply( + consumption_data_dict[utility]["exports"], + conversion_factor, + model=model, + varstr=utility + "_exports_converted", + ) + continue + else: # create imports/exports + converted_consumption, model = ut.multiply( + consumption_data_dict[utility], + conversion_factor, + model=model, + varstr=utility + "_converted", + ) + + if decomposition_type == "absolute_value": + # Decompose consumption data into positive and negative components + # with constraint that total = positive - negative + # (where negative is stored as positive magnitude) + pos_name, neg_name = ut._get_decomposed_var_names(utility) + imports, exports, model = ut.decompose_consumption( + converted_consumption, + model=model, + varstr=utility, + decomposition_type="absolute_value", + ) + + consumption_data_dict[utility] = { + "imports": imports, + "exports": exports, + } + elif decomposition_type is None: + consumption_data_dict[utility] = { + "imports": converted_consumption, + "exports": converted_consumption, + } + + return consumption_data_dict + + def get_charge_array_duration(key): """Parse a charge array key to determine the duration of the charge period. @@ -1133,50 +1216,13 @@ def calculate_cost( if consumption_estimate is None: consumption_estimate = 0 - conversion_factors = calculate_conversion_factors( + conversion_factors = get_conversion_factors( electric_consumption_units, gas_consumption_units ) - # Ensure consumption_data_dict has imports/exports structure for each utility - for utility in consumption_data_dict.keys(): - # Check if this utility already has imports/exports structure - if ( - isinstance(consumption_data_dict[utility], dict) - and "imports" in consumption_data_dict[utility] - and "exports" in consumption_data_dict[utility] - ): - continue - else: # create imports/exports - conversion_factor = conversion_factors[utility] - - converted_consumption, model = ut.multiply( - consumption_data_dict[utility], - conversion_factor, - model=model, - varstr=utility + "_converted", - ) - - if decomposition_type == "absolute_value": - # Decompose consumption data into positive and negative components - # with constraint that total = positive - negative - # (where negative is stored as positive magnitude) - pos_name, neg_name = ut._get_decomposed_var_names(utility) - imports, exports, model = ut.decompose_consumption( - converted_consumption, - model=model, - varstr=utility, - decomposition_type="absolute_value", - ) - - consumption_data_dict[utility] = { - "imports": imports, - "exports": exports, - } - elif decomposition_type is None: - consumption_data_dict[utility] = { - "imports": converted_consumption, - "exports": converted_consumption, - } + consumption_data_dict = get_converted_consumption_data( + consumption_data_dict, conversion_factors, decomposition_type, model + ) for key, charge_array in charge_dict.items(): utility, charge_type, name, eff_start, eff_end, limit_str = key.split("_") @@ -1192,17 +1238,6 @@ def calculate_cost( ): continue - if utility == ELECTRIC: - conversion_factor = (1 * electric_consumption_units).to(u.kW).magnitude - divisor = n_per_hour - elif utility == GAS: - conversion_factor = ( - (1 * gas_consumption_units).to(u.meter**3 / u.hour).magnitude - ) - divisor = n_per_hour - else: - raise ValueError("Invalid utility: " + utility) - charge_limit = int(limit_str) key_substr = "_".join([utility, charge_type, name, eff_start, eff_end]) next_limit = get_next_limit(key_substr, charge_limit, charge_dict.keys()) @@ -1229,13 +1264,13 @@ def calculate_cost( if isinstance(consumption_estimate, (float, int)): # convert single kWh to the equivalent kW per timestep demand_consumption_estimate = ( - consumption_estimate * divisor / len(charge_array) + consumption_estimate * n_per_hour / len(charge_array) ) elif isinstance(consumption_estimate, (dict)): demand_consumption_estimate = consumption_estimate[utility] if isinstance(demand_consumption_estimate, (float, int)): demand_consumption_estimate = ( - demand_consumption_estimate * divisor / len(charge_array) + demand_consumption_estimate * n_per_hour / len(charge_array) ) else: demand_consumption_estimate = consumption_estimate @@ -1267,14 +1302,16 @@ def calculate_cost( if not isinstance( energy_consumption_estimate, (float, int) ): # array-like - energy_consumption_estimate = energy_consumption_estimate / divisor + energy_consumption_estimate = ( + energy_consumption_estimate / n_per_hour + ) else: - energy_consumption_estimate = consumption_estimate / divisor + energy_consumption_estimate = consumption_estimate / n_per_hour new_cost, model = calculate_energy_cost( charge_array, - converted_data, - divisor, + consumption_data_dict[utility]["imports"], + n_per_hour, limit=charge_limit, next_limit=next_limit, prev_consumption=prev_consumption, @@ -1284,8 +1321,12 @@ def calculate_cost( ) cost += new_cost elif charge_type == EXPORT: - new_cost, model = calculate_export_revenues( - charge_array, converted_data, divisor, model=model, varstr=varstr + new_cost, model = calculate_export_revenue( + charge_array, + consumption_data_dict[utility]["exports"], + n_per_hour, + model=model, + varstr=varstr, ) cost -= new_cost elif charge_type == CUSTOMER: @@ -1600,7 +1641,7 @@ def calculate_itemized_cost( Units for the electricity consumption data. Default is kW gas_consumption_units : pint.Unit - Units for the natura gas consumption data. Default is cubic meters / hour + Units for the natural gas consumption data. Default is cubic meters / hour resolution : str String of the form `[int][str]` giving the temporal resolution @@ -1684,54 +1725,17 @@ def calculate_itemized_cost( "Use Pyomo instead for problems requiring decomposition_type." ) - conversion_factors = calculate_conversion_factors( + conversion_factors = get_conversion_factors( electric_consumption_units, gas_consumption_units ) + consumption_data_dict = get_converted_consumption_data( + consumption_data_dict, conversion_factors, decomposition_type, model + ) + total_cost = 0 results_dict = {} - # Create consumption objects once to avoid recreating in each calculate_cost call - for utility in consumption_data_dict.keys(): - # Check if this utility already has imports/exports structure - if ( - isinstance(consumption_data_dict[utility], dict) - and "imports" in consumption_data_dict[utility] - and "exports" in consumption_data_dict[utility] - ): - continue - else: # create imports/exports - conversion_factor = conversion_factors[utility] - - converted_consumption, model = ut.multiply( - consumption_data_dict[utility], - conversion_factor, - model=model, - varstr=utility + "_converted", - ) - - if decomposition_type == "absolute_value": - # Decompose consumption data into positive and negative components - # with constraint that total = positive - negative - # (where negative is stored as positive magnitude) - pos_name, neg_name = ut._get_decomposed_var_names(utility) - imports, exports, model = ut.decompose_consumption( - converted_consumption, - model=model, - varstr=utility, - decomposition_type="absolute_value", - ) - - consumption_data_dict[utility] = { - "imports": imports, - "exports": exports, - } - elif decomposition_type is None: - consumption_data_dict[utility] = { - "imports": converted_consumption, - "exports": converted_consumption, - } - if desired_utility is None: for utility in [ELECTRIC, GAS]: results_dict[utility] = {} From 501308e6d93ec147d7ddf0da59d4d4e0c098db61 Mon Sep 17 00:00:00 2001 From: dalyw Date: Mon, 15 Sep 2025 11:31:42 -0700 Subject: [PATCH 09/26] Consolidating get_unique_row_name and default_varstr_alias_func default_varstr_alias_func takes row index parameter, where index is used if "name" is blank Removing dashes from names in test_costs.py since sanitize_varstr removes dashes and replaces with underscores Cleaning up error expectation in test_calculate_cost_np --- eeco/costs.py | 139 ++++++++++---------- eeco/tests/test_costs.py | 271 ++++++++++++++++++--------------------- 2 files changed, 189 insertions(+), 221 deletions(-) diff --git a/eeco/costs.py b/eeco/costs.py index 585b0bd..a2d09bf 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -50,34 +50,6 @@ OFF_PEAK = "off_peak" -def get_unique_row_name(charge, index=None): - """ - Get a unique row name for each row of charge df. - - Parameters - ---------- - charge : dict or pandas.Series - The charge row data containing NAME and PERIOD fields - index : int, optional - Index to use if name is empty or None - - Returns - ------- - str - A unique name with underscores converted to dashes - """ - try: - name = charge[NAME] - except KeyError: - name = charge[PERIOD] - - # if no name was given just use the index to differentiate - if not (isinstance(name, str) and name != ""): - name = str(index) if index is not None else "" - # replace underscores with dashes for unique delimiter - return name.replace("_", "-") - - def create_charge_array(charge, datetime, effective_start_date, effective_end_date): """Creates a single charge array based on the given parameters. @@ -295,7 +267,11 @@ def get_charge_dict(start_dt, end_dt, rate_data, resolution="15m"): limit_charges = effective_charges.loc[charge_limits == limit, :] for i, idx in enumerate(limit_charges.index): charge = limit_charges.loc[idx, :] - name = get_unique_row_name(charge, i) + + try: + name = charge[NAME] + except KeyError: # backward compatibility to "period" + name = charge.get(PERIOD, None) try: assessed = charge[ASSESSED] @@ -312,15 +288,14 @@ def get_charge_dict(start_dt, end_dt, rate_data, resolution="15m"): "and 'charge (imperial)' format", DeprecationWarning, ) - key_str = "_".join( - ( - utility, - charge_type, - name, - start.strftime("%Y%m%d"), - end.strftime("%Y%m%d"), - str(int(limit)), - ) + key_str = default_varstr_alias_func( + utility, + charge_type, + name, + start.strftime("%Y%m%d"), + end.strftime("%Y%m%d"), + str(int(limit)), + i, ) add_to_charge_array(charge_dict, key_str, charge_array) elif charge_type == DEMAND and assessed == "daily": @@ -330,30 +305,28 @@ def get_charge_dict(start_dt, end_dt, rate_data, resolution="15m"): charge_array = create_charge_array( charge, datetime, new_start, new_end ) - key_str = "_".join( - ( - utility, - charge_type, - name, - new_start.strftime("%Y%m%d"), - new_start.strftime("%Y%m%d"), - str(limit), - ) + key_str = default_varstr_alias_func( + utility, + charge_type, + name, + new_start.strftime("%Y%m%d"), + new_start.strftime("%Y%m%d"), + str(limit), + i, ) add_to_charge_array(charge_dict, key_str, charge_array) else: charge_array = create_charge_array( charge, datetime, start, new_end ) - key_str = "_".join( - ( - utility, - charge_type, - name, - start.strftime("%Y%m%d"), - end.strftime("%Y%m%d"), - str(int(limit)), - ) + key_str = default_varstr_alias_func( + utility, + charge_type, + name, + start.strftime("%Y%m%d"), + end.strftime("%Y%m%d"), + str(int(limit)), + i, ) add_to_charge_array(charge_dict, key_str, charge_array) return charge_dict @@ -488,7 +461,7 @@ def get_charge_df( def default_varstr_alias_func( - utility, charge_type, name, start_date, end_date, charge_limit + utility, charge_type, name, start_date, end_date, charge_limit, row_index ): """Default function for creating the variable name strings for each charge in the tariff sheet. Can be overwritten in the function call to `calculate_cost` @@ -503,7 +476,8 @@ def default_varstr_alias_func( Name of the `charge_type` ('demand', 'energy', or 'customer') name : str - The name of the period for this charge (e.g., 'all-day' or 'on-peak') + The name of the period for this charge (e.g., 'all-day' or 'on-peak'). + If empty or None, will use row_index for uniqueness. start_date The inclusive start date for this charge @@ -514,13 +488,24 @@ def default_varstr_alias_func( charge_limit : str The consumption limit for this tier of charges converted to a string + row_index : int + Row index to use if name is empty or None + Returns ------- str Variable name of the form `utility`_`charge_type`_`name`_`start_date`_`end_date`_`charge_limit` """ - return f"{utility}_{charge_type}_{name}_{start_date}_{end_date}_{charge_limit}" + # Handle NaN values and None - float NaN values from df + # TODO: check why they are float + if name is None or (isinstance(name, float) and str(name).lower() == "nan"): + name = str(row_index) + name = str(name).replace("_", "") + name = str(name).replace("-", "") + + key = f"{utility}_{charge_type}_{name}_{start_date}_{end_date}_{charge_limit}" + return ut.sanitize_varstr(key) def get_next_limit(key_substr, current_limit, keys): @@ -1224,13 +1209,8 @@ def calculate_cost( consumption_data_dict, conversion_factors, decomposition_type, model ) - for key, charge_array in charge_dict.items(): - utility, charge_type, name, eff_start, eff_end, limit_str = key.split("_") - varstr = ut.sanitize_varstr( - varstr_alias_func( - utility, charge_type, name, eff_start, eff_end, limit_str - ) # noqa: E501 - ) + for varstr, charge_array in charge_dict.items(): + utility, charge_type, name, eff_start, eff_end, limit_str = varstr.split("_") # if we want itemized costs skip irrelvant portions of the bill if (desired_utility and utility not in desired_utility) or ( @@ -1250,13 +1230,13 @@ def calculate_cost( ) # Only apply demand_scale_factor if charge spans more than one day - charge_duration_days = get_charge_array_duration(key) + charge_duration_days = get_charge_array_duration(varstr) effective_scale_factor = demand_scale_factor if charge_duration_days > 1 else 1 if charge_type == DEMAND: if prev_demand_dict is not None: - prev_demand = prev_demand_dict[key][DEMAND] - prev_demand_cost = prev_demand_dict[key]["cost"] + prev_demand = prev_demand_dict[varstr][DEMAND] + prev_demand_cost = prev_demand_dict[varstr]["cost"] else: prev_demand = 0 prev_demand_cost = 0 @@ -1290,7 +1270,7 @@ def calculate_cost( cost += new_cost elif charge_type == ENERGY: if prev_consumption_dict is not None: - prev_consumption = prev_consumption_dict[key] + prev_consumption = prev_consumption_dict[varstr] else: prev_consumption = 0 @@ -2104,15 +2084,28 @@ def parametrize_rate_data( row = variant_data.loc[row_idx] if has_exact_keys: # Format 2: Exact charge key matching - row_name = get_unique_row_name(row, row_idx) + try: + name = row[NAME] + except KeyError: # for backward compatibility + name = row.get(PERIOD, None) + + if name is None or ( + isinstance(name, float) and str(name).lower() == "nan" + ): + name = str(row_idx) + + name = str(name).replace("_", "").replace("-", "") + for key_prefix, key_ratio in charge_ratios.items(): if key_prefix.startswith( - f"{row[UTILITY]}_{row[TYPE]}_{row_name}" + f"{row[UTILITY]}_{row[TYPE]}_{name}" ): ratio = key_ratio + # Remove dashes in the df when key is found + variant_data.loc[row_idx, NAME] = name break else: - missing_keys.add(f"{row[UTILITY]}_{row[TYPE]}_{row_name}") + missing_keys.add(f"{row[UTILITY]}_{row[TYPE]}_{name}") elif charge_ratios: # Format 1: Period-based approach ratio = charge_ratios[period] diff --git a/eeco/tests/test_costs.py b/eeco/tests/test_costs.py index 07461a3..eaed668 100644 --- a/eeco/tests/test_costs.py +++ b/eeco/tests/test_costs.py @@ -278,14 +278,14 @@ def test_create_charge_array( input_dir + "billing_energy_peak.csv", "15m", { - "electric_energy_off-peak_20240710_20240710_0": np.concatenate( + "electric_energy_offpeak_20240710_20240710_0": np.concatenate( [ np.ones(64) * 0.05, np.zeros(20), np.ones(12) * 0.05, ] ), - "electric_energy_on-peak_20240710_20240710_0": np.concatenate( + "electric_energy_onpeak_20240710_20240710_0": np.concatenate( [ np.zeros(64), np.ones(20) * 0.1, @@ -302,7 +302,7 @@ def test_create_charge_array( input_dir + "billing_energy_combine.csv", "15m", { - "electric_energy_all-day_20240710_20240710_0": np.concatenate( + "electric_energy_allday_20240710_20240710_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, @@ -311,15 +311,15 @@ def test_create_charge_array( ), }, ), - # 2 demand charges, all-day and on-peak + # 2 demand charges, allday and on-peak ( np.datetime64("2024-07-10"), # Summer weekdays np.datetime64("2024-07-11"), # Summer weekdays input_dir + "billing_demand_2.csv", "15m", { - "electric_demand_all-day_20240710_20240710_0": np.ones(96) * 5, - "electric_demand_on-peak_20240710_20240710_0": np.concatenate( + "electric_demand_allday_20240710_20240710_0": np.ones(96) * 5, + "electric_demand_onpeak_20240710_20240710_0": np.concatenate( [ np.zeros(64), np.ones(20) * 20, @@ -335,8 +335,8 @@ def test_create_charge_array( input_dir + "billing_demand_monthly.csv", "15m", { - "electric_demand_all-day_20240710_20240710_0": np.ones(96) * 5, - "electric_demand_on-peak_20240710_20240710_0": np.concatenate( + "electric_demand_allday_20240710_20240710_0": np.ones(96) * 5, + "electric_demand_onpeak_20240710_20240710_0": np.concatenate( [ np.zeros(64), np.ones(20) * 20, @@ -353,8 +353,8 @@ def test_create_charge_array( input_dir + "billing_demand_daily.csv", "15m", { - "electric_demand_all-day_20240710_20240710_0": np.ones(96) * 5, - "electric_demand_on-peak_20240710_20240710_0": np.concatenate( + "electric_demand_allday_20240710_20240710_0": np.ones(96) * 5, + "electric_demand_onpeak_20240710_20240710_0": np.concatenate( [ np.zeros(64), np.ones(20) * 20, @@ -371,15 +371,15 @@ def test_create_charge_array( input_dir + "billing_demand_daily.csv", "15m", { - "electric_demand_all-day_20240710_20240711_0": np.ones(192) * 5, - "electric_demand_on-peak_20240710_20240710_0": np.concatenate( + "electric_demand_allday_20240710_20240711_0": np.ones(192) * 5, + "electric_demand_onpeak_20240710_20240710_0": np.concatenate( [ np.zeros(64), np.ones(20) * 20, np.zeros(108), ] ), - "electric_demand_on-peak_20240711_20240711_0": np.concatenate( + "electric_demand_onpeak_20240711_20240711_0": np.concatenate( [ np.zeros(160), np.ones(20) * 20, @@ -549,14 +549,14 @@ def test_create_charge_array( input_dir + "billing_energy_combine_charge_limit.csv", "15m", { - "electric_energy_all-day_20240710_20240710_0": np.concatenate( + "electric_energy_allday_20240710_20240710_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_all-day_20240710_20240710_100": np.concatenate( + "electric_energy_allday_20240710_20240710_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -565,23 +565,23 @@ def test_create_charge_array( ), }, ), - # 2 demand charges with 100 kW charge limits, all-day and on-peak + # 2 demand charges with 100 kW charge limits, allday and on-peak ( np.datetime64("2024-07-10"), # Summer weekdays np.datetime64("2024-07-11"), # Summer weekdays input_dir + "billing_demand_2_charge_limit.csv", "15m", { - "electric_demand_all-day_20240710_20240710_0": np.ones(96) * 5, - "electric_demand_all-day_20240710_20240710_100": np.ones(96) * 10, - "electric_demand_on-peak_20240710_20240710_0": np.concatenate( + "electric_demand_allday_20240710_20240710_0": np.ones(96) * 5, + "electric_demand_allday_20240710_20240710_100": np.ones(96) * 10, + "electric_demand_onpeak_20240710_20240710_0": np.concatenate( [ np.zeros(64), np.ones(20) * 20, np.zeros(12), ] ), - "electric_demand_on-peak_20240710_20240710_100": np.concatenate( + "electric_demand_onpeak_20240710_20240710_100": np.concatenate( [ np.zeros(64), np.ones(20) * 30, @@ -635,14 +635,14 @@ def test_get_charge_dict(start_dt, end_dt, billing_path, resolution, expected): # energy charge with charge limit ( { - "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -709,43 +709,18 @@ def test_calculate_cost_np( expect_error, ): if expect_error: - if ( - isinstance(consumption_data_dict.get(ELECTRIC), dict) - and "imports" in consumption_data_dict[ELECTRIC] - ): - # Import/export format with invalid list types - with pytest.raises( - AttributeError, match="'list' object has no attribute 'shape'" - ): - costs.calculate_cost( - charge_dict, - consumption_data_dict, - resolution=resolution, - prev_demand_dict=prev_demand_dict, - consumption_estimate=consumption_estimate, - desired_utility=desired_utility, - desired_charge_type=desired_charge_type, - ) - else: - # Invalid list types - with pytest.raises( - TypeError, - match="Only CVXPY or Pyomo variables and NumPy arrays " - "are currently supported", - ): - costs.calculate_cost( - charge_dict, - consumption_data_dict, - resolution=resolution, - prev_demand_dict=prev_demand_dict, - consumption_estimate=consumption_estimate, - desired_utility=desired_utility, - desired_charge_type=desired_charge_type, - ) + with pytest.raises(Exception): + costs.calculate_cost( + charge_dict, + consumption_data_dict, + resolution=resolution, + prev_demand_dict=prev_demand_dict, + consumption_estimate=consumption_estimate, + desired_utility=desired_utility, + desired_charge_type=desired_charge_type, + ) elif expect_warning: - with pytest.warns( - UserWarning, match="Energy calculation includes negative values" - ): + with pytest.warns(UserWarning): result, model = costs.calculate_cost( charge_dict, consumption_data_dict, @@ -779,7 +754,7 @@ def test_calculate_cost_np( # demand charge with previous consumption ( { - "electric_demand_peak-summer_2024-07-10_2024-07-10_0": ( + "electric_demand_peaksummer_2024-07-10_2024-07-10_0": ( np.concatenate( [ np.ones(48) * 0, @@ -788,7 +763,7 @@ def test_calculate_cost_np( ] ) ), - "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": ( + "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": ( np.concatenate( [ np.ones(34) * 0, @@ -799,20 +774,20 @@ def test_calculate_cost_np( ] ) ), - "electric_demand_off-peak_2024-07-10_2024-07-10_0": np.ones(96) * 10, + "electric_demand_offpeak_2024-07-10_2024-07-10_0": np.ones(96) * 10, }, {ELECTRIC: np.arange(96), GAS: np.arange(96)}, "15m", { - "electric_demand_peak-summer_2024-07-10_2024-07-10_0": { + "electric_demand_peaksummer_2024-07-10_2024-07-10_0": { "demand": 150, "cost": 150, }, - "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": { + "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": { "demand": 40, "cost": 80, }, - "electric_demand_off-peak_2024-07-10_2024-07-10_0": { + "electric_demand_offpeak_2024-07-10_2024-07-10_0": { "demand": 90, "cost": 900, }, @@ -825,14 +800,14 @@ def test_calculate_cost_np( # demand charge with no previous consumption ( { - "electric_demand_peak-summer_2024-07-10_2024-07-10_0": np.concatenate( + "electric_demand_peaksummer_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(48) * 0, np.ones(24) * 1, np.ones(24) * 0, ] ), - "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": ( + "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": ( np.concatenate( [ np.ones(34) * 0, @@ -843,20 +818,20 @@ def test_calculate_cost_np( ] ) ), - "electric_demand_off-peak_2024-07-10_2024-07-10_0": np.ones(96) * 10, + "electric_demand_offpeak_2024-07-10_2024-07-10_0": np.ones(96) * 10, }, {ELECTRIC: np.arange(96), GAS: np.arange(96)}, "15m", { - "electric_demand_peak-summer_2024-07-10_2024-07-10_0": { + "electric_demand_peaksummer_2024-07-10_2024-07-10_0": { "demand": 0, "cost": 0, }, - "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": { + "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": { "demand": 0, "cost": 0, }, - "electric_demand_off-peak_2024-07-10_2024-07-10_0": { + "electric_demand_offpeak_2024-07-10_2024-07-10_0": { "demand": 0, "cost": 0, }, @@ -869,14 +844,14 @@ def test_calculate_cost_np( # demand charge with consumption estimate as an array ( { - "electric_demand_peak-summer_2024-07-10_2024-07-10_0": np.concatenate( + "electric_demand_peaksummer_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(48) * 0, np.ones(24) * 1, np.ones(24) * 0, ] ), - "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": ( + "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": ( np.concatenate( [ np.ones(34) * 0, @@ -887,20 +862,20 @@ def test_calculate_cost_np( ] ) ), - "electric_demand_off-peak_2024-07-10_2024-07-10_0": np.ones(96) * 10, + "electric_demand_offpeak_2024-07-10_2024-07-10_0": np.ones(96) * 10, }, {ELECTRIC: np.arange(96), GAS: np.arange(96)}, "15m", { - "electric_demand_peak-summer_2024-07-10_2024-07-10_0": { + "electric_demand_peaksummer_2024-07-10_2024-07-10_0": { "demand": 150, "cost": 150, }, - "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": { + "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": { "demand": 40, "cost": 80, }, - "electric_demand_off-peak_2024-07-10_2024-07-10_0": { + "electric_demand_offpeak_2024-07-10_2024-07-10_0": { "demand": 90, "cost": 900, }, @@ -913,14 +888,14 @@ def test_calculate_cost_np( # demand charge with consumption estimate as an array and a charge tier ( { - "electric_demand_peak-summer_2024-07-10_2024-07-10_0": np.concatenate( + "electric_demand_peaksummer_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(48) * 0, np.ones(24) * 1, np.ones(24) * 0, ] ), - "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": ( + "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": ( np.concatenate( [ np.ones(34) * 0, @@ -931,25 +906,25 @@ def test_calculate_cost_np( ] ) ), - "electric_demand_off-peak_2024-07-10_2024-07-10_0": np.ones(96) * 10, - "electric_demand_off-peak_2024-07-10_2024-07-10_90": np.ones(96) * 5, + "electric_demand_offpeak_2024-07-10_2024-07-10_0": np.ones(96) * 10, + "electric_demand_offpeak_2024-07-10_2024-07-10_90": np.ones(96) * 5, }, {ELECTRIC: np.arange(96), GAS: np.arange(96)}, "15m", { - "electric_demand_peak-summer_2024-07-10_2024-07-10_0": { + "electric_demand_peaksummer_2024-07-10_2024-07-10_0": { "demand": 150, "cost": 150, }, - "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": { + "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": { "demand": 40, "cost": 80, }, - "electric_demand_off-peak_2024-07-10_2024-07-10_0": { + "electric_demand_offpeak_2024-07-10_2024-07-10_0": { "demand": 90, "cost": 900, }, - "electric_demand_off-peak_2024-07-10_2024-07-10_90": { + "electric_demand_offpeak_2024-07-10_2024-07-10_90": { "demand": 0, "cost": 0, }, @@ -962,14 +937,14 @@ def test_calculate_cost_np( # energy charge with charge limit ( { - "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -988,14 +963,14 @@ def test_calculate_cost_np( # energy charge with charge limit and time-varying consumption estimate ( { - "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -1014,14 +989,14 @@ def test_calculate_cost_np( # energy charge with charge limit and dictionary consumption estimate ( { - "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -1040,14 +1015,14 @@ def test_calculate_cost_np( # energy charge with charge limit and dictionary consumption estimate ( { - "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -1066,21 +1041,21 @@ def test_calculate_cost_np( # energy charge that won't hit charge limit + time-varying consumption estimate ( { - "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, np.ones(12) * 0.1, ] ), - "electric_energy_all-day_2024-07-10_2024-07-10_100000": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_100000": np.concatenate( [np.ones(96)] ), }, @@ -1140,14 +1115,14 @@ def test_calculate_cost_cvx( # energy charge with charge limit ( { - "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -1167,7 +1142,7 @@ def test_calculate_cost_cvx( # demand charge with previous consumption ( { - "electric_demand_peak-summer_2024-07-10_2024-07-10_0": ( + "electric_demand_peaksummer_2024-07-10_2024-07-10_0": ( np.concatenate( [ np.ones(48) * 0, @@ -1176,7 +1151,7 @@ def test_calculate_cost_cvx( ] ) ), - "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": ( + "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": ( np.concatenate( [ np.ones(34) * 0, @@ -1187,20 +1162,20 @@ def test_calculate_cost_cvx( ] ) ), - "electric_demand_off-peak_2024-07-10_2024-07-10_0": np.ones(96) * 10, + "electric_demand_offpeak_2024-07-10_2024-07-10_0": np.ones(96) * 10, }, {ELECTRIC: np.arange(96), GAS: np.arange(96)}, "15m", { - "electric_demand_peak-summer_2024-07-10_2024-07-10_0": { + "electric_demand_peaksummer_2024-07-10_2024-07-10_0": { "demand": 150, "cost": 150, }, - "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": { + "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": { "demand": 40, "cost": 80, }, - "electric_demand_off-peak_2024-07-10_2024-07-10_0": { + "electric_demand_offpeak_2024-07-10_2024-07-10_0": { "demand": 90, "cost": 900, }, @@ -1214,14 +1189,14 @@ def test_calculate_cost_cvx( # demand charge with no previous consumption ( { - "electric_demand_peak-summer_2024-07-10_2024-07-10_0": np.concatenate( + "electric_demand_peaksummer_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(48) * 0, np.ones(24) * 1, np.ones(24) * 0, ] ), - "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": ( + "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": ( np.concatenate( [ np.ones(34) * 0, @@ -1232,20 +1207,20 @@ def test_calculate_cost_cvx( ] ) ), - "electric_demand_off-peak_2024-07-10_2024-07-10_0": np.ones(96) * 10, + "electric_demand_offpeak_2024-07-10_2024-07-10_0": np.ones(96) * 10, }, {ELECTRIC: np.arange(96), GAS: np.arange(96)}, "15m", { - "electric_demand_peak-summer_2024-07-10_2024-07-10_0": { + "electric_demand_peaksummer_2024-07-10_2024-07-10_0": { "demand": 0, "cost": 0, }, - "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": { + "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": { "demand": 0, "cost": 0, }, - "electric_demand_off-peak_2024-07-10_2024-07-10_0": { + "electric_demand_offpeak_2024-07-10_2024-07-10_0": { "demand": 0, "cost": 0, }, @@ -1294,14 +1269,14 @@ def test_calculate_cost_cvx( # energy charge with charge limit and time-varying consumption estimate ( { - "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -1321,14 +1296,14 @@ def test_calculate_cost_cvx( # energy charge with charge limit and dictionary consumption estimate ( { - "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -1348,14 +1323,14 @@ def test_calculate_cost_cvx( # energy charge with charge limit and dictionary consumption estimate ( { - "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -1375,21 +1350,21 @@ def test_calculate_cost_cvx( # energy charge that won't hit charge limit + time-varying consumption estimate ( { - "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, np.ones(12) * 0.1, ] ), - "electric_energy_all-day_2024-07-10_2024-07-10_100000": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_100000": np.concatenate( [np.ones(96)] ), }, @@ -1444,14 +1419,14 @@ def test_calculate_cost_pyo( # energy charge with charge limit ( { - "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -1467,14 +1442,14 @@ def test_calculate_cost_pyo( # energy charge with charge limit ( { - "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -1600,23 +1575,23 @@ def gas_constraint(m, t): ELECTRIC, {ELECTRIC: np.arange(96), GAS: np.arange(96)}, { - "electric_demand_peak-summer_20240309_20240309_0": { + "electric_demand_peaksummer_20240309_20240309_0": { "demand": 0, "cost": 0, }, - "electric_demand_half-peak-summer_20240309_20240309_0": { + "electric_demand_halfpeaksummer_20240309_20240309_0": { "demand": 0, "cost": 0, }, - "electric_demand_off-peak_20240309_20240309_0": { + "electric_demand_offpeak_20240309_20240309_0": { "demand": 0, "cost": 0, }, - "electric_demand_half-peak-winter1_20240309_20240309_0": { + "electric_demand_halfpeakwinter1_20240309_20240309_0": { "demand": 0, "cost": 0, }, - "electric_demand_half-peak-winter2_20240309_20240309_0": { + "electric_demand_halfpeakwinter2_20240309_20240309_0": { "demand": 0, "cost": 0, }, @@ -1632,23 +1607,23 @@ def gas_constraint(m, t): ELECTRIC, {ELECTRIC: np.arange(96), GAS: np.arange(96)}, { - "electric_demand_peak-summer_20240710_20240710_0": { + "electric_demand_peaksummer_20240710_20240710_0": { "demand": 7.078810759792355, "cost": 150, }, - "electric_demand_half-peak-summer_20240710_20240710_0": { + "electric_demand_halfpeaksummer_20240710_20240710_0": { "demand": 13.605442176870748, "cost": 80, }, - "electric_demand_off-peak_20240710_20240710_0": { + "electric_demand_offpeak_20240710_20240710_0": { "demand": 42.253521126760563, "cost": 900, }, - "electric_demand_half-peak-winter1_20240710_20240710_0": { + "electric_demand_halfpeakwinter1_20240710_20240710_0": { "demand": 0, "cost": 0, }, - "electric_demand_half-peak-winter2_20240710_20240710_0": { + "electric_demand_halfpeakwinter2_20240710_20240710_0": { "demand": 0, "cost": 0, }, @@ -2147,9 +2122,9 @@ def test_detect_charge_periods( "billing_pge.csv", { "percent_change_dict": { - "electric_demand_peak-summer": 2.0, + "electric_demand_peaksummer": 2.0, "electric_energy_0": 3.0, - "electric_demand_all-day": 1.5, + "electric_demand_allday": 1.5, }, }, { @@ -2315,7 +2290,7 @@ def test_detect_charge_periods( "billing_pge.csv", { "percent_change_dict": { - "electric_demand_peak-summer": 2.0, + "electric_demand_peaksummer": 2.0, DEMAND: 3.0, # Conflicts with the exact key above }, }, @@ -2387,14 +2362,14 @@ def test_parametrize_rate_data( if "peak_demand_charge" in expected: peak_demand = variant_data[ (variant_data[TYPE] == costs.DEMAND) - & (variant_data["name"] == "peak-summer") + & (variant_data["name"] == "peaksummer") ] assert np.allclose(peak_demand[CHARGE].values, expected["peak_demand_charge"]) if "half_peak_demand_charge" in expected: half_peak_demand = variant_data[ (variant_data[TYPE] == costs.DEMAND) - & (variant_data["name"] == "half-peak-summer") + & (variant_data["name"] == "halfpeaksummer") ] assert np.allclose( half_peak_demand[CHARGE].values, @@ -2403,7 +2378,7 @@ def test_parametrize_rate_data( if "off_peak_demand_charge" in expected: off_peak_demand = variant_data[ - (variant_data[TYPE] == costs.DEMAND) & (variant_data["name"] == "off-peak") + (variant_data[TYPE] == costs.DEMAND) & (variant_data["name"] == "offpeak") ] assert np.allclose( off_peak_demand[CHARGE].values, expected["off_peak_demand_charge"] @@ -2530,16 +2505,16 @@ def test_parametrize_rate_data( # Test exact charge key use - find any matching charges and verify they're modified if "peak_summer_demand_charge" in expected: - # For peak summer demand: find any demand charge with "peak-summer" in the name + # For peak summer demand: find any demand charge with "peaksummer" in the name peak_summer_demand = variant_data[ (variant_data[TYPE] == costs.DEMAND) - & (variant_data["name"].str.contains("peak-summer", na=False)) + & (variant_data["name"].str.contains("peaksummer", na=False)) ] - assert not peak_summer_demand.empty, "Should find peak-summer demand charges" + assert not peak_summer_demand.empty, "Should find peaksummer demand charges" # Verify at least one charge is scaled (not all zeros) assert np.any( peak_summer_demand[CHARGE] > 0 - ), "Peak-summer demand charges should be non-zero" + ), "peaksummer demand charges should be non-zero" # For energy 0: find any energy charge that might be scaled if "energy_0_charge" in expected: @@ -2591,7 +2566,7 @@ def test_parametrize_rate_data( "variant_name": "double_peak", } ], - "peak-summer", + "peaksummer", { "variant_name": "double_peak", "expected_keys": ["original", "double_peak"], @@ -2623,14 +2598,14 @@ def test_parametrize_rate_data( [ { "percent_change_dict": { - "electric_demand_peak-summer": 2.0, + "electric_demand_peaksummer": 2.0, "electric_energy_0": 3.0, - "electric_demand_all-day": 1.5, + "electric_demand_allday": 1.5, }, "variant_name": "exact_keys", } ], - "electric_demand_peak-summer", + "electric_demand_peaksummer", { "variant_name": "exact_keys", "expected_keys": ["original", "exact_keys"], @@ -3108,14 +3083,14 @@ def test_calculate_itemized_cost_cvx( # energy charge with charge limit ( { - "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, From 695b880aa5b23de17ce8532ac3ca3e24a9d024a6 Mon Sep 17 00:00:00 2001 From: dalyw Date: Mon, 15 Sep 2025 11:38:01 -0700 Subject: [PATCH 10/26] Cleaning up calculate_energy_cost --- eeco/costs.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/eeco/costs.py b/eeco/costs.py index a2d09bf..308f23e 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -806,9 +806,8 @@ def calculate_energy_cost( elif isinstance(consumption_data, (cp.Expression, pyo.Var, pyo.Param)): # For tiered charges, approximate extimated consumption being split evenly + # if we have a finite next_limit OR if limit > 0 # NOTE: this convex approximation breaks global optimality guarantees - # Apply tiered logic if we have a finite next_limit OR if limit > 0 - # (indicating a tiered structure) if not np.isinf(next_limit) or (not np.isinf(limit) and limit > 0): if isinstance(consumption_estimate, (float, int)): consumption_per_timestep = consumption_estimate / n_steps From fcbf53d8bd6db4d3b88133754e17b34102483615 Mon Sep 17 00:00:00 2001 From: dalyw Date: Tue, 16 Sep 2025 21:38:31 -0700 Subject: [PATCH 11/26] Reverting changes to default_varstr_alias_func Adding back get_unique_row_name (renamed to get_charge_name) Removing initialize_decomposed_pyo_vars. The variables self-initialize during solve. Updating test_decompose_consumption_pyo to just check for the presence of objects, not values. (WIP to add value check in test_calculate_cost_pyo for pre-decomposed inputs) Renaming _get_decomposed_var_names to get_decomposed_var_names Adding test cases for extended consumption data format in test_calculate_cost_np and test_calculate_cost_pyo --- eeco/costs.py | 104 ++++++++------ eeco/tests/test_costs.py | 302 ++++++++++++++++++++++---------------- eeco/tests/test_utils.py | 41 ++---- eeco/utils.py | 304 +++++++++++++-------------------------- 4 files changed, 353 insertions(+), 398 deletions(-) diff --git a/eeco/costs.py b/eeco/costs.py index 308f23e..df67fa9 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -50,6 +50,39 @@ OFF_PEAK = "off_peak" +def get_charge_name(charge, index=None): + """ + Get a unique row name for each row of charge df. + + Parameters + ---------- + charge : dict or pandas.Series + The charge row data containing NAME and PERIOD fields + index : int, optional + Index to use if name is empty or None + + Returns + ------- + str + A unique name with underscores converted to dashes + """ + try: + name = charge[NAME] + except KeyError: + name = charge[PERIOD] + warnings.warn( + "Please update billing file to use 'name' column rather than 'period'", + DeprecationWarning, + ) + + # If no name was given, use the index to differentiate + if not (isinstance(name, str) and name != ""): + name = str(index) if index is not None else "" + + # Replace underscores with dashes for unique delimiter + return name.replace("_", "-") + + def create_charge_array(charge, datetime, effective_start_date, effective_end_date): """Creates a single charge array based on the given parameters. @@ -267,11 +300,7 @@ def get_charge_dict(start_dt, end_dt, rate_data, resolution="15m"): limit_charges = effective_charges.loc[charge_limits == limit, :] for i, idx in enumerate(limit_charges.index): charge = limit_charges.loc[idx, :] - - try: - name = charge[NAME] - except KeyError: # backward compatibility to "period" - name = charge.get(PERIOD, None) + name = get_charge_name(charge, i) try: assessed = charge[ASSESSED] @@ -295,7 +324,6 @@ def get_charge_dict(start_dt, end_dt, rate_data, resolution="15m"): start.strftime("%Y%m%d"), end.strftime("%Y%m%d"), str(int(limit)), - i, ) add_to_charge_array(charge_dict, key_str, charge_array) elif charge_type == DEMAND and assessed == "daily": @@ -312,7 +340,6 @@ def get_charge_dict(start_dt, end_dt, rate_data, resolution="15m"): new_start.strftime("%Y%m%d"), new_start.strftime("%Y%m%d"), str(limit), - i, ) add_to_charge_array(charge_dict, key_str, charge_array) else: @@ -326,7 +353,6 @@ def get_charge_dict(start_dt, end_dt, rate_data, resolution="15m"): start.strftime("%Y%m%d"), end.strftime("%Y%m%d"), str(int(limit)), - i, ) add_to_charge_array(charge_dict, key_str, charge_array) return charge_dict @@ -461,7 +487,7 @@ def get_charge_df( def default_varstr_alias_func( - utility, charge_type, name, start_date, end_date, charge_limit, row_index + utility, charge_type, name, start_date, end_date, charge_limit ): """Default function for creating the variable name strings for each charge in the tariff sheet. Can be overwritten in the function call to `calculate_cost` @@ -476,8 +502,7 @@ def default_varstr_alias_func( Name of the `charge_type` ('demand', 'energy', or 'customer') name : str - The name of the period for this charge (e.g., 'all-day' or 'on-peak'). - If empty or None, will use row_index for uniqueness. + The name of the period for this charge (e.g., 'all-day' or 'on-peak') start_date The inclusive start date for this charge @@ -488,24 +513,13 @@ def default_varstr_alias_func( charge_limit : str The consumption limit for this tier of charges converted to a string - row_index : int - Row index to use if name is empty or None - Returns ------- str Variable name of the form `utility`_`charge_type`_`name`_`start_date`_`end_date`_`charge_limit` """ - # Handle NaN values and None - float NaN values from df - # TODO: check why they are float - if name is None or (isinstance(name, float) and str(name).lower() == "nan"): - name = str(row_index) - name = str(name).replace("_", "") - name = str(name).replace("-", "") - - key = f"{utility}_{charge_type}_{name}_{start_date}_{end_date}_{charge_limit}" - return ut.sanitize_varstr(key) + return f"{utility}_{charge_type}_{name}_{start_date}_{end_date}_{charge_limit}" def get_next_limit(key_substr, current_limit, keys): @@ -619,10 +633,10 @@ def calculate_demand_cost( "Pass in only positive values or " "run calculate_cost with a decomposition_type" ) - if (ut.max(consumption_data)[0] >= limit) or ( + if (np.max(consumption_data) >= limit) or ( (prev_demand >= limit) and (prev_demand <= next_limit) ): - if ut.max(consumption_data)[0] >= next_limit: + if np.max(consumption_data) >= next_limit: demand_charged, model = ut.multiply(next_limit - limit, charge_array) else: demand_charged, model = ut.multiply( @@ -683,6 +697,7 @@ def const_rule(model, t): "consumption_data must be of type numpy.ndarray, " "cvxpy.Expression, or pyomo.environ.Var" ) + if model is None: max_var, _ = ut.max(demand_charged) max_pos_val, max_pos_model = ut.max_pos(max_var - prev_demand_cost) @@ -996,7 +1011,7 @@ def get_converted_consumption_data( # Decompose consumption data into positive and negative components # with constraint that total = positive - negative # (where negative is stored as positive magnitude) - pos_name, neg_name = ut._get_decomposed_var_names(utility) + pos_name, neg_name = ut.get_decomposed_var_names(utility) imports, exports, model = ut.decompose_consumption( converted_consumption, model=model, @@ -1208,8 +1223,13 @@ def calculate_cost( consumption_data_dict, conversion_factors, decomposition_type, model ) - for varstr, charge_array in charge_dict.items(): - utility, charge_type, name, eff_start, eff_end, limit_str = varstr.split("_") + for key, charge_array in charge_dict.items(): + utility, charge_type, name, eff_start, eff_end, limit_str = key.split("_") + varstr = ut.sanitize_varstr( + varstr_alias_func( + utility, charge_type, name, eff_start, eff_end, limit_str + ) # noqa: E501 + ) # if we want itemized costs skip irrelvant portions of the bill if (desired_utility and utility not in desired_utility) or ( @@ -1229,13 +1249,13 @@ def calculate_cost( ) # Only apply demand_scale_factor if charge spans more than one day - charge_duration_days = get_charge_array_duration(varstr) + charge_duration_days = get_charge_array_duration(key) effective_scale_factor = demand_scale_factor if charge_duration_days > 1 else 1 if charge_type == DEMAND: if prev_demand_dict is not None: - prev_demand = prev_demand_dict[varstr][DEMAND] - prev_demand_cost = prev_demand_dict[varstr]["cost"] + prev_demand = prev_demand_dict[key][DEMAND] + prev_demand_cost = prev_demand_dict[key]["cost"] else: prev_demand = 0 prev_demand_cost = 0 @@ -1269,7 +1289,7 @@ def calculate_cost( cost += new_cost elif charge_type == ENERGY: if prev_consumption_dict is not None: - prev_consumption = prev_consumption_dict[varstr] + prev_consumption = prev_consumption_dict[key] else: prev_consumption = 0 @@ -2082,29 +2102,19 @@ def parametrize_rate_data( continue row = variant_data.loc[row_idx] - if has_exact_keys: # Format 2: Exact charge key matching - try: - name = row[NAME] - except KeyError: # for backward compatibility - name = row.get(PERIOD, None) - - if name is None or ( - isinstance(name, float) and str(name).lower() == "nan" - ): - name = str(row_idx) - - name = str(name).replace("_", "").replace("-", "") + if has_exact_keys: # Format 2: Exact charge key matching + row_name = get_charge_name(row, row_idx) for key_prefix, key_ratio in charge_ratios.items(): if key_prefix.startswith( - f"{row[UTILITY]}_{row[TYPE]}_{name}" + f"{row[UTILITY]}_{row[TYPE]}_{row_name}" ): ratio = key_ratio # Remove dashes in the df when key is found - variant_data.loc[row_idx, NAME] = name + variant_data.loc[row_idx, NAME] = row_name break else: - missing_keys.add(f"{row[UTILITY]}_{row[TYPE]}_{name}") + missing_keys.add(f"{row[UTILITY]}_{row[TYPE]}_{row_name}") elif charge_ratios: # Format 1: Period-based approach ratio = charge_ratios[period] diff --git a/eeco/tests/test_costs.py b/eeco/tests/test_costs.py index eaed668..748ec03 100644 --- a/eeco/tests/test_costs.py +++ b/eeco/tests/test_costs.py @@ -53,17 +53,26 @@ def solve_cvx_problem(objective, constraints): def setup_pyo_vars_constraints(consumption_data_dict): """Helper function to set up Pyomo model, variables and constraints.""" model = pyo.ConcreteModel() - model.T = len(consumption_data_dict[ELECTRIC]) + + if isinstance(consumption_data_dict[ELECTRIC], dict): + # Extended format + electric_data = consumption_data_dict[ELECTRIC]["imports"] + gas_data = consumption_data_dict[GAS]["imports"] + else: + electric_data = consumption_data_dict[ELECTRIC] + gas_data = consumption_data_dict[GAS] + + model.T = len(electric_data) model.t = pyo.RangeSet(0, model.T - 1) model.electric_consumption = pyo.Var(model.t, bounds=(None, None)) model.gas_consumption = pyo.Var(model.t, bounds=(None, None)) # Constrain variables to initialized values def electric_constraint_rule(model, t): - return model.electric_consumption[t] == consumption_data_dict[ELECTRIC][t - 1] + return model.electric_consumption[t] == electric_data[t - 1] def gas_constraint_rule(model, t): - return model.gas_consumption[t] == consumption_data_dict[GAS][t - 1] + return model.gas_consumption[t] == gas_data[t - 1] model.electric_constraint = pyo.Constraint(model.t, rule=electric_constraint_rule) model.gas_constraint = pyo.Constraint(model.t, rule=gas_constraint_rule) @@ -84,12 +93,6 @@ def solve_pyo_problem( consumption_data_dict=None, ): """Helper function to solve Pyomo optimization problem.""" - - # Initialize decomposed variables if needed - # TODO: check if always needed - if decomposition_type is not None and consumption_data_dict is not None: - utils.initialize_decomposed_pyo_vars(consumption_data_dict, model, charge_dict) - model.obj = pyo.Objective(expr=objective) if decomposition_type is not None: # Nonlinear when decomposition_type used @@ -278,14 +281,14 @@ def test_create_charge_array( input_dir + "billing_energy_peak.csv", "15m", { - "electric_energy_offpeak_20240710_20240710_0": np.concatenate( + "electric_energy_off-peak_20240710_20240710_0": np.concatenate( [ np.ones(64) * 0.05, np.zeros(20), np.ones(12) * 0.05, ] ), - "electric_energy_onpeak_20240710_20240710_0": np.concatenate( + "electric_energy_on-peak_20240710_20240710_0": np.concatenate( [ np.zeros(64), np.ones(20) * 0.1, @@ -302,7 +305,7 @@ def test_create_charge_array( input_dir + "billing_energy_combine.csv", "15m", { - "electric_energy_allday_20240710_20240710_0": np.concatenate( + "electric_energy_all-day_20240710_20240710_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, @@ -311,15 +314,15 @@ def test_create_charge_array( ), }, ), - # 2 demand charges, allday and on-peak + # 2 demand charges, all-day and on-peak ( np.datetime64("2024-07-10"), # Summer weekdays np.datetime64("2024-07-11"), # Summer weekdays input_dir + "billing_demand_2.csv", "15m", { - "electric_demand_allday_20240710_20240710_0": np.ones(96) * 5, - "electric_demand_onpeak_20240710_20240710_0": np.concatenate( + "electric_demand_all-day_20240710_20240710_0": np.ones(96) * 5, + "electric_demand_on-peak_20240710_20240710_0": np.concatenate( [ np.zeros(64), np.ones(20) * 20, @@ -335,8 +338,8 @@ def test_create_charge_array( input_dir + "billing_demand_monthly.csv", "15m", { - "electric_demand_allday_20240710_20240710_0": np.ones(96) * 5, - "electric_demand_onpeak_20240710_20240710_0": np.concatenate( + "electric_demand_all-day_20240710_20240710_0": np.ones(96) * 5, + "electric_demand_on-peak_20240710_20240710_0": np.concatenate( [ np.zeros(64), np.ones(20) * 20, @@ -353,8 +356,8 @@ def test_create_charge_array( input_dir + "billing_demand_daily.csv", "15m", { - "electric_demand_allday_20240710_20240710_0": np.ones(96) * 5, - "electric_demand_onpeak_20240710_20240710_0": np.concatenate( + "electric_demand_all-day_20240710_20240710_0": np.ones(96) * 5, + "electric_demand_on-peak_20240710_20240710_0": np.concatenate( [ np.zeros(64), np.ones(20) * 20, @@ -371,15 +374,15 @@ def test_create_charge_array( input_dir + "billing_demand_daily.csv", "15m", { - "electric_demand_allday_20240710_20240711_0": np.ones(192) * 5, - "electric_demand_onpeak_20240710_20240710_0": np.concatenate( + "electric_demand_all-day_20240710_20240711_0": np.ones(192) * 5, + "electric_demand_on-peak_20240710_20240710_0": np.concatenate( [ np.zeros(64), np.ones(20) * 20, np.zeros(108), ] ), - "electric_demand_onpeak_20240711_20240711_0": np.concatenate( + "electric_demand_on-peak_20240711_20240711_0": np.concatenate( [ np.zeros(160), np.ones(20) * 20, @@ -549,14 +552,14 @@ def test_create_charge_array( input_dir + "billing_energy_combine_charge_limit.csv", "15m", { - "electric_energy_allday_20240710_20240710_0": np.concatenate( + "electric_energy_all-day_20240710_20240710_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_allday_20240710_20240710_100": np.concatenate( + "electric_energy_all-day_20240710_20240710_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -565,23 +568,23 @@ def test_create_charge_array( ), }, ), - # 2 demand charges with 100 kW charge limits, allday and on-peak + # 2 demand charges with 100 kW charge limits, all-day and on-peak ( np.datetime64("2024-07-10"), # Summer weekdays np.datetime64("2024-07-11"), # Summer weekdays input_dir + "billing_demand_2_charge_limit.csv", "15m", { - "electric_demand_allday_20240710_20240710_0": np.ones(96) * 5, - "electric_demand_allday_20240710_20240710_100": np.ones(96) * 10, - "electric_demand_onpeak_20240710_20240710_0": np.concatenate( + "electric_demand_all-day_20240710_20240710_0": np.ones(96) * 5, + "electric_demand_all-day_20240710_20240710_100": np.ones(96) * 10, + "electric_demand_on-peak_20240710_20240710_0": np.concatenate( [ np.zeros(64), np.ones(20) * 20, np.zeros(12), ] ), - "electric_demand_onpeak_20240710_20240710_100": np.concatenate( + "electric_demand_on-peak_20240710_20240710_100": np.concatenate( [ np.zeros(64), np.ones(20) * 30, @@ -635,14 +638,14 @@ def test_get_charge_dict(start_dt, end_dt, billing_path, resolution, expected): # energy charge with charge limit ( { - "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -694,6 +697,31 @@ def test_get_charge_dict(start_dt, end_dt, billing_path, resolution, expected): False, True, ), + # extended format with pre-decomposed variables (imports/exports) + ( + { + "electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05, + "electric_export_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.025, + }, + { + ELECTRIC: { + "imports": np.ones(96) * 10, + "exports": np.ones(96) * 5, + }, + GAS: { + "imports": np.ones(96) * 2, + "exports": np.zeros(96), + }, + }, + "15m", + None, + 0, + None, + None, + pytest.approx(9.0), + False, + False, + ), ], ) def test_calculate_cost_np( @@ -754,7 +782,7 @@ def test_calculate_cost_np( # demand charge with previous consumption ( { - "electric_demand_peaksummer_2024-07-10_2024-07-10_0": ( + "electric_demand_peak-summer_2024-07-10_2024-07-10_0": ( np.concatenate( [ np.ones(48) * 0, @@ -763,7 +791,7 @@ def test_calculate_cost_np( ] ) ), - "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": ( + "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": ( np.concatenate( [ np.ones(34) * 0, @@ -774,20 +802,20 @@ def test_calculate_cost_np( ] ) ), - "electric_demand_offpeak_2024-07-10_2024-07-10_0": np.ones(96) * 10, + "electric_demand_off-peak_2024-07-10_2024-07-10_0": np.ones(96) * 10, }, {ELECTRIC: np.arange(96), GAS: np.arange(96)}, "15m", { - "electric_demand_peaksummer_2024-07-10_2024-07-10_0": { + "electric_demand_peak-summer_2024-07-10_2024-07-10_0": { "demand": 150, "cost": 150, }, - "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": { + "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": { "demand": 40, "cost": 80, }, - "electric_demand_offpeak_2024-07-10_2024-07-10_0": { + "electric_demand_off-peak_2024-07-10_2024-07-10_0": { "demand": 90, "cost": 900, }, @@ -800,14 +828,14 @@ def test_calculate_cost_np( # demand charge with no previous consumption ( { - "electric_demand_peaksummer_2024-07-10_2024-07-10_0": np.concatenate( + "electric_demand_peak-summer_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(48) * 0, np.ones(24) * 1, np.ones(24) * 0, ] ), - "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": ( + "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": ( np.concatenate( [ np.ones(34) * 0, @@ -818,20 +846,20 @@ def test_calculate_cost_np( ] ) ), - "electric_demand_offpeak_2024-07-10_2024-07-10_0": np.ones(96) * 10, + "electric_demand_off-peak_2024-07-10_2024-07-10_0": np.ones(96) * 10, }, {ELECTRIC: np.arange(96), GAS: np.arange(96)}, "15m", { - "electric_demand_peaksummer_2024-07-10_2024-07-10_0": { + "electric_demand_peak-summer_2024-07-10_2024-07-10_0": { "demand": 0, "cost": 0, }, - "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": { + "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": { "demand": 0, "cost": 0, }, - "electric_demand_offpeak_2024-07-10_2024-07-10_0": { + "electric_demand_off-peak_2024-07-10_2024-07-10_0": { "demand": 0, "cost": 0, }, @@ -844,14 +872,14 @@ def test_calculate_cost_np( # demand charge with consumption estimate as an array ( { - "electric_demand_peaksummer_2024-07-10_2024-07-10_0": np.concatenate( + "electric_demand_peak-summer_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(48) * 0, np.ones(24) * 1, np.ones(24) * 0, ] ), - "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": ( + "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": ( np.concatenate( [ np.ones(34) * 0, @@ -862,20 +890,20 @@ def test_calculate_cost_np( ] ) ), - "electric_demand_offpeak_2024-07-10_2024-07-10_0": np.ones(96) * 10, + "electric_demand_off-peak_2024-07-10_2024-07-10_0": np.ones(96) * 10, }, {ELECTRIC: np.arange(96), GAS: np.arange(96)}, "15m", { - "electric_demand_peaksummer_2024-07-10_2024-07-10_0": { + "electric_demand_peak-summer_2024-07-10_2024-07-10_0": { "demand": 150, "cost": 150, }, - "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": { + "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": { "demand": 40, "cost": 80, }, - "electric_demand_offpeak_2024-07-10_2024-07-10_0": { + "electric_demand_off-peak_2024-07-10_2024-07-10_0": { "demand": 90, "cost": 900, }, @@ -888,14 +916,14 @@ def test_calculate_cost_np( # demand charge with consumption estimate as an array and a charge tier ( { - "electric_demand_peaksummer_2024-07-10_2024-07-10_0": np.concatenate( + "electric_demand_peak-summer_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(48) * 0, np.ones(24) * 1, np.ones(24) * 0, ] ), - "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": ( + "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": ( np.concatenate( [ np.ones(34) * 0, @@ -906,25 +934,25 @@ def test_calculate_cost_np( ] ) ), - "electric_demand_offpeak_2024-07-10_2024-07-10_0": np.ones(96) * 10, - "electric_demand_offpeak_2024-07-10_2024-07-10_90": np.ones(96) * 5, + "electric_demand_off-peak_2024-07-10_2024-07-10_0": np.ones(96) * 10, + "electric_demand_off-peak_2024-07-10_2024-07-10_90": np.ones(96) * 5, }, {ELECTRIC: np.arange(96), GAS: np.arange(96)}, "15m", { - "electric_demand_peaksummer_2024-07-10_2024-07-10_0": { + "electric_demand_peak-summer_2024-07-10_2024-07-10_0": { "demand": 150, "cost": 150, }, - "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": { + "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": { "demand": 40, "cost": 80, }, - "electric_demand_offpeak_2024-07-10_2024-07-10_0": { + "electric_demand_off-peak_2024-07-10_2024-07-10_0": { "demand": 90, "cost": 900, }, - "electric_demand_offpeak_2024-07-10_2024-07-10_90": { + "electric_demand_off-peak_2024-07-10_2024-07-10_90": { "demand": 0, "cost": 0, }, @@ -937,14 +965,14 @@ def test_calculate_cost_np( # energy charge with charge limit ( { - "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -963,14 +991,14 @@ def test_calculate_cost_np( # energy charge with charge limit and time-varying consumption estimate ( { - "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -989,14 +1017,14 @@ def test_calculate_cost_np( # energy charge with charge limit and dictionary consumption estimate ( { - "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -1015,14 +1043,14 @@ def test_calculate_cost_np( # energy charge with charge limit and dictionary consumption estimate ( { - "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -1041,21 +1069,21 @@ def test_calculate_cost_np( # energy charge that won't hit charge limit + time-varying consumption estimate ( { - "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, np.ones(12) * 0.1, ] ), - "electric_energy_allday_2024-07-10_2024-07-10_100000": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_100000": np.concatenate( [np.ones(96)] ), }, @@ -1115,14 +1143,14 @@ def test_calculate_cost_cvx( # energy charge with charge limit ( { - "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -1142,7 +1170,7 @@ def test_calculate_cost_cvx( # demand charge with previous consumption ( { - "electric_demand_peaksummer_2024-07-10_2024-07-10_0": ( + "electric_demand_peak-summer_2024-07-10_2024-07-10_0": ( np.concatenate( [ np.ones(48) * 0, @@ -1151,7 +1179,7 @@ def test_calculate_cost_cvx( ] ) ), - "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": ( + "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": ( np.concatenate( [ np.ones(34) * 0, @@ -1162,20 +1190,20 @@ def test_calculate_cost_cvx( ] ) ), - "electric_demand_offpeak_2024-07-10_2024-07-10_0": np.ones(96) * 10, + "electric_demand_off-peak_2024-07-10_2024-07-10_0": np.ones(96) * 10, }, {ELECTRIC: np.arange(96), GAS: np.arange(96)}, "15m", { - "electric_demand_peaksummer_2024-07-10_2024-07-10_0": { + "electric_demand_peak-summer_2024-07-10_2024-07-10_0": { "demand": 150, "cost": 150, }, - "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": { + "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": { "demand": 40, "cost": 80, }, - "electric_demand_offpeak_2024-07-10_2024-07-10_0": { + "electric_demand_off-peak_2024-07-10_2024-07-10_0": { "demand": 90, "cost": 900, }, @@ -1189,14 +1217,14 @@ def test_calculate_cost_cvx( # demand charge with no previous consumption ( { - "electric_demand_peaksummer_2024-07-10_2024-07-10_0": np.concatenate( + "electric_demand_peak-summer_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(48) * 0, np.ones(24) * 1, np.ones(24) * 0, ] ), - "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": ( + "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": ( np.concatenate( [ np.ones(34) * 0, @@ -1207,20 +1235,20 @@ def test_calculate_cost_cvx( ] ) ), - "electric_demand_offpeak_2024-07-10_2024-07-10_0": np.ones(96) * 10, + "electric_demand_off-peak_2024-07-10_2024-07-10_0": np.ones(96) * 10, }, {ELECTRIC: np.arange(96), GAS: np.arange(96)}, "15m", { - "electric_demand_peaksummer_2024-07-10_2024-07-10_0": { + "electric_demand_peak-summer_2024-07-10_2024-07-10_0": { "demand": 0, "cost": 0, }, - "electric_demand_halfpeaksummer_2024-07-10_2024-07-10_0": { + "electric_demand_half-peak-summer_2024-07-10_2024-07-10_0": { "demand": 0, "cost": 0, }, - "electric_demand_offpeak_2024-07-10_2024-07-10_0": { + "electric_demand_off-peak_2024-07-10_2024-07-10_0": { "demand": 0, "cost": 0, }, @@ -1269,14 +1297,14 @@ def test_calculate_cost_cvx( # energy charge with charge limit and time-varying consumption estimate ( { - "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -1296,14 +1324,14 @@ def test_calculate_cost_cvx( # energy charge with charge limit and dictionary consumption estimate ( { - "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -1323,14 +1351,14 @@ def test_calculate_cost_cvx( # energy charge with charge limit and dictionary consumption estimate ( { - "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -1350,21 +1378,21 @@ def test_calculate_cost_cvx( # energy charge that won't hit charge limit + time-varying consumption estimate ( { - "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, np.ones(12) * 0.1, ] ), - "electric_energy_allday_2024-07-10_2024-07-10_100000": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_100000": np.concatenate( [np.ones(96)] ), }, @@ -1377,6 +1405,30 @@ def test_calculate_cost_cvx( None, 260, ), + # extended format with pre-decomposed variables (imports/exports) + ( + { + "electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05, + "electric_export_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.025, + }, + { + ELECTRIC: { + "imports": np.ones(96) * 10, + "exports": np.ones(96) * 5, + }, + GAS: { + "imports": np.ones(96) * 2, + "exports": np.zeros(96), + }, + }, + "15m", + None, + 0, + None, + None, + None, + pytest.approx(9.0), + ), ], ) def test_calculate_cost_pyo( @@ -1392,9 +1444,15 @@ def test_calculate_cost_pyo( ): model, pyo_vars = setup_pyo_vars_constraints(consumption_data_dict) + if isinstance(consumption_data_dict[ELECTRIC], dict): + # Extended format: pass full consumption data + consumption_input = consumption_data_dict + else: + consumption_input = pyo_vars + result, model = costs.calculate_cost( charge_dict, - pyo_vars, + consumption_input, resolution=resolution, prev_demand_dict=prev_demand_dict, consumption_estimate=consumption_estimate, @@ -1419,14 +1477,14 @@ def test_calculate_cost_pyo( # energy charge with charge limit ( { - "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -1442,14 +1500,14 @@ def test_calculate_cost_pyo( # energy charge with charge limit ( { - "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, @@ -1575,23 +1633,23 @@ def gas_constraint(m, t): ELECTRIC, {ELECTRIC: np.arange(96), GAS: np.arange(96)}, { - "electric_demand_peaksummer_20240309_20240309_0": { + "electric_demand_peak-summer_20240309_20240309_0": { "demand": 0, "cost": 0, }, - "electric_demand_halfpeaksummer_20240309_20240309_0": { + "electric_demand_half-peak-summer_20240309_20240309_0": { "demand": 0, "cost": 0, }, - "electric_demand_offpeak_20240309_20240309_0": { + "electric_demand_off-peak_20240309_20240309_0": { "demand": 0, "cost": 0, }, - "electric_demand_halfpeakwinter1_20240309_20240309_0": { + "electric_demand_half-peak-winter1_20240309_20240309_0": { "demand": 0, "cost": 0, }, - "electric_demand_halfpeakwinter2_20240309_20240309_0": { + "electric_demand_half-peak-winter2_20240309_20240309_0": { "demand": 0, "cost": 0, }, @@ -1607,23 +1665,23 @@ def gas_constraint(m, t): ELECTRIC, {ELECTRIC: np.arange(96), GAS: np.arange(96)}, { - "electric_demand_peaksummer_20240710_20240710_0": { + "electric_demand_peak-summer_20240710_20240710_0": { "demand": 7.078810759792355, "cost": 150, }, - "electric_demand_halfpeaksummer_20240710_20240710_0": { + "electric_demand_half-peak-summer_20240710_20240710_0": { "demand": 13.605442176870748, "cost": 80, }, - "electric_demand_offpeak_20240710_20240710_0": { + "electric_demand_off-peak_20240710_20240710_0": { "demand": 42.253521126760563, "cost": 900, }, - "electric_demand_halfpeakwinter1_20240710_20240710_0": { + "electric_demand_half-peak-winter1_20240710_20240710_0": { "demand": 0, "cost": 0, }, - "electric_demand_halfpeakwinter2_20240710_20240710_0": { + "electric_demand_half-peak-winter2_20240710_20240710_0": { "demand": 0, "cost": 0, }, @@ -2122,9 +2180,9 @@ def test_detect_charge_periods( "billing_pge.csv", { "percent_change_dict": { - "electric_demand_peaksummer": 2.0, + "electric_demand_peak-summer": 2.0, "electric_energy_0": 3.0, - "electric_demand_allday": 1.5, + "electric_demand_all-day": 1.5, }, }, { @@ -2290,7 +2348,7 @@ def test_detect_charge_periods( "billing_pge.csv", { "percent_change_dict": { - "electric_demand_peaksummer": 2.0, + "electric_demand_peak-summer": 2.0, DEMAND: 3.0, # Conflicts with the exact key above }, }, @@ -2362,14 +2420,14 @@ def test_parametrize_rate_data( if "peak_demand_charge" in expected: peak_demand = variant_data[ (variant_data[TYPE] == costs.DEMAND) - & (variant_data["name"] == "peaksummer") + & (variant_data["name"] == "peak-summer") ] assert np.allclose(peak_demand[CHARGE].values, expected["peak_demand_charge"]) if "half_peak_demand_charge" in expected: half_peak_demand = variant_data[ (variant_data[TYPE] == costs.DEMAND) - & (variant_data["name"] == "halfpeaksummer") + & (variant_data["name"] == "half-peak-summer") ] assert np.allclose( half_peak_demand[CHARGE].values, @@ -2378,7 +2436,7 @@ def test_parametrize_rate_data( if "off_peak_demand_charge" in expected: off_peak_demand = variant_data[ - (variant_data[TYPE] == costs.DEMAND) & (variant_data["name"] == "offpeak") + (variant_data[TYPE] == costs.DEMAND) & (variant_data["name"] == "off-peak") ] assert np.allclose( off_peak_demand[CHARGE].values, expected["off_peak_demand_charge"] @@ -2505,16 +2563,16 @@ def test_parametrize_rate_data( # Test exact charge key use - find any matching charges and verify they're modified if "peak_summer_demand_charge" in expected: - # For peak summer demand: find any demand charge with "peaksummer" in the name + # For peak summer demand: find any demand charge with "peak-summer" in the name peak_summer_demand = variant_data[ (variant_data[TYPE] == costs.DEMAND) - & (variant_data["name"].str.contains("peaksummer", na=False)) + & (variant_data["name"].str.contains("peak-summer", na=False)) ] - assert not peak_summer_demand.empty, "Should find peaksummer demand charges" + assert not peak_summer_demand.empty, "Should find peak-summer demand charges" # Verify at least one charge is scaled (not all zeros) assert np.any( peak_summer_demand[CHARGE] > 0 - ), "peaksummer demand charges should be non-zero" + ), "peak-summer demand charges should be non-zero" # For energy 0: find any energy charge that might be scaled if "energy_0_charge" in expected: @@ -2566,7 +2624,7 @@ def test_parametrize_rate_data( "variant_name": "double_peak", } ], - "peaksummer", + "peak-summer", { "variant_name": "double_peak", "expected_keys": ["original", "double_peak"], @@ -2598,14 +2656,14 @@ def test_parametrize_rate_data( [ { "percent_change_dict": { - "electric_demand_peaksummer": 2.0, + "electric_demand_peak-summer": 2.0, "electric_energy_0": 3.0, - "electric_demand_allday": 1.5, + "electric_demand_all-day": 1.5, }, "variant_name": "exact_keys", } ], - "electric_demand_peaksummer", + "electric_demand_peak-summer", { "variant_name": "exact_keys", "expected_keys": ["original", "exact_keys"], @@ -3083,14 +3141,14 @@ def test_calculate_itemized_cost_cvx( # energy charge with charge limit ( { - "electric_energy_allday_2024-07-10_2024-07-10_0": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_0": np.concatenate( [ np.ones(64) * 0.05, np.ones(20) * 0.1, np.ones(12) * 0.05, ] ), - "electric_energy_allday_2024-07-10_2024-07-10_100": np.concatenate( + "electric_energy_all-day_2024-07-10_2024-07-10_100": np.concatenate( [ np.ones(64) * 0.1, np.ones(20) * 0.15, diff --git a/eeco/tests/test_utils.py b/eeco/tests/test_utils.py index 6b84447..07f4cf1 100644 --- a/eeco/tests/test_utils.py +++ b/eeco/tests/test_utils.py @@ -5,6 +5,7 @@ import cvxpy as cp from eeco import utils as ut +from eeco.tests.test_costs import setup_pyo_vars_constraints os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) skip_all_tests = False @@ -226,34 +227,20 @@ def test_decompose_consumption_cvx(): def test_decompose_consumption_pyo( consumption_data, expected_positive_sum, expected_negative_sum ): - """Test decompose_consumption with pyomo variables.""" - model = pyo.ConcreteModel() - model.T = len(consumption_data) - model.t = range(1, model.T + 1) # Pyomo uses 1-indexed - - model.electric_consumption = pyo.Var(model.t, initialize=0) - for t in model.t: - model.electric_consumption[t].value = consumption_data[t - 1] - - positive_var, negative_var, model = ut.decompose_consumption( - model.electric_consumption, model=model, varstr="electric" - ) - - init_consumption_data = { + consumption_data_dict = { "electric": consumption_data, + "gas": np.zeros_like(consumption_data), } - ut.initialize_decomposed_pyo_vars(init_consumption_data, model, None) - - # Verify expected sums - assert sum(pyo.value(positive_var[t]) for t in model.t) == pytest.approx( - expected_positive_sum - ) - assert sum(pyo.value(negative_var[t]) for t in model.t) == pytest.approx( - expected_negative_sum + model, pyo_vars = setup_pyo_vars_constraints(consumption_data_dict) + positive_var, negative_var, model = ut.decompose_consumption( + pyo_vars["electric"], model=model, varstr="electric" ) - # Verify decomposition constraint - for t in model.t: - assert pyo.value(model.electric_consumption[t]) == pytest.approx( - pyo.value(positive_var[t]) - pyo.value(negative_var[t]) - ) + # Check that variables exist and have the correct length + assert hasattr(model, "electric_positive") + assert hasattr(model, "electric_negative") + assert hasattr(model, "electric_decomposition_constraint") + assert hasattr(model, "electric_magnitude_constraint") + assert len(positive_var) == len(consumption_data) + assert len(negative_var) == len(consumption_data) + # Testing of values handled after solving problem in test_costs.py diff --git a/eeco/utils.py b/eeco/utils.py index cf0af71..b92f5f3 100644 --- a/eeco/utils.py +++ b/eeco/utils.py @@ -312,7 +312,6 @@ def const_rule(model): elif isinstance(expression, (IndexedExpression, pyo.Param, pyo.Var)): # Check if expression is indexed if hasattr(expression, "index_set"): - # if hasattr(expression, "is_indexed") and expression.is_indexed(): # Create indexed max_pos variable model.add_component( varstr, pyo.Var(expression.index_set(), bounds=(0, None)) @@ -341,207 +340,7 @@ def const_rule(model): ): return (np.max(expression), model) if np.max(expression) > 0 else (0, model) elif isinstance(expression, cp.Expression): - # Check if expression is indexed - if expression.shape == (): # Scalar - return cp.maximum(expression, 0), None - else: # Vector - return cp.maximum(expression, 0), None - else: - raise TypeError( - "Only CVXPY or Pyomo variables and NumPy arrays are currently supported." - ) - - -def _get_decomposed_var_names(utility): - """Get consistent variable names for decomposed consumption variables.""" - return f"{utility}_positive", f"{utility}_negative" - - -def initialize_decomposed_pyo_vars(consumption_data_dict, model, charge_dict): - """Helper function to initialize Pyomo variables with baseline consumption values. - - This function takes consumption data as numpy arrays, decomposes them using - the numpy version of decompose_consumption, and then initializes the corresponding - Pyomo variables with those values. - - Parameters - ---------- - consumption_data_dict : dict - Dictionary with keys "electric" and "gas" containing numpy arrays - of consumption data. - - model : pyomo.environ.Model - The Pyomo model containing the variables to initialize. - - charge_dict : dict - Dictionary containing charge arrays for different utilities and charge types. - Used to extract the correct charge rate for export calculations. - - Returns - ------- - dict - Dictionary with initialized consumption objects for each utility. - """ - consumption_object_dict = {} - - # Initialize the basic consumption variables and converted variables - for utility in consumption_data_dict.keys(): - consumption_data = consumption_data_dict[utility] - consumption_var = model.find_component(f"{utility}_consumption") - if consumption_var is not None: - for t in model.t: - consumption_var[t].value = consumption_data[t - 1] # Pyomo 1-indexed - converted_var = model.find_component(f"{utility}_converted") - if converted_var is not None: - for t in model.t: - converted_var[t].value = consumption_data[t - 1] - - consumption_object_dict[utility] = {} - consumption_data = consumption_data_dict[utility] - - # Decompose using numpy version - positive_values, negative_values, _ = decompose_consumption(consumption_data) - - # Find and initialize the corresponding Pyomo variables - pos_name, neg_name = _get_decomposed_var_names(utility) - positive_var = model.find_component(pos_name) - negative_var = model.find_component(neg_name) - - for t in model.t: - positive_var[t].value = positive_values[t - 1] - negative_var[t].value = negative_values[t - 1] - - consumption_object_dict[utility]["imports"] = positive_var - consumption_object_dict[utility]["exports"] = negative_var - - # # Initialize export-related variables created by calculate_export_revenue - for component_name in model.component_map(): - if "_multiply" in component_name: - component = model.find_component(component_name) - if hasattr(component, "__iter__") and hasattr( - component[list(component.keys())[0]], "value" - ): - for i in component: - export_var = model.find_component("electric_negative") - if export_var is not None and hasattr(export_var[i], "value"): - charge_rate = 0.0 # Default export - if charge_dict is not None: - export_keys = [ - key for key in charge_dict.keys() if "export" in key - ] - if export_keys: - charge_rate = charge_dict[export_keys[0]][0] - component[i].value = export_var[i].value * charge_rate - else: - component[i].value = 0.0 - if "_sum" in component_name: - component = model.find_component(component_name) - if hasattr(component, "value"): - multiply_var = model.find_component( - component_name.replace("_sum", "_multiply") - ) - if multiply_var is not None and hasattr(multiply_var, "__iter__"): - total = 0.0 - for i in multiply_var: - if hasattr(multiply_var[i], "value"): - total += multiply_var[i].value - component.value = total - else: - component.value = 0.0 - - return consumption_object_dict - - -def decompose_consumption( - expression, model=None, varstr=None, decomposition_type="absolute_value" -): - """Decomposes consumption data into positive and negative components - And adds constraint such that total consumption equals - positive values minus negative values - (where negative values are stored as positive magnitudes). - - Parameters - ---------- - expression : [ - numpy.Array, - cvxpy.Expression, - pyomo.core.expr.numeric_expr.NumericExpression, - pyomo.core.expr.numeric_expr.NumericNDArray, - pyomo.environ.Param, - pyomo.environ.Var - ] - Expression representing consumption data - - model : pyomo.environ.Model - The model object associated with the problem. - Only used in the case of Pyomo, so `None` by default. - - varstr : str - Name prefix for the variables to be created if using a Pyomo `model` - - decomposition_type : str - Type of decomposition to use. - - "binary_variable": To be implemented - - "absolute_value": Creates nonlinear problem - - Returns - ------- - tuple - (positive_values, negative_values, model) where - positive_values and negative_values are both positive - with the constraint that total = positive - negative - """ - if isinstance(expression, np.ndarray): - if decomposition_type == "absolute_value": - positive_values = np.maximum(expression, 0) - negative_values = np.maximum(-expression, 0) # magnitude as positive - else: - pass - return positive_values, negative_values, model - elif isinstance(expression, cp.Expression): - if decomposition_type == "absolute_value": - positive_values, _ = max_pos(expression) - negative_values, _ = max_pos(-expression) # magnitude as positive - else: - pass - return positive_values, negative_values, model - elif isinstance(expression, (pyo.Var, pyo.Param)): - if decomposition_type == "absolute_value": - - # Use max_pos to create positive_var and negative_var - pos_name, neg_name = _get_decomposed_var_names(varstr) - positive_var, model = max_pos(expression, model, pos_name) - - # Create negative expression since pyomo won't take -expression directly - def negative_rule(model, t): - return -expression[t] - - negative_expr = pyo.Expression(model.t, rule=negative_rule) - model.add_component(f"{varstr}_negative_expr", negative_expr) - negative_var, model = max_pos(negative_expr, model, neg_name) - - # Add constraint to balance import and export decomposed values - def decomposition_rule(model, t): - return expression[t] == positive_var[t] - negative_var[t] - - model.add_component( - f"{varstr}_decomposition_constraint", - pyo.Constraint(model.t, rule=decomposition_rule), - ) - - # Add constraint to ensure positive_var + negative_var = |expression| - # This both variables becoming larger due to artificial arbitrage - def magnitude_rule(model, t): - return positive_var[t] + negative_var[t] == abs(expression[t]) - - model.add_component( - f"{varstr}_magnitude_constraint", - pyo.Constraint(model.t, rule=magnitude_rule), - ) - - return positive_var, negative_var, model - else: - pass + return cp.maximum(expression, 0), None # Works for scalar or vector else: raise TypeError( "Only CVXPY or Pyomo variables and NumPy arrays are currently supported." @@ -658,6 +457,107 @@ def const_rule(model, t): ) +def get_decomposed_var_names(utility): + """Get consistent variable names for decomposed consumption variables.""" + return f"{utility}_positive", f"{utility}_negative" + + +def decompose_consumption( + expression, model=None, varstr=None, decomposition_type="absolute_value" +): + """Decomposes consumption data into positive and negative components + And adds constraint such that total consumption equals + positive values minus negative values + (where negative values are stored as positive magnitudes). + + Parameters + ---------- + expression : [ + numpy.Array, + cvxpy.Expression, + pyomo.core.expr.numeric_expr.NumericExpression, + pyomo.core.expr.numeric_expr.NumericNDArray, + pyomo.environ.Param, + pyomo.environ.Var + ] + Expression representing consumption data + + model : pyomo.environ.Model + The model object associated with the problem. + Only used in the case of Pyomo, so `None` by default. + + varstr : str + Name prefix for the variables to be created if using a Pyomo `model` + + decomposition_type : str + Type of decomposition to use. + - "binary_variable": To be implemented + - "absolute_value": Creates nonlinear problem + + Returns + ------- + tuple + (positive_values, negative_values, model) where + positive_values and negative_values are both positive + with the constraint that total = positive - negative + """ + if isinstance(expression, np.ndarray): + if decomposition_type == "absolute_value": + positive_values = np.maximum(expression, 0) + negative_values = np.maximum(-expression, 0) # magnitude as positive + else: + pass + return positive_values, negative_values, model + elif isinstance(expression, cp.Expression): + if decomposition_type == "absolute_value": + positive_values, _ = max_pos(expression) + negative_values, _ = max_pos(-expression) # magnitude as positive + else: + pass + return positive_values, negative_values, model + elif isinstance(expression, (pyo.Var, pyo.Param)): + if decomposition_type == "absolute_value": + + # Use max_pos to create positive_var and negative_var + pos_name, neg_name = get_decomposed_var_names(varstr) + positive_var, model = max_pos(expression, model, pos_name) + + # Create negative expression since pyomo won't take -expression directly + def negative_rule(model, t): + return -expression[t] + + negative_expr = pyo.Expression(model.t, rule=negative_rule) + model.add_component(f"{varstr}_negative_expr", negative_expr) + negative_var, model = max_pos(negative_expr, model, neg_name) + + # Add constraint to balance import and export decomposed values + def decomposition_rule(model, t): + return expression[t] == positive_var[t] - negative_var[t] + + model.add_component( + f"{varstr}_decomposition_constraint", + pyo.Constraint(model.t, rule=decomposition_rule), + ) + + # Add constraint to ensure positive_var + negative_var = |expression| + # This both variables becoming larger due to artificial arbitrage + def magnitude_rule(model, t): + return positive_var[t] + negative_var[t] == abs(expression[t]) + + model.add_component( + f"{varstr}_magnitude_constraint", + pyo.Constraint(model.t, rule=magnitude_rule), + ) + + return positive_var, negative_var, model + else: + pass + else: + raise TypeError( + "Only CVXPY or Pyomo variables and NumPy arrays are currently supported." + ) + + def parse_freq(freq): """Parses a time frequency code string, returning its type and its freq_binsize From 982dd4352ac5dd8da848ee397f31b2883b358268 Mon Sep 17 00:00:00 2001 From: dalyw Date: Wed, 1 Oct 2025 12:25:43 -0700 Subject: [PATCH 12/26] Adding test calling max_pos on pyo scalar Updating minimum pyomo version to 6.8 --- eeco/tests/test_utils.py | 99 ++++++++++++++++++++++++++++------------ setup.py | 2 +- 2 files changed, 70 insertions(+), 31 deletions(-) diff --git a/eeco/tests/test_utils.py b/eeco/tests/test_utils.py index 07f4cf1..5f56f74 100644 --- a/eeco/tests/test_utils.py +++ b/eeco/tests/test_utils.py @@ -134,41 +134,80 @@ def gas_constraint(m, t): ( {"electric": np.ones(96) * 45, "gas": np.ones(96) * -1}, "electric", - np.ones(96) * 45, + np.ones(96) * 45 + ), + ( + {"electric": np.ones(96) * 100, "gas": np.ones(96) * -1}, + "gas", + np.zeros(96) + ), + ( + {"electric": 45.0, "gas": -10.0}, + "electric", + 45.0 + ), + ( + {"electric": 100.0, "gas": -5.0}, + "gas", + 0.0 ), - ({"electric": np.ones(96) * 100, "gas": np.ones(96) * -1}, "gas", np.zeros(96)), ], ) def test_max_pos_pyo(consumption_data, varstr, expected): model = pyo.ConcreteModel() - model.T = len(consumption_data["electric"]) - model.t = range(model.T) - pyo_vars = {} - for key, val in consumption_data.items(): - var = pyo.Var(model.t, initialize=np.zeros(len(val))) - model.add_component(key, var) - pyo_vars[key] = var - - @model.Constraint(model.t) - def electric_constraint(m, t): - return consumption_data["electric"][t] == m.electric[t] - - @model.Constraint(model.t) - def gas_constraint(m, t): - return consumption_data["gas"][t] == m.gas[t] - - var = getattr(model, varstr) - result, model = ut.max_pos(var, model=model, varstr="test") - model.objective = pyo.Objective(expr=0) - solver = pyo.SolverFactory("gurobi") - solver.solve(model) - - # Check each element in returned vector - for t in result.index_set(): - expected_element = expected[t] - assert pyo.value(result[t]) == expected_element - - # TODO: add scalar test + + if isinstance(consumption_data["electric"], (int, float)): + # Scalar case + pyo_vars = {} + for key, val in consumption_data.items(): + var = pyo.Var(initialize=0) + model.add_component(key, var) + pyo_vars[key] = var + + @model.Constraint() + def electric_constraint(m): + return consumption_data["electric"] == m.electric + + @model.Constraint() + def gas_constraint(m): + return consumption_data["gas"] == m.gas + + var = getattr(model, varstr) + expr = var - 0 # similar to max_var - prev_demand_cost + result, model = ut.max_pos(expr, model=model, varstr="test") + model.objective = pyo.Objective(expr=0) + solver = pyo.SolverFactory("gurobi") + solver.solve(model) + + assert pyo.value(result) == expected + else: + # Vector case + model.T = len(consumption_data["electric"]) + model.t = range(model.T) + pyo_vars = {} + for key, val in consumption_data.items(): + var = pyo.Var(model.t, initialize=np.zeros(len(val))) + model.add_component(key, var) + pyo_vars[key] = var + + @model.Constraint(model.t) + def electric_constraint(m, t): + return consumption_data["electric"][t] == m.electric[t] + + @model.Constraint(model.t) + def gas_constraint(m, t): + return consumption_data["gas"][t] == m.gas[t] + + var = getattr(model, varstr) + result, model = ut.max_pos(var, model=model, varstr="test") + model.objective = pyo.Objective(expr=0) + solver = pyo.SolverFactory("gurobi") + solver.solve(model) + + # Check each element in returned vector + for t in result.index_set(): + expected_element = expected[t] + assert pyo.value(result[t]) == expected_element assert model is not None diff --git a/setup.py b/setup.py index d17e8fd..7320879 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ "pandas>=2.2.1", "numpy>=1.26.4", "cvxpy>=1.3.0", - "pyomo>=6.7", + "pyomo>=6.8", "gurobipy>=11.0", "pint>=0.19.2", ] From 5b3c611208d8288ece0f9fa497c248aca7d52e9d Mon Sep 17 00:00:00 2001 From: dalyw Date: Wed, 1 Oct 2025 12:28:56 -0700 Subject: [PATCH 13/26] Reformatted with black --- eeco/tests/test_utils.py | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/eeco/tests/test_utils.py b/eeco/tests/test_utils.py index 5f56f74..e5bd689 100644 --- a/eeco/tests/test_utils.py +++ b/eeco/tests/test_utils.py @@ -134,28 +134,16 @@ def gas_constraint(m, t): ( {"electric": np.ones(96) * 45, "gas": np.ones(96) * -1}, "electric", - np.ones(96) * 45 - ), - ( - {"electric": np.ones(96) * 100, "gas": np.ones(96) * -1}, - "gas", - np.zeros(96) - ), - ( - {"electric": 45.0, "gas": -10.0}, - "electric", - 45.0 - ), - ( - {"electric": 100.0, "gas": -5.0}, - "gas", - 0.0 + np.ones(96) * 45, ), + ({"electric": np.ones(96) * 100, "gas": np.ones(96) * -1}, "gas", np.zeros(96)), + ({"electric": 45.0, "gas": -10.0}, "electric", 45.0), + ({"electric": 100.0, "gas": -5.0}, "gas", 0.0), ], ) def test_max_pos_pyo(consumption_data, varstr, expected): model = pyo.ConcreteModel() - + if isinstance(consumption_data["electric"], (int, float)): # Scalar case pyo_vars = {} @@ -173,7 +161,7 @@ def gas_constraint(m): return consumption_data["gas"] == m.gas var = getattr(model, varstr) - expr = var - 0 # similar to max_var - prev_demand_cost + expr = var - 0 # similar to max_var - prev_demand_cost result, model = ut.max_pos(expr, model=model, varstr="test") model.objective = pyo.Objective(expr=0) solver = pyo.SolverFactory("gurobi") From 08572c0ab6d4bfd394573905629db39662339081 Mon Sep 17 00:00:00 2001 From: dalyw Date: Wed, 1 Oct 2025 14:54:32 -0700 Subject: [PATCH 14/26] Add comprehensive test coverage for utils and costs - Add scalar LinearExpression tests for max_pos_pyo - Add TypeError tests for invalid types in max_pos and decompose_consumption - Add warning tests for unimplemented decomposition types - Add ValueError test for invalid type in calculate_export_revenue - Add negative values warning test in calculate_demand_costs - Add test for consumption_estimate=None and dict consumption_estimate - Add conversion factor test with MW units in test_calculate_itemized_cost_pyo - Fix idempotency in get_converted_consumption_data to prevent duplicate components - Add warnings for unimplemented decomposition_type in utils.decompose_consumption --- eeco/costs.py | 60 ++++++++------ eeco/tests/test_costs.py | 170 +++++++++++++++++++++++++++++++++------ eeco/tests/test_utils.py | 121 +++++++++++++++++++--------- eeco/utils.py | 56 ++++++------- 4 files changed, 294 insertions(+), 113 deletions(-) diff --git a/eeco/costs.py b/eeco/costs.py index df67fa9..dcb3b8a 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -986,38 +986,52 @@ def get_converted_consumption_data( ): # Apply conversion if conversion factor is nonzero if conversion_factor != 1.0: - consumption_data_dict[utility]["imports"], model = ut.multiply( - consumption_data_dict[utility]["imports"], - conversion_factor, - model=model, - varstr=utility + "_imports_converted", - ) - consumption_data_dict[utility]["exports"], model = ut.multiply( - consumption_data_dict[utility]["exports"], + # Apply conversion if not already done + imports_varstr = utility + "_imports_converted" + if model is None or not hasattr(model, imports_varstr): + consumption_data_dict[utility]["imports"], model = ut.multiply( + consumption_data_dict[utility]["imports"], + conversion_factor, + model=model, + varstr=imports_varstr, + ) + consumption_data_dict[utility]["exports"], model = ut.multiply( + consumption_data_dict[utility]["exports"], + conversion_factor, + model=model, + varstr=utility + "_exports_converted", + ) + continue + else: # create imports/exports + # Convert if not already done + converted_varstr = utility + "_converted" + if model is None or not hasattr(model, converted_varstr): + converted_consumption, model = ut.multiply( + consumption_data_dict[utility], conversion_factor, model=model, - varstr=utility + "_exports_converted", + varstr=converted_varstr, ) - continue - else: # create imports/exports - converted_consumption, model = ut.multiply( - consumption_data_dict[utility], - conversion_factor, - model=model, - varstr=utility + "_converted", - ) + else: + converted_consumption = getattr(model, converted_varstr) if decomposition_type == "absolute_value": # Decompose consumption data into positive and negative components # with constraint that total = positive - negative # (where negative is stored as positive magnitude) pos_name, neg_name = ut.get_decomposed_var_names(utility) - imports, exports, model = ut.decompose_consumption( - converted_consumption, - model=model, - varstr=utility, - decomposition_type="absolute_value", - ) + + # Decompose if not already done + if model is None or not hasattr(model, pos_name): + imports, exports, model = ut.decompose_consumption( + converted_consumption, + model=model, + varstr=utility, + decomposition_type="absolute_value", + ) + else: + imports = getattr(model, pos_name) + exports = getattr(model, neg_name) consumption_data_dict[utility] = { "imports": imports, diff --git a/eeco/tests/test_costs.py b/eeco/tests/test_costs.py index 748ec03..707d5e4 100644 --- a/eeco/tests/test_costs.py +++ b/eeco/tests/test_costs.py @@ -7,6 +7,7 @@ import datetime from eeco import costs +from eeco.units import u from eeco.costs import ( CHARGE, TYPE, @@ -1580,7 +1581,7 @@ def gas_constraint(m, t): @pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") @pytest.mark.parametrize( "start_dt, end_dt, billing_data, utility, consumption_data_dict, " - "prev_demand_dict, consumption_estimate, scale_factor, expected", + "prev_demand_dict, consumption_estimate, scale_factor, expected, expect_warning", [ ( np.datetime64("2024-07-10"), # Summer weekday @@ -1592,6 +1593,7 @@ def gas_constraint(m, t): 0, 1, # default scale factor np.float64(4027.79), + False, ), ( np.datetime64("2024-07-10"), # Summer weekday @@ -1600,9 +1602,22 @@ def gas_constraint(m, t): ELECTRIC, {ELECTRIC: np.arange(96), GAS: np.arange(96)}, None, - 0, + None, # consumption_estimate=None (default to 0) + 1, # default scale factor + np.float64(4027.79), + False, + ), + ( + np.datetime64("2024-07-10"), # Summer weekday + np.datetime64("2024-07-11"), # Summer weekday + input_dir + "billing_pge.csv", + ELECTRIC, + {ELECTRIC: np.arange(96), GAS: np.arange(96)}, + None, + 0, # consumption_estimate=None (same as default) 1.1, # non-default scale factor np.float64(4027.79), # daily demand charge unscaled + False, ), ( np.datetime64("2024-07-13"), # Summer weekend @@ -1614,6 +1629,7 @@ def gas_constraint(m, t): 0, 1, # default scale factor np.float64(2023.5), + False, ), ( np.datetime64("2024-03-07"), # Winter weekday @@ -1625,6 +1641,7 @@ def gas_constraint(m, t): 0, 1, # default scale factor np.float64(2028.6), + False, ), ( np.datetime64("2024-03-09"), # Winter weekend @@ -1657,6 +1674,7 @@ def gas_constraint(m, t): 0, 1, # default scale factor np.float64(2023.5), + False, ), ( np.datetime64("2024-07-10"), # Summer weekday @@ -1689,6 +1707,7 @@ def gas_constraint(m, t): 0, 1, # default scale factor np.float64(2897.79), + False, ), ( np.datetime64("2024-07-10"), # Summer weekday @@ -1700,6 +1719,7 @@ def gas_constraint(m, t): 0, 1, # default scale factor np.float64(0), + False, ), ( np.datetime64("2024-07-10"), # Summer weekday @@ -1714,6 +1734,34 @@ def gas_constraint(m, t): 0, 1.1, # non-default scale factor pytest.approx(14646.313), # 13314.83 * 1.1 + False, + ), + ( + np.datetime64("2024-07-10"), # Summer weekday + np.datetime64("2024-07-11"), # Summer weekday + input_dir + "billing_pge.csv", + ELECTRIC, + { + ELECTRIC: np.concatenate([np.arange(48), -np.arange(48)]), + GAS: np.arange(96), + }, + None, + 0, + 1, # default scale factor + np.float64(1277.46), # based on 47 kW + True, # negative values warning + ), + ( + np.datetime64("2024-07-10"), # Summer weekday + np.datetime64("2024-07-11"), # Summer weekday + input_dir + "billing_pge.csv", + ELECTRIC, + {ELECTRIC: np.arange(96), GAS: np.arange(96)}, + None, + {"electric": 0, "gas": 0}, # dict consumption_estimate + 1, # default scale factor + np.float64(4027.79), + False, ), ], ) @@ -1727,6 +1775,7 @@ def test_calculate_demand_costs( consumption_estimate, scale_factor, expected, + expect_warning, ): billing_data = pd.read_csv(billing_data) charge_dict = costs.get_charge_dict( @@ -1734,15 +1783,27 @@ def test_calculate_demand_costs( end_dt, billing_data, ) - result, model = costs.calculate_cost( - charge_dict, - consumption_data_dict, - prev_demand_dict=prev_demand_dict, - consumption_estimate=consumption_estimate, - desired_utility=utility, - desired_charge_type="demand", - demand_scale_factor=scale_factor, - ) + if expect_warning: + with pytest.warns(UserWarning): + result, model = costs.calculate_cost( + charge_dict, + consumption_data_dict, + prev_demand_dict=prev_demand_dict, + consumption_estimate=consumption_estimate, + desired_utility=utility, + desired_charge_type="demand", + demand_scale_factor=scale_factor, + ) + else: + result, model = costs.calculate_cost( + charge_dict, + consumption_data_dict, + prev_demand_dict=prev_demand_dict, + consumption_estimate=consumption_estimate, + desired_utility=utility, + desired_charge_type="demand", + demand_scale_factor=scale_factor, + ) assert result == expected assert model is None @@ -1928,7 +1989,7 @@ def test_calculate_energy_costs( @pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") @pytest.mark.parametrize( - "charge_array, export_data, divisor, expected, expect_warning", + "charge_array, export_data, divisor, expected, expect_warning, expect_error", [ ( np.ones(96), @@ -1936,6 +1997,7 @@ def test_calculate_energy_costs( 4, 1140, False, + False, ), # positive values (export magnitude) ( np.ones(96), @@ -1943,23 +2005,37 @@ def test_calculate_energy_costs( 4, 0, # values treated as magnitude so expectation is 0 True, + False, ), # negative values (export magnitude) - should warn + ( + np.ones(96), + [1, 2, 3], # invalid type + 4, + None, + False, + True, # invalid type + ), ], ) def test_calculate_export_revenue( - charge_array, export_data, divisor, expected, expect_warning + charge_array, export_data, divisor, expected, expect_warning, expect_error ): - if expect_warning: + if expect_error: + with pytest.raises(ValueError): + costs.calculate_export_revenue(charge_array, export_data, divisor) + elif expect_warning: with pytest.warns(UserWarning): result, model = costs.calculate_export_revenue( charge_array, export_data, divisor ) + assert result == expected + assert model is None else: result, model = costs.calculate_export_revenue( charge_array, export_data, divisor ) - assert result == expected - assert model is None + assert result == expected + assert model is None @pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") @@ -3135,6 +3211,8 @@ def test_calculate_itemized_cost_cvx( "resolution, " "decomposition_type, " "consumption_estimate, " + "electric_consumption_units, " + "gas_consumption_units, " "expected_cost, " "expected_itemized", [ @@ -3160,6 +3238,8 @@ def test_calculate_itemized_cost_cvx( "15m", None, 2400, + None, + None, pytest.approx(260), { "electric": { @@ -3189,6 +3269,8 @@ def test_calculate_itemized_cost_cvx( "15m", "absolute_value", 240, + None, + None, pytest.approx(6.0 - 1.5), # 48*10*0.05/4 - 48*5*0.025/4 = 6.0 - 1.5 = 4.5 { "electric": { @@ -3205,6 +3287,39 @@ def test_calculate_itemized_cost_cvx( }, }, ), + # energy and export charges with MW instead of kW units + ( + { + "electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05, + "electric_export_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.025, + }, + { + ELECTRIC: np.concatenate( + [np.ones(48) * 0.01, -np.ones(48) * 0.005] + ), # 0.01 MW = 10 kW + GAS: np.ones(96), + }, + "15m", + "absolute_value", + 240, + u.MW, + u.meters**3 / u.hour, + pytest.approx(4500), + { + "electric": { + "energy": pytest.approx(6000), # 48*0.01 MW*1000*0.05/4 + "export": pytest.approx(-1500), # -48*0.005 MW*1000*0.025/4 + "customer": 0.0, + "demand": 0.0, + }, + "gas": { + "energy": 0.0, + "export": 0.0, + "customer": 0.0, + "demand": 0.0, + }, + }, + ), ], ) def test_calculate_itemized_cost_pyo( @@ -3213,19 +3328,26 @@ def test_calculate_itemized_cost_pyo( resolution, decomposition_type, consumption_estimate, + electric_consumption_units, + gas_consumption_units, expected_cost, expected_itemized, ): """Test calculate_itemized_cost with Pyomo variables.""" model, pyo_vars = setup_pyo_vars_constraints(consumption_data_dict) - result, model = costs.calculate_itemized_cost( - charge_dict, - pyo_vars, - resolution=resolution, - decomposition_type=decomposition_type, - model=model, - consumption_estimate=consumption_estimate, - ) + + kwargs = { + "resolution": resolution, + "decomposition_type": decomposition_type, + "model": model, + "consumption_estimate": consumption_estimate, + } + if electric_consumption_units is not None: + kwargs["electric_consumption_units"] = electric_consumption_units + if gas_consumption_units is not None: + kwargs["gas_consumption_units"] = gas_consumption_units + + result, model = costs.calculate_itemized_cost(charge_dict, pyo_vars, **kwargs) solve_pyo_problem( model, result["total"], decomposition_type, charge_dict, consumption_data_dict ) diff --git a/eeco/tests/test_utils.py b/eeco/tests/test_utils.py index e5bd689..c629fbb 100644 --- a/eeco/tests/test_utils.py +++ b/eeco/tests/test_utils.py @@ -120,6 +120,7 @@ def gas_constraint(m, t): var = getattr(model, varstr) result, model = ut.max(var, model=model, varstr="test") + model.objective = pyo.Objective(expr=0) solver = pyo.SolverFactory("gurobi") solver.solve(model) @@ -129,23 +130,35 @@ def gas_constraint(m, t): @pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") @pytest.mark.parametrize( - "consumption_data, varstr, expected", + "consumption_data, varstr, expected, expect_error", [ ( {"electric": np.ones(96) * 45, "gas": np.ones(96) * -1}, "electric", np.ones(96) * 45, + False, + ), + ( + {"electric": np.ones(96) * 100, "gas": np.ones(96) * -1}, + "gas", + np.zeros(96), + False, ), - ({"electric": np.ones(96) * 100, "gas": np.ones(96) * -1}, "gas", np.zeros(96)), - ({"electric": 45.0, "gas": -10.0}, "electric", 45.0), - ({"electric": 100.0, "gas": -5.0}, "gas", 0.0), + ({"electric": 45.0, "gas": -10.0}, "electric", 45.0, False), + ({"electric": 100.0, "gas": -5.0}, "gas", 0.0, False), + ([1, 2, 3], None, None, True), # invalid type ], ) -def test_max_pos_pyo(consumption_data, varstr, expected): +def test_max_pos_pyo(consumption_data, varstr, expected, expect_error): + if expect_error: + with pytest.raises(TypeError): + ut.max_pos(consumption_data) + return + model = pyo.ConcreteModel() if isinstance(consumption_data["electric"], (int, float)): - # Scalar case + # LinearExpression case pyo_vars = {} for key, val in consumption_data.items(): var = pyo.Var(initialize=0) @@ -161,7 +174,7 @@ def gas_constraint(m): return consumption_data["gas"] == m.gas var = getattr(model, varstr) - expr = var - 0 # similar to max_var - prev_demand_cost + expr = var - 0 # like max_var - prev_demand_cost result, model = ut.max_pos(expr, model=model, varstr="test") model.objective = pyo.Objective(expr=0) solver = pyo.SolverFactory("gurobi") @@ -202,33 +215,42 @@ def gas_constraint(m, t): @pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") @pytest.mark.parametrize( - "consumption_data, expected_positive, expected_negative", + "consumption_data, expected_positive, expected_negative, expect_error", [ ( np.array([1, -2, 3, -4, 0]), np.array([1, 0, 3, 0, 0]), np.array([0, 2, 0, 4, 0]), + False, ), ( np.array([5, 0, -3, 7, -1]), np.array([5, 0, 0, 7, 0]), np.array([0, 0, 3, 0, 1]), + False, ), - (np.array([0, 0, 0]), np.array([0, 0, 0]), np.array([0, 0, 0])), - (np.array([-10, -5, -1]), np.array([0, 0, 0]), np.array([10, 5, 1])), - (np.array([10, 5, 1]), np.array([10, 5, 1]), np.array([0, 0, 0])), + (np.array([0, 0, 0]), np.array([0, 0, 0]), np.array([0, 0, 0]), False), + (np.array([-10, -5, -1]), np.array([0, 0, 0]), np.array([10, 5, 1]), False), + (np.array([10, 5, 1]), np.array([10, 5, 1]), np.array([0, 0, 0]), False), + ([1, 2, 3], None, None, True), # invalid type ], ) def test_decompose_consumption_np( - consumption_data, expected_positive, expected_negative + consumption_data, expected_positive, expected_negative, expect_error ): """Test decompose_consumption with numpy arrays.""" - positive_values, negative_values, model = ut.decompose_consumption(consumption_data) + if expect_error: + with pytest.raises(TypeError): + ut.decompose_consumption(consumption_data) + else: + positive_values, negative_values, model = ut.decompose_consumption( + consumption_data + ) - assert np.array_equal(positive_values, expected_positive) - assert np.array_equal(negative_values, expected_negative) - assert model is None - assert np.array_equal(consumption_data, positive_values - negative_values) + assert np.array_equal(positive_values, expected_positive) + assert np.array_equal(negative_values, expected_negative) + assert model is None + assert np.array_equal(consumption_data, positive_values - negative_values) @pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") @@ -238,36 +260,63 @@ def test_decompose_consumption_cvx(): positive_values, negative_values, model = ut.decompose_consumption(x) assert isinstance(positive_values, cp.Expression) assert isinstance(negative_values, cp.Expression) - # TODO: add value checks + + # Test warning for unimplemented decomposition_type + with pytest.warns(UserWarning): + positive_values, negative_values, model = ut.decompose_consumption( + x, decomposition_type="unimplemented" + ) + assert positive_values is None + assert negative_values is None @pytest.mark.skipif(skip_all_tests, reason="Exclude all tests") @pytest.mark.parametrize( - "consumption_data, expected_positive_sum, expected_negative_sum", + "consumption_data, expected_positive_sum, expected_negative_sum, " + "decomposition_type, expect_warning", [ - (np.array([1, -2, 3, -4, 0]), 4, 6), # positive: 1+3=4, negative: 2+4=6 - (np.array([0, 0, 0]), 0, 0), - (np.array([-10, -5, -1]), 0, 16), # positive: 0, negative: 10+5+1=16 - (np.array([10, 5, 1]), 16, 0), # positive: 10+5+1=16, negative: 0 + (np.array([1, -2, 3, -4, 0]), 4, 6, "absolute_value", False), + (np.array([0, 0, 0]), 0, 0, "absolute_value", False), + (np.array([-10, -5, -1]), 0, 16, "absolute_value", False), + (np.array([10, 5, 1]), 16, 0, "absolute_value", False), + (np.array([1, -2, 3]), 4, 2, "binary_variable", True), ], ) def test_decompose_consumption_pyo( - consumption_data, expected_positive_sum, expected_negative_sum + consumption_data, + expected_positive_sum, + expected_negative_sum, + decomposition_type, + expect_warning, ): consumption_data_dict = { "electric": consumption_data, "gas": np.zeros_like(consumption_data), } model, pyo_vars = setup_pyo_vars_constraints(consumption_data_dict) - positive_var, negative_var, model = ut.decompose_consumption( - pyo_vars["electric"], model=model, varstr="electric" - ) - - # Check that variables exist and have the correct length - assert hasattr(model, "electric_positive") - assert hasattr(model, "electric_negative") - assert hasattr(model, "electric_decomposition_constraint") - assert hasattr(model, "electric_magnitude_constraint") - assert len(positive_var) == len(consumption_data) - assert len(negative_var) == len(consumption_data) - # Testing of values handled after solving problem in test_costs.py + + if expect_warning: + with pytest.warns(UserWarning): + positive_var, negative_var, model = ut.decompose_consumption( + pyo_vars["electric"], + model=model, + varstr="electric", + decomposition_type=decomposition_type, + ) + assert positive_var is None + assert negative_var is None + else: + positive_var, negative_var, model = ut.decompose_consumption( + pyo_vars["electric"], + model=model, + varstr="electric", + decomposition_type=decomposition_type, + ) + # Check that variables exist and have the correct length + assert hasattr(model, "electric_positive") + assert hasattr(model, "electric_negative") + assert hasattr(model, "electric_decomposition_constraint") + assert hasattr(model, "electric_magnitude_constraint") + assert len(positive_var) == len(consumption_data) + assert len(negative_var) == len(consumption_data) + # Testing of values handled after solving problem in test_costs.py diff --git a/eeco/utils.py b/eeco/utils.py index b92f5f3..b9c0182 100644 --- a/eeco/utils.py +++ b/eeco/utils.py @@ -1,6 +1,7 @@ import re import pytz import datetime +import warnings import numpy as np import cvxpy as cp import pyomo.environ as pyo @@ -310,31 +311,16 @@ def const_rule(model): model.add_component(varstr + "_constraint", constraint) return (var, model) elif isinstance(expression, (IndexedExpression, pyo.Param, pyo.Var)): - # Check if expression is indexed - if hasattr(expression, "index_set"): - # Create indexed max_pos variable - model.add_component( - varstr, pyo.Var(expression.index_set(), bounds=(0, None)) - ) - var = model.find_component(varstr) - - def const_rule(model, *indices): - return var[indices] >= expression[indices] - - constraint = pyo.Constraint(expression.index_set(), rule=const_rule) - model.add_component(varstr + "_constraint", constraint) - return (var, model) - else: - # Create scalar max_pos variable - model.add_component(varstr, pyo.Var(initialize=0, bounds=(0, None))) - var = model.find_component(varstr) + # Create indexed max_pos variable + model.add_component(varstr, pyo.Var(expression.index_set(), bounds=(0, None))) + var = model.find_component(varstr) - def const_rule(model): - return var >= expression + def const_rule(model, *indices): + return var[indices] >= expression[indices] - constraint = pyo.Constraint(rule=const_rule) - model.add_component(varstr + "_constraint", constraint) - return (var, model) + constraint = pyo.Constraint(expression.index_set(), rule=const_rule) + model.add_component(varstr + "_constraint", constraint) + return (var, model) elif isinstance( expression, (int, float, np.int32, np.int64, np.float32, np.float64, np.ndarray) ): @@ -502,18 +488,21 @@ def decompose_consumption( with the constraint that total = positive - negative """ if isinstance(expression, np.ndarray): - if decomposition_type == "absolute_value": - positive_values = np.maximum(expression, 0) - negative_values = np.maximum(-expression, 0) # magnitude as positive - else: - pass + positive_values = np.maximum(expression, 0) + negative_values = np.maximum(-expression, 0) # magnitude as positive return positive_values, negative_values, model elif isinstance(expression, cp.Expression): if decomposition_type == "absolute_value": positive_values, _ = max_pos(expression) negative_values, _ = max_pos(-expression) # magnitude as positive else: - pass + warnings.warn( + f"Decomposition type '{decomposition_type}' is not implemented yet. " + "Please use available type. Skipping decomposition.", + UserWarning, + ) + positive_values = None + negative_values = None return positive_values, negative_values, model elif isinstance(expression, (pyo.Var, pyo.Param)): if decomposition_type == "absolute_value": @@ -551,7 +540,14 @@ def magnitude_rule(model, t): return positive_var, negative_var, model else: - pass + warnings.warn( + f"Decomposition type '{decomposition_type}' is not implemented yet. " + "Please use available type. Skipping decomposition.", + UserWarning, + ) + positive_var = None + negative_var = None + return positive_var, negative_var, model else: raise TypeError( "Only CVXPY or Pyomo variables and NumPy arrays are currently supported." From b1a2ab216dcf599bd3cab01c3d74d18b3d369403 Mon Sep 17 00:00:00 2001 From: dalyw Date: Wed, 1 Oct 2025 15:13:07 -0700 Subject: [PATCH 15/26] Removing uneeded else statements in calculate_cost if conversion / decomposition has already been run on the model --- eeco/costs.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/eeco/costs.py b/eeco/costs.py index dcb3b8a..1326873 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -1012,8 +1012,6 @@ def get_converted_consumption_data( model=model, varstr=converted_varstr, ) - else: - converted_consumption = getattr(model, converted_varstr) if decomposition_type == "absolute_value": # Decompose consumption data into positive and negative components @@ -1029,9 +1027,6 @@ def get_converted_consumption_data( varstr=utility, decomposition_type="absolute_value", ) - else: - imports = getattr(model, pos_name) - exports = getattr(model, neg_name) consumption_data_dict[utility] = { "imports": imports, From 8a67db9ed9ae20fa622c287b41d39031fbf10d16 Mon Sep 17 00:00:00 2001 From: dalyw Date: Thu, 16 Oct 2025 17:53:33 -0700 Subject: [PATCH 16/26] Correcting UserWarning syntax in costs.py --- eeco/costs.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/eeco/costs.py b/eeco/costs.py index 1326873..ec661f5 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -629,9 +629,9 @@ def calculate_demand_cost( if isinstance(consumption_data, np.ndarray): if np.any(consumption_data < 0): warnings.warn( - "UserWarning: Demand calculation includes negative values. " - "Pass in only positive values or " - "run calculate_cost with a decomposition_type" + "Demand calculation includes negative values. Pass in " + "positive values or run calculate_cost with a decomposition_type", + UserWarning, ) if (np.max(consumption_data) >= limit) or ( (prev_demand >= limit) and (prev_demand <= next_limit) @@ -786,9 +786,9 @@ def calculate_energy_cost( if isinstance(consumption_data, np.ndarray): if np.any(consumption_data < 0): warnings.warn( - "UserWarning: Energy calculation includes negative values. " - "Pass in only positive values or " - "run calculate_cost with a decomposition_type" + "Energy calculation includes negative values. Pass in " + "positive values or run calculate_cost with a decomposition_type", + UserWarning, ) energy = prev_consumption @@ -907,9 +907,9 @@ def calculate_export_revenue( if isinstance(consumption_data, np.ndarray): if np.any(consumption_data < 0): warnings.warn( - "UserWarning: Export revenue calculation includes negative values. " - "Pass in only positive values or " - "run calculate_cost with a decomposition_type" + "Export revenue calculation includes negative values. Pass in " + "positive values or run calculate_cost with a decomposition_type", + UserWarning, ) return np.sum(consumption_data * charge_array) / n_per_hour, model From c50e6c7c8e004caa2571db11ac9f1516aa0e3d99 Mon Sep 17 00:00:00 2001 From: Fletcher Chapin Date: Mon, 8 Dec 2025 13:25:13 -0800 Subject: [PATCH 17/26] Creating new test to address bug with double unit conversions in itemized costs --- .../input/negative_purchases_within_tol.csv | 2977 +++++++++++++++++ eeco/tests/test_costs.py | 72 + 2 files changed, 3049 insertions(+) create mode 100644 eeco/tests/data/input/negative_purchases_within_tol.csv diff --git a/eeco/tests/data/input/negative_purchases_within_tol.csv b/eeco/tests/data/input/negative_purchases_within_tol.csv new file mode 100644 index 0000000..62c5729 --- /dev/null +++ b/eeco/tests/data/input/negative_purchases_within_tol.csv @@ -0,0 +1,2977 @@ +wrrf_natural_gas_combust +719.7256219 +716.2498391 +760.3649102 +709.5657033 +710.6351951 +705.8226419 +693.2565335 +672.9367874 +689.5133315 +649.14137 +662.7769435 +658.2316958 +636.5752134 +634.4362423 +636.8424906 +607.1650621 +636.040411 +599.9462397 +608.2345022 +569.7340812 +601.2830518 +564.119377 +602.0851324 +611.9775767 +459.8372826 +699.0090106 +652.8842814 +650.4781553 +628.286908 +611.9776917 +644.5962026 +641.120511 +653.6865603 +633.6342673 +649.4087954 +637.6447919 +742.1842519 +673.7389575 +600.7484837 +552.622872 +548.3450405 +539.7893797 +538.4525662 +523.480156 +552.0881684 +553.6923585 +509.0424732 +502.3583629 +482.5733831 +491.663785 +501.0215475 +490.8616955 +477.4934588 +471.611443 +492.7332548 +467.0662584 +455.5695923 +490.5944091 +303.9740745 +388.2132392 +425.6248004 +461.9864215 +451.8265324 +444.6076735 +443.5548901 +435.5339478 +428.0477349 +439.2770541 +434.9992183 +435.5339478 +413.610039 +410.6690269 +439.8117836 +420.2941575 +441.4159721 +407.7280149 +401.5786259 +390.6166714 +386.3388357 +422.7027937 +473.7671056 +497.0278381 +493.5520966 +477.510212 +501.2888813 +501.2888851 +569.1994969 +562.7827436 +583.1025193 +594.8665562 +629.8913166 +589.2518829 +601.8180044 +559.3070246 +569.7342234 +554.2270658 +572.9425769 +554.7617718 +544.3345531 +564.119531 +545.9387364 +553.6923148 +537.6504447 +557.4354185 +538.7198968 +540.8588012 +536.5809687 +553.6922817 +557.7027602 +552.0880848 +570.5362332 +559.0395342 +561.980532 +561.4458022 +574.8140171 +578.022398 +586.0432777 +576.4181681 +601.2830728 +599.1441203 +631.4952425 +648.0717649 +662.5094988 +694.5930107 +692.72164 +688.4438615 +687.1070819 +695.9300876 +709.0309713 +691.1175702 +723.4686585 +712.7741287 +790.309657 +814.3723198 +768.6532372 +732.5591325 +728.2812826 +734.4306997 +723.2014015 +719.993049 +758.4935313 +719.4583207 +747.7989787 +750.2052368 +746.7295289 +767.5839329 +763.3061121 +762.2366368 +813.8379216 +875.5986779 +873.4598955 +903.6717433 +898.5397424 +864.1021853 +838.435452 +836.5638517 +828.0082275 +811.9664217 +818.9178731 +815.9768811 +810.6466779 +810.6466778 +814.9245136 +811.1814074 +763.8578489 +706.9091596 +749.1527884 +778.8302743 +833.37268 +801.5562768 +818.1328903 +787.3859457 +795.1395232 +794.3374289 +787.1185809 +794.3374293 +780.7018273 +783.3754747 +786.3164868 +783.3754749 +793.2508953 +751.5420927 +785.497334 +788.9730754 +792.4488809 +861.6960639 +896.4530398 +846.1889802 +844.8521521 +871.3210644 +841.3763727 +797.2613065 +780.6846396 +802.6083278 +806.618628 +822.1254718 +781.2191678 +732.5590047 +784.1601111 +781.4865509 +807.420598 +782.0210669 +720.8996898 +722.1315855 +721.5967902 +716.7842798 +707.4265719 +691.3848038 +686.3049119 +681.4923061 +663.0442114 +659.8358083 +635.2382827 +649.408535 +650.2107168 +611.7102157 +632.2973096 +615.988103 +639.7835479 +610.9082378 +632.5647218 +636.3078371 +644.3288048 +621.8702135 +619.998688 +609.3041006 +608.5020242 +598.3421908 +589.2518169 +621.6029385 +666.5201897 +722.1319414 +694.0587 +644.3289271 +635.5058983 +621.6029549 +626.6828874 +644.863679 +643.5268643 +666.7875948 +688.7114739 +714.9131731 +705.0206918 +714.3784378 +653.686714 +651.5478011 +652.3499273 +692.1872405 +841.6438061 +779.0806481 +738.441254 +689.2462389 +684.4336682 +692.4546127 +668.3918066 +656.6277564 +679.6379561 +659.3182358 +658.2487771 +656.9119532 +662.7939775 +632.5817618 +644.8805398 +658.5161416 +680.1726855 +687.9262629 +661.9918832 +661.189789 +651.5646583 +657.7140475 +709.8501717 +688.7283586 +661.189789 +656.6445885 +663.0613423 +653.4362117 +667.3222916 +649.4088686 +660.3708121 +652.8846089 +678.2843008 +691.6525163 +686.037856 +695.3956097 +695.3956065 +691.3851319 +684.700998 +687.1072465 +709.5985383 +714.1509485 +704.5100436 +713.347414 +705.0453842 +711.4726405 +706.6521535 +713.8827767 +729.6832688 +732.6288601 +727.2729199 +720.576981 +712.5434821 +719.2384508 +710.4002662 +708.2575916 +476.791798 +554.64816 +538.1783982 +542.2464137 +545.6786712 +294.3510851 +491.3220563 +158.597366 +259.1748433 +370.7872896 +597.3988039 +536.5437167 +615.0166721 +675.58196 +735.8413713 +707.1872813 +698.6179507 +716.2928424 +737.9853747 +728.8798551 +703.7066079 +724.5955303 +695.9403805 +702.6354538 +695.4048216 +658.1799157 +681.4791337 +673.9805657 +683.8894167 +666.7498791 +680.1403139 +686.032019 +679.0692008 +700.7615138 +699.6904388 +701.8329387 +705.0466543 +705.8500015 +721.9182112 +727.2745843 +692.7275256 +638.6308168 +686.0327022 +783.2457917 +746.8242417 +729.6846808 +772.801411 +754.3227196 +759.2186968 +745.0249138 +743.418076 +756.0050062 +768.3241309 +760.0221077 +742.0790388 +747.702983 +750.3810504 +743.6858687 +754.9337682 +740.2043763 +743.9536719 +735.6516521 +744.4892839 +723.8681438 +759.7542907 +745.560513 +733.5091949 +733.5091975 +753.2506938 +724.0600073 +731.0228744 +724.0599472 +735.5758765 +744.6812952 +697.8153451 +858.7650469 +769.5867563 +787.7963141 +765.3004491 +757.2659393 +664.4136717 +736.1094649 +424.7959583 +563.6066128 +728.8790444 +652.843747 +722.718909 +577.9717045 +733.9668737 +706.5440801 +718.2095668 +715.2205405 +625.0433572 +716.0237395 +679.6020937 +657.549434 +661.9271138 +643.765365 +649.6080234 +645.8586041 +558.7641849 +648.0019404 +651.489867 +635.4142113 +639.4316822 +622.5597631 +636.7539274 +593.5088124 +631.1302675 +563.9117721 +622.2927162 +591.2276279 +600.3331649 +594.7093571 +597.3875847 +631.1310359 +627.1141359 +585.6042931 +608.3678461 +592.8352114 +621.4904523 +609.4392266 +600.0659802 +593.906487 +600.8695166 +603.0119498 +618.0091702 +596.852503 +613.9921625 +610.2429375 +616.9381829 +603.0123208 +605.1547358 +624.9724846 +618.5450329 +616.9384082 +638.8985426 +630.328695 +628.9897123 +615.5992591 +632.2032689 +626.3115841 +878.3156357 +709.3310825 +695.2127943 +715.298328 +745.8283286 +714.494901 +709.6743785 +677.5375309 +673.7882378 +666.2896376 +663.0759538 +656.1129689 +659.8622721 +643.7938454 +664.6827963 +642.1869995 +719.0476323 +672.9848153 +684.232711 +672.1813829 +656.9162234 +663.3541583 +660.590281 +643.7184982 +659.5190682 +665.6785959 +681.7484325 +666.4835311 +663.5373297 +671.8390227 +666.7505401 +676.6590756 +679.604568 +704.5103061 +699.4218369 +679.0686202 +700.7607848 +681.4787517 +701.8317658 +698.0820778 +704.7771016 +689.2445338 +706.9197457 +687.1021014 +698.0824619 +696.2076398 +730.4864488 +687.6379264 +735.8421111 +703.7045222 +707.4541552 +643.4005953 +718.1668229 +704.7764305 +695.9469686 +644.7161151 +701.0722172 +711.0389839 +557.5086031 +667.8984916 +736.6520284 +509.3346328 +573.3150327 +715.2201999 +719.5049745 +717.6313994 +698.3496628 +671.0339687 +668.6237379 +667.8205555 +666.7493416 +677.7294088 +684.6925142 +687.9062858 +687.638509 +665.9462377 +692.9947629 +651.7526047 +671.3025415 +658.1800393 +666.7498772 +658.180174 +671.3027245 +683.889751 +657.377178 +657.1094557 +675.8556515 +666.4826347 +683.622432 +668.6252323 +683.8902917 +654.6991978 +691.9244375 +701.0296474 +611.5822091 +585.6074289 +690.8536559 +811.9009469 +753.294102 +700.5361551 +695.1800281 +685.0033638 +697.5902904 +739.368152 +748.4735874 +771.5049624 +725.4421939 +639.4761939 +642.154269 +668.3993463 +674.826709 +636.7981263 +629.2995361 +611.3564712 +661.9719739 +623.6755901 +683.1287261 +636.7981314 +670.5872188 +691.1232692 +732.8991262 +710.40346 +716.8308731 +712.0101212 +734.5054602 +788.0662521 +861.1765649 +861.9799014 +820.202516 +777.0856394 +765.1041903 +747.9103117 +768.3279655 +783.909723 +773.4321727 +782.5662693 +766.71591 +752.2087257 +750.8654331 +726.1493234 +731.5223458 +752.4770808 +746.2980709 +718.6268053 +714.5970047 +686.6569646 +712.1789573 +708.9864388 +535.0178402 +551.0932519 +658.9854456 +638.2991605 +626.4782956 +631.0454111 +625.6744826 +619.2246796 +623.5233619 +622.7173826 +635.6127761 +654.6871628 +596.3893504 +580.5388014 +600.1506407 +564.1509477 +564.9569432 +549.9122792 +556.8973176 +542.1212951 +541.5840212 +545.0765345 +562.5390922 +568.4494859 +548.3004286 +571.6733509 +568.180866 +562.8077854 +575.7032441 +561.4645657 +574.628633 +574.0913575 +573.5540942 +560.1214058 +603.3747696 +589.9420815 +611.9717073 +575.9720287 +603.9121898 +593.1659263 +800.029638 +679.672521 +647.7027284 +612.7777606 +599.6136945 +571.9652739 +597.7440998 +611.1768291 +601.5052629 +602.5798806 +597.7440981 +596.1321707 +620.5797378 +596.4008249 +621.3857028 +595.3262066 +613.5947195 +601.2366078 +595.3262073 +604.1918081 +604.1918076 +613.3260631 +605.8037341 +606.6096976 +628.3707179 +610.3708609 +600.6883663 +631.8522402 +618.4194132 +609.8224953 +626.7478769 +604.9868538 +602.3003424 +610.6284654 +617.0761558 +610.8969716 +616.8073132 +606.8670265 +633.1951192 +623.5235481 +636.6875758 +630.7772409 +635.344337 +635.6129563 +626.2100507 +618.687712 +622.9862031 +620.2996532 +639.6426597 +623.2547547 +635.3441763 +631.8517102 +660.0602325 +645.8216368 +632.6574741 +643.1348758 +640.1797728 +623.7917669 +638.2991724 +631.8514727 +643.4035706 +624.8666419 +633.732247 +610.628018 +607.672875 +578.3895934 +570.3299754 +562.807667 +627.0160301 +645.0158394 +723.1939877 +706.5374712 +736.3580826 +692.5676565 +650.3890023 +639.105537 +696.5975084 +699.8214055 +635.34445 +626.7475147 +634.0011971 +621.3744429 +626.7475306 +626.7475188 +605.7925113 +609.8223522 +613.852151 +593.9717456 +599.0761713 +595.8523218 +602.0313668 +605.7925526 +627.0162616 +646.3594641 +626.2103552 +629.9717311 +582.9576854 +513.6444832 +550.9872505 +654.1503247 +709.2244517 +710.0303742 +659.5433225 +625.6928386 +647.7225204 +640.2001895 +658.7373588 +657.6627402 +653.6329203 +646.9165547 +690.4386043 +653.095611 +680.2297287 +650.6777188 +666.2596873 +665.7223789 +669.2148889 +659.0060135 +736.9158564 +794.1392927 +796.0198763 +770.2290325 +710.5676542 +693.3737883 +731.5226845 +695.7916752 +697.4038786 +717.2842153 +709.7618766 +680.2099517 +686.6576885 +694.4486166 +678.0606582 +688.8067061 +709.2242988 +691.2243832 +706.8063564 +679.940928 +682.6274684 +698.2093911 +664.6276376 +697.940673 +714.0596968 +673.7617157 +683.7018512 +670.8065238 +661.1350295 +645.2844029 +635.6128463 +606.0609137 +634.8068636 +635.0754707 +596.6580244 +576.5089542 +594.777449 +577.5835672 +577.5836144 +645.2842997 +665.4333048 +604.1804055 +556.6286272 +537.016875 +529.2259067 +528.6886096 +544.8078696 +560.1211659 +581.344854 +578.3896881 +543.7332685 +552.5988598 +592.8970124 +594.2402963 +575.9718134 +558.2406477 +571.9420302 +564.1510624 +580.5389875 +558.7779819 +554.4795297 +611.1656158 +650.9264756 +588.5986555 +593.1658188 +582.9569521 +588.8673786 +582.6883999 +578.9272914 +585.1063537 +601.4941294 +684.2396705 +633.7326544 +585.1062523 +599.613592 +602.0315174 +604.4494108 +616.5387699 +668.1203881 +644.7474795 +602.3198896 +587.8125368 +789.0348533 +787.6915801 +711.9309754 +645.5732827 +605.812396 +611.7227982 +605.8123956 +600.1706478 +596.6781383 +599.9019947 +603.12585 +598.0214138 +600.4393055 +571.424604 +593.991595 +570.0811796 +580.5615924 +567.1261142 +567.3748897 +616.0013305 +583.2255021 +592.6284022 +604.4493548 +567.9124098 +624.8670152 +567.1063037 +578.3897364 +563.3450569 +569.5240966 +581.6135144 +572.0570744 +588.6242945 +600.3816954 +584.3489084 +595.0374551 +603.5882808 +632.9817925 +628.9734943 +604.6571513 +601.4505372 +623.8962775 +618.2847132 +629.5076364 +626.033609 +642.333608 +646.3417129 +643.936961 +649.2810352 +658.6334267 +555.6550798 +618.3077156 +592.8005892 +667.1843759 +666.1144482 +594.6054739 +661.3055357 +672.5291135 +537.3829427 +727.8421093 +676.5372275 +635.1193842 +606.2603479 +638.5930881 +648.7471664 +654.0910036 +659.4354808 +673.3307904 +644.7391814 +653.5572114 +656.2293615 +645.0064105 +640.1965888 +642.3343058 +626.5687027 +640.998223 +625.7670539 +647.6785609 +626.8359079 +634.3178836 +648.4802312 +634.3179293 +637.5245037 +645.5409126 +635.1195736 +639.9294361 +594.5031231 +585.9522988 +538.1210497 +623.0949979 +670.1246062 +656.7639234 +655.1606428 +661.8409767 +666.1163952 +651.4600616 +645.0469246 +658.4076237 +676.8453916 +677.914246 +684.3273837 +673.6388236 +654.9338429 +674.4404643 +674.7076779 +698.2225125 +675.2421067 +704.6356487 +701.9635063 +689.6716636 +692.8782309 +673.9060374 +709.4454992 +712.9192804 +876.4542433 +761.2443224 +784.4918534 +824.0392799 +729.4459542 +755.0984994 +790.9050476 +747.8837457 +773.5361889 +731.3164649 +705.6638977 +739.3326226 +709.4046852 +717.9554326 +706.7323214 +699.7849031 +693.9062863 +714.7487683 +727.307686 +702.7242955 +711.2750881 +698.4485932 +727.3075027 +724.6354242 +685.622383 +670.1241613 +658.9012452 +661.3062259 +662.6421533 +653.0225545 +641.5323892 +616.681547 +602.5191797 +621.7586297 +612.9405992 +641.5323013 +584.6160372 +597.1750387 +584.0815929 +615.880004 +586.4865784 +581.943976 +581.1423321 +594.5030164 +578.7374296 +602.78663 +631.3785138 +591.5637092 +602.5194658 +640.4637858 +630.8441135 +627.9047836 +648.2130387 +654.8933749 +636.455634 +635.3867803 +640.1966131 +686.4245651 +745.2114647 +709.1377182 +654.8934076 +665.5819501 +646.8769961 +648.4802876 +676.8049261 +659.1688523 +665.0475428 +663.4442743 +654.3590034 +643.1360422 +655.1606402 +882.8257416 +693.9066042 +644.4720978 +609.7343334 +628.4392787 +606.2605456 +604.4303953 +605.2320374 +652.796129 +671.2338966 +614.8517406 +588.1303396 +567.0204318 +560.0728668 +582.2516311 +596.1467594 +600.4221828 +597.2156166 +597.7500429 +634.8927905 +629.2812956 +713.9881361 +707.842215 +609.7746755 +590.8080495 +585.725414 +596.6408322 +581.676864 +562.7046913 +577.4014371 +652.7557471 +649.2819607 +669.0557721 +662.6426251 +669.3229336 +700.0524228 +668.2539864 +690.4326464 +686.1572026 +696.5784632 +701.9226368 +713.1456051 +693.6391071 +701.9226736 +720.0932079 +728.9110967 +749.486503 +727.0404719 +621.7643423 +692.7094279 +606.4848773 +687.6096121 +532.5370966 +550.3024995 +592.5553568 +322.1816879 +263.3738016 +267.8016967 +215.4910463 +221.6316118 +232.3167647 +391.7391142 +467.0786465 +628.9026599 +743.8737143 +763.1146137 +720.8943296 +710.4734065 +715.5505475 +727.3076215 +758.3335272 +833.9248497 +757.2358448 +845.9499675 +828.5815375 +724.6360552 +719.5589901 +765.5197132 +653.5573291 +648.2130508 +663.4442416 +649.2819251 +652.75571 +647.6786592 +670.3918213 +667.185245 +667.9868998 +670.3918374 +666.3836467 +657.5656007 +667.4525033 +667.9869138 +684.0197402 +681.8820154 +682.6836482 +588.891667 +620.155663 +685.3557645 +747.6165083 +724.9033851 +715.0164862 +708.6033482 +705.7045054 +722.004559 +730.020982 +708.1094326 +736.1669043 +713.7209258 +736.1669022 +726.0127709 +739.1062561 +708.9110744 +713.4537133 +713.9881413 +730.020981 +732.1586908 +754.3374571 +724.6767018 +746.3210367 +723.6078456 +744.7177525 +687.8011553 +720.0935257 +717.1541765 +707.0000707 +711.8099032 +733.9886431 +729.7132487 +728.6443768 +761.5115778 +794.3787001 +773.0015032 +746.8146105 +720.8948457 +751.6004456 +743.1947877 +744.0082218 +729.366111 +736.144866 +742.9236233 +725.298845 +748.3466214 +725.2988296 +753.4984755 +720.4181325 +716.079712 +721.2315781 +713.3682528 +710.9278688 +689.5069663 +688.6935314 +662.3919106 +673.5090835 +631.8727874 +664.5611165 +664.5611141 +673.5091894 +628.6215716 +546.2868741 +669.1706839 +636.3614707 +620.9058878 +624.4308495 +607.8906622 +622.8039552 +592.4350871 +591.3504865 +605.9926267 +639.0730032 +627.9558293 +608.4329889 +579.9621735 +558.8124256 +572.0988056 +586.4697909 +625.7866312 +573.4545605 +621.4482242 +578.0641242 +565.5911958 +591.3505091 +614.940616 +580.7756365 +597.8581283 +595.6889242 +605.4503471 +612.7714142 +619.0078793 +645.309491 +653.71516 +603.0099904 +712.8259797 +821.0150615 +772.4791243 +723.1297176 +664.0188836 +629.0404525 +657.7824213 +629.8677629 +675.9633799 +718.5340381 +682.4709967 +636.1042286 +642.8829958 +629.5966118 +-1.50E-11 +-1.52E-11 +-1.24E-11 +-1.74E-11 +-1.46E-11 +-1.51E-11 +-1.42E-11 +-1.46E-11 +-1.39E-11 +-1.47E-11 +-1.46E-11 +-1.47E-11 +-1.46E-11 +-1.48E-11 +-1.47E-11 +-1.49E-11 +-1.50E-11 +-1.51E-11 +-1.52E-11 +-1.53E-11 +-1.53E-11 +-1.52E-11 +-1.51E-11 +-1.50E-11 +-1.50E-11 +-1.48E-11 +-1.47E-11 +-1.47E-11 +-1.47E-11 +-1.47E-11 +-1.46E-11 +-1.46E-11 +-1.46E-11 +-1.46E-11 +-1.46E-11 +-1.45E-11 +-1.45E-11 +-1.45E-11 +-1.44E-11 +-1.44E-11 +-1.44E-11 +-1.43E-11 +-1.43E-11 +-1.43E-11 +-1.43E-11 +-1.43E-11 +-1.43E-11 +-1.43E-11 +-1.42E-11 +-1.44E-11 +-1.45E-11 +-1.45E-11 +-1.46E-11 +-1.47E-11 +-1.48E-11 +-1.46E-11 +776.2754711 +693.0319197 +477.8240134 +764.886787 +685.1685793 +683.8128373 +674.5937253 +675.1360231 +699.26843 +679.7455932 +698.4549931 +672.9668361 +645.0383273 +674.8648978 +686.2532279 +654.5286053 +670.2553429 +674.3226042 +675.9495101 +666.4592376 +671.0687989 +668.086142 +744.0083136 +843.791697 +823.7265724 +789.2904583 +749.4313266 +716.3509559 +594.3331716 +607.6195529 +365.2108667 +503.4977016 +617.9232756 +638.2734338 +669.7269133 +682.1998452 +699.0111874 +699.0111873 +701.4515436 +685.4536534 +695.7573793 +720.1609407 +705.2476527 +694.943926 +704.4341999 +700.0957891 +691.9612687 +712.2975705 +689.7920637 +688.1651601 +689.7920644 +699.8246398 +714.7379275 +672.6957033 +685.9820873 +697.3704145 +698.183867 +711.7413986 +684.8974862 +720.6893677 +685.9820868 +671.3399518 +702.793421 +667.8149817 +715.2663162 +720.9604728 +692.4896611 +713.0971061 +675.4071749 +694.9300087 +717.1643478 +694.1165546 +678.932119 +694.3876832 +701.979894 +695.7434357 +711.7413046 +679.4744066 +680.830169 +662.9342332 +662.9342229 +657.511204 +672.1533995 +653.986265 +640.9712164 +627.9557926 +614.3982616 +624.7019852 +625.244283 +598.6715408 +599.4849967 +598.6715493 +575.8949045 +591.6216434 +583.2159734 +578.8775645 +585.1140282 +565.5911846 +562.879681 +565.8623387 +569.6584486 +577.5218205 +566.6757942 +572.0988073 +600.8407768 +589.723602 +623.6174314 +584.0294395 +600.5696288 +598.6715761 +589.4524548 +591.0793594 +605.9926462 +590.2659104 +608.7041552 +596.5023766 +579.1487343 +604.636895 +587.2832549 +606.5349509 +627.9558526 +616.5675244 +738.3141498 +826.9803763 +682.7282791 +666.1880877 +655.8843627 +632.2942576 +632.5654082 +618.7646112 +610.0843778 +660.5184346 +625.8111357 +621.2015818 +640.1821315 +611.7113055 +656.7223243 +632.047606 +630.1495418 +625.539987 +651.0281598 +627.9803415 +623.9130654 +628.7937973 +680.5835869 +651.8416024 +650.2146841 +715.562028 +782.807403 +727.1969683 +702.2511129 +728.5527204 +788.4769983 +722.8585719 +744.0083161 +779.5290417 +774.1060333 +715.2663533 +709.3010395 +731.5353754 +733.704553 +706.4100443 +700.4758947 +707.2192492 +734.462374 +723.4032626 +725.2913985 +726.9098106 +735.2715609 +707.4889516 +727.718975 +734.4623126 +691.3760753 +685.475207 +528.901438 +465.6975138 +512.9014512 +321.3927536 +334.5450609 +428.7056911 +306.1354332 +415.833314 +429.2755018 +445.9752422 +280.9062043 +522.5743506 +534.7627659 +467.9825084 +414.6960938 +554.0866775 +675.9304314 +695.8903504 +691.5746326 +691.0351968 +699.6666941 +676.7393121 +679.9761242 +671.0749032 +677.009055 +657.8579364 +700.4759186 +664.871027 +672.6933171 +706.949542 +774.3830557 +689.9563006 +667.0289072 +664.6013028 +676.739367 +671.3446634 +693.4628488 +619.8254448 +697.239141 +666.2197306 +692.3839179 +759.5476948 +715.5810495 +692.1141862 +702.3640861 +647.3383175 +636.8186962 +623.6017157 +684.5616184 +696.9693855 +728.798011 +719.9165842 +726.659937 +724.23233 +720.4560524 +720.1863183 +740.9558454 +722.8836594 +754.4425512 +754.7122856 +745.8110596 +716.6797747 +741.765048 +707.7785488 +717.7587113 +706.9693464 +690.245831 +697.7983864 +701.3049299 +695.9102475 +717.7587112 +734.7321549 +724.4822649 +684.8313638 +686.4497719 +748.7583388 +745.2518014 +755.771419 +741.475514 +743.0939205 +782.205351 +754.1529963 +799.7380055 +763.8633939 +761.7055154 +764.133101 +726.1006112 +761.9751855 +706.6797638 +743.0938471 +755.2318666 +732.3044664 +802.7050002 +728.5906317 +754.4226265 +776.001551 +728.2584335 +727.9887025 +725.2914372 +698.8574274 +715.5809612 +667.3636074 +678.3576446 +459.997165 +632.0644456 +580.3461921 +653.0483639 +693.1930419 +653.0026653 +637.088368 +705.3310507 +723.6729855 +669.7261962 +635.20025 +627.9174335 +640.8646726 +699.3969563 +756.3108506 +814.3036531 +739.317619 +661.3644764 +656.2395315 +615.2399537 +615.5096885 +632.2332038 +636.8186862 +623.0622522 +625.2201338 +626.8385497 +611.7334455 +616.0492043 +619.0162786 +628.4569778 +611.1939887 +601.7532914 +646.0127821 +616.5886686 +623.6008797 +621.1741167 +821.3168039 +707.2193055 +678.0880206 +671.0749338 +710.9955634 +733.11376 +698.5877985 +687.2589762 +636.8384766 +649.2462456 +646.8186386 +651.9435874 +653.5619913 +650.5949156 +652.7527885 +658.9566735 +646.5489042 +658.9566733 +649.5159791 +641.1542214 +653.292257 +662.193483 +662.7329513 +669.7460384 +650.0554484 +659.2264081 +634.4108696 +648.7535394 +756.3108969 +696.6996735 +675.9301521 +664.3315811 +670.2657394 +675.120944 +735.5413629 +787.060564 +748.7583271 +700.7456561 +691.5746849 +693.7325501 +691.5746721 +695.0812138 +692.1141406 +690.7654669 +681.0550379 +688.068127 +689.4168002 +736.0807751 +683.4826451 +701.5548252 +698.3180101 +669.9959295 +687.5286279 +723.1340042 +698.0482323 +570.25321 +662.927168 +698.5876915 +690.2259709 +677.0998614 +694.2720896 +634.2484944 +307.5490753 +458.7386723 +698.6120531 +704.7915997 +679.7063497 +667.5683365 +669.4564618 +637.8975957 +628.9963692 +619.8254148 +612.0031307 +446.6561494 +429.1234374 +615.5096854 +671.0749068 +696.1601742 +664.6012921 +653.0027286 +641.6738956 +631.1542732 +643.2923058 +669.7262454 +645.1804448 +647.3383214 +643.2923087 +618.7464998 +631.1542719 +643.5620374 +641.1344293 +644.3712412 +633.0424075 +632.7726792 +635.2002866 +645.4501818 +667.2986401 +580.7140113 +538.6354841 +576.1285183 +663.5223581 +684.0221501 +667.8381089 +668.9170464 +651.9435863 +643.3120945 +641.1542215 +659.4961418 +652.4830547 +667.3184315 +659.2264075 +649.7857133 +654.6409274 +648.9765109 +641.6936895 +647.3581061 +644.3910309 +784.9225072 +690.5155656 +670.8249754 +642.505661 +654.3711945 +641.4239556 +647.3581075 +651.1146381 +642.752882 +651.6540985 +623.0622757 +640.8647326 +631.4240337 +647.8777907 +658.3974143 +656.2395384 +687.7984205 +681.3247983 +669.1867624 +689.3832423 +688.5638348 +685.5593447 +716.6968076 +689.1101118 +709.3221467 +718.3356238 +696.2116386 +707.1370685 +728.7147969 +723.2520766 +736.0894544 +742.9179098 +736.3625967 +735.5432086 +741.2791361 +734.9988995 +728.9880126 +374.3092928 +476.5775041 +687.19887 +633.8123202 +635.3025356 +636.9413993 +637.7611193 +579.3377191 +669.7179206 +639.6728402 +627.9278118 +637.4875048 +635.302409 +652.7830901 +633.6635863 +600.6141673 +612.905267 +634.4829784 +618.6411108 +642.1307732 +614.2709364 +613.7246647 +702.2206169 +803.2807997 +695.9384925 +643.4964418 +626.2888938 +628.7471103 +643.2233026 +643.2232981 +608.5350661 +647.3203312 +626.0157373 +644.8621097 +612.0858145 +627.9276933 +620.2798976 +619.1873576 +612.6321174 +867.4676748 +753.8432491 +747.0148572 +691.2951858 +660.4308563 +653.8755995 +637.48746 +623.2937519 +615.3728187 +682.5641871 +692.6702058 +657.708843 +641.0475676 +651.4267188 +663.9909628 +683.3835938 +649.5147741 +648.1490959 +649.2416382 +648.1490954 +644.5983319 +668.361133 +654.7043511 +630.1221412 +647.3296887 +633.3997701 +664.8116817 +646.2278087 +666.4398472 +642.9501809 +635.0292468 +645.6815248 +669.1711942 +670.5368731 +697.5773051 +677.0921352 +689.6563779 +674.0876506 +682.2817257 +679.0041016 +696.4847775 +700.3086853 +684.1936786 +686.925045 +666.7130076 +681.4623286 +688.5638638 +689.3832773 +688.2907273 +697.0310538 +699.2161639 +697.3042063 +710.960995 +705.2253293 +718.6089797 +711.7827104 +572.3571058 +424.3855118 +370.3945959 +496.1280196 +191.7000091 +532.8875983 +413.7130378 +369.718604 +410.3144928 +677.9118155 +607.3608439 +526.7080783 +680.6472053 +644.5891082 +654.9682046 +648.6860677 +621.0993527 +641.5845183 +645.1352744 +619.7336552 +648.1397621 +615.9097529 +608.5350893 +608.5350882 +619.1873773 +620.2799183 +623.2844104 +636.3949211 +616.456017 +619.7336418 +626.0157608 +612.6321057 +610.9932895 +634.2098219 +618.0948171 +618.3679479 +609.081334 +634.7560766 +643.7695472 +638.0336937 +628.7470828 +624.3769374 +625.4694682 +628.7470924 +631.2053186 +627.3814243 +642.6770267 +625.474786 +520.8638333 +527.9653603 +548.9968053 +669.1764901 +714.2438718 +665.352591 +623.8359724 +631.210635 +633.9419917 +637.7658907 +641.3166543 +653.6077585 +654.9734371 +651.1495378 +624.9285157 +649.2375879 +663.4406414 +646.5062313 +681.4675942 +676.5458599 +649.778562 +664.5278852 +687.1981451 +650.871102 +691.2951839 +720.2475648 +694.0265458 +681.7354457 +695.6653708 +696.7579154 +722.432677 +707.6833497 +705.7714204 +710.687873 +726.2565706 +723.2520808 +710.4147506 +716.4237217 +710.1424256 +709.6592369 +636.8062706 +623.1039489 +541.4845118 +454.1244641 +594.6343346 +434.3608791 +367.4553323 +348.9329012 +367.6236876 +521.0415046 +412.0459072 +552.8928612 +381.6700423 +510.6766385 +518.4194296 +482.1990128 +650.408326 +621.0994319 +635.8488729 +611.53965 +623.0113215 +596.2440107 +593.2395063 +580.6752692 +581.4946849 +709.8684245 +636.6680688 +603.6186547 +589.9618682 +583.1334766 +590.5081389 +597.6096638 +591.0544051 +584.2260137 +580.6752484 +577.9438902 +569.2035478 +557.7318486 +591.3275332 +571.9348988 +610.4470189 +654.1487281 +649.2322839 +636.6680171 +611.2663823 +614.54404 +615.3634468 +626.2889005 +845.6168286 +704.9519602 +680.9160253 +666.7129645 +660.4308483 +623.5575353 +630.659063 +620.2852091 +617.553853 +632.0300423 +616.1881747 +631.2106352 +648.6913164 +640.497247 +647.8719096 +644.0480108 +644.3211464 +628.2061433 +640.770383 +638.5852979 +654.7003008 +642.136061 +667.8108116 +645.4136886 +644.5942818 +655.5197074 +662.6212341 +657.6994653 +685.0130517 +664.5278698 +651.4283063 +658.7917877 +673.8144516 +663.4353256 +669.9905897 +662.0696611 +665.3472988 +673.8145118 +671.083163 +678.2616681 +690.5898071 +671.1387256 +662.0980976 +675.2480623 +671.6865801 +667.5771757 +701.5479856 +675.2479364 +676.0698642 +694.1509813 +680.1791079 +671.1383189 +684.8364662 +683.7401093 +687.3033657 +694.9709909 +666.5982268 +466.5420656 +587.4519901 +600.2658255 +468.0887186 +632.6274821 +158.7708507 +155.9763004 +458.4506119 +612.8369603 +706.7531509 +665.1114989 +670.5905999 +659.0846198 +642.099141 +639.6334929 +650.5918497 +631.140831 +634.4283424 +638.811702 +658.8106971 +631.9627609 +640.1815269 +637.1679934 +648.4003011 +648.9482258 +654.7013658 +639.907597 +651.4138649 +637.1680384 +637.1680393 +633.0586648 +656.0712013 +655.2493548 +677.9879374 +661.8243733 +640.7295535 +666.4816518 +659.6326844 +676.0702132 +704.0140638 +574.7056033 +596.3482599 +676.3441243 +708.6712363 +701.5483202 +674.9743687 +666.5322594 +689.5448018 +680.2301995 +674.2031037 +678.3124875 +692.2843899 +700.5031569 +691.4625146 +688.4489672 +678.3124893 +683.2437471 +688.9968835 +694.4760602 +692.2843894 +705.1604582 +671.4635166 +692.558351 +698.0375293 +710.0917172 +801.5939819 +750.860889 +688.6723095 +703.4660778 +717.4379635 +717.4379625 +723.1911212 +705.109818 +800.7213211 +757.4358791 +794.4201832 +779.6263803 +781.2699462 +789.7626226 +783.1874824 +780.4479983 +767.8458737 +765.6541642 +742.3677009 +737.1626048 +752.2300313 +764.2843492 +784.2827092 +768.3926062 +755.788724 +717.9845211 +728.6678475 +657.4435571 +658.4212583 +622.1356225 +511.1358315 +661.7981103 +696.6150706 +607.1221486 +647.0286211 +384.3361302 +232.036721 +580.6464642 +692.5071313 +650.3176391 +681.2751475 +672.5084737 +672.2346081 +653.0575186 +623.470003 +659.9065215 +667.5773474 +688.9461353 +693.8773892 +641.2773661 +621.5523492 +611.4158953 +599.0877652 +613.333628 +629.7711475 +648.4003385 +649.2222286 +642.9211676 +648.4003681 +640.1816108 +665.1118372 +645.9347433 +655.2493441 +639.3597369 +647.0305995 +675.7962587 +695.7952315 +692.2337682 +667.0295727 +669.4951993 +761.8191637 +849.7597922 +790.3109252 +748.3952798 +722.3691983 +708.1233319 +707.5754181 +701.8729512 +706.5302527 +717.762566 +700.2291975 +693.1062674 +701.3250363 +695.0239806 +689.5448018 +679.6822825 +686.5312541 +698.5854435 +680.5041573 +693.3802264 +703.2427468 +701.3250342 +700.2292007 +692.8323139 +688.722929 +684.3395924 +703.5167147 +698.2609054 +723.1911059 +811.9536203 +835.5140039 +742.6421611 +736.3411345 +734.4234264 +730.5879752 +740.1765132 +746.4774859 +709.7669779 +723.1908602 +748.3949404 +775.2427874 +753.600173 +759.6271056 +751.1343787 +737.4364728 +750.3123472 +768.3934037 +754.9697449 +760.9949593 +568.9189397 +726.5228109 +614.4340124 +513.5754553 +534.5029695 +582.0188127 +612.5841622 +734.4645927 +564.4896095 +669.0566839 +507.4942032 +262.292199 +452.0896392 +610.7527933 +649.6832777 +668.9660344 +609.153943 +740.1752064 +723.4641452 +745.9787344 +765.6542432 +725.3823571 +778.8041007 +785.6533567 +759.0794588 +704.561726 +700.1783969 +571.6918732 +691.1378318 +740.9982849 +726.2045291 +687.85037 +676.6180598 +662.3722381 +678.5358118 +681.8233415 +668.9472778 +650.8660519 +651.1400005 +636.8941543 +638.8118921 +649.2224463 +629.497288 +654.4275057 +658.2629361 +654.7014666 +667.0296151 +511.1474244 +495.0425921 +586.7597353 +680.4535061 +770.5859073 +721.273382 +710.3150508 +716.1188247 +699.9552408 +725.1594569 +722.6938256 +721.0500721 +736.1178114 +730.6386335 +730.9125933 +731.7344701 +716.6667308 +737.2136457 +759.130357 +733.9261389 +732.2823854 +739.4053181 +725.1594555 +739.1313625 +736.3917845 +731.7415419 +735.5698998 +752.2306589 +731.4098711 +747.0255281 +741.2723816 +731.1359268 +803.460908 +798.529653 +868.9368125 +880.9908713 +910.0300122 +932.7762192 +917.7050265 +753.4885494 +475.5339722 +318.5536936 +670.8822871 +547.4847017 +443.3795003 +556.7655883 +650.47231 +455.0955903 +487.2353483 +769.9579251 +774.1862874 +413.0346117 +768.3012905 +615.8563316 +703.5895282 +443.114994 +399.4562855 +396.3336811 +482.2366981 +572.5482623 +430.9938363 +646.8605205 +682.4870702 +695.1605466 +718.7382915 +710.808857 +701.7461837 +704.0122303 +711.3760585 +704.5790464 +709.3936319 +751.3096626 +692.6838959 +715.0580527 +675.4077675 +668.6106283 +669.4603621 +666.9114494 +651.0513718 +666.9114752 +673.1422983 +674.5584034 +666.9115788 +685.3206796 +683.9047992 +688.4360721 +690.1353622 +688.4360833 +705.4290607 +695.7997522 +706.2788156 +727.2367274 +756.4083459 +724.6877457 +727.2366949 +746.212085 +841.8761109 +809.0861442 +807.6700491 +801.7224117 +773.4008162 +798.6070529 +782.4638025 +756.7454909 +772.6056393 +770.6231225 +757.0287161 +760.1440959 +751.9308044 +759.8608839 +763.825919 +760.4273108 +762.1266139 +743.4342902 +751.647583 +761.5601784 +802.6266362 +857.5707273 +817.0707012 +806.3084599 +811.1231519 +786.4832744 +792.1476193 +827.4950426 +800.5895234 +778.4986703 +753.575638 +762.0729828 +752.443997 +763.4885846 +777.6494675 +784.7296682 +763.7719782 +751.3102178 +812.2014496 +885.8374557 +809.3689598 +805.1206193 +820.9805317 +816.4490754 +842.5047396 +828.0604811 +832.0254647 +836.2738895 +828.9092397 +854.6817678 +834.2907229 +752.8913197 +702.6028853 +641.4992232 +558.0744136 +468.2203213 +532.0984439 +295.3401971 +464.4990845 +537.7279929 +513.2679274 +611.7309635 +641.7627605 +778.5851742 +671.6353307 +792.09129 +772.550599 +796.6234859 +793.7917874 +767.4528108 +748.1942223 +762.3548931 +736.5825276 +735.166482 +736.5825687 +782.1803803 +732.9007927 +732.051212 +720.1561445 +726.1036915 +717.3240047 +726.9533948 +726.387006 +713.9255603 +710.2436727 +743.9464478 +728.9359848 +759.2400902 +720.1562669 +737.1492977 +735.7332128 +723.2717659 +737.1493543 +715.3417934 +741.1143344 +779.6318597 +730.3522114 +770.5688574 +800.5897338 +795.2085811 +767.1701717 +764.0547974 +764.3379853 +783.0846768 +782.5182401 +778.8364191 +767.507746 +790.4557006 +791.2979492 +815.9373854 +810.5567241 +844.5427542 +886.4588584 +892.1231983 +848.7910033 +842.843448 +847.6581354 +829.2490339 +809.1406313 +813.3888866 +841.1441515 +833.4972989 +846.8084936 +867.7116567 +811.634946 +813.6174819 +832.5929419 +825.2295678 +850.1525759 +839.107158 +832.0266944 +839.6734963 +828.9111218 +836.2746885 +854.4001944 +818.4318455 +794.3584034 +840.239231 +828.9103791 +823.5290131 +802.2880987 +813.6168645 +812.7671972 +816.7309749 +793.2242545 +797.755905 +797.1914437 +722.5448266 +721.6103381 +726.3434639 +641.4710378 +710.1159354 +731.7664826 +728.9341062 +726.3855466 +710.3634727 +719.3052399 +690.5810088 +686.7355887 +720.4379943 +680.2219414 +713.6414164 +720.1549565 +705.9944074 +725.2533652 +697.4982889 +686.7361538 +707.410828 +662.3796582 +685.3202137 +709.9599724 +696.0825234 +716.4741297 +679.3728729 +645.3869529 +673.4254141 +662.6632422 +674.8415644 +711.3765598 +661.8137576 +728.6527763 +679.6563935 +681.6389384 +682.2053044 +703.7297506 +668.8941725 +711.0933582 +684.4712952 +689.0027482 +707.6951665 +700.0481411 +734.8836746 +858.6488388 +839.9568184 +770.2855953 +737.1493345 +718.1737848 +751.8765561 +716.4744967 +719.6206128 +744.5436997 +722.1695671 +738.8793627 +748.5087372 +722.1695678 +736.896846 +673.7394779 +704.326901 +717.9213068 +721.8863483 +717.6380901 +731.7989351 +707.4422833 +751.057685 +724.4352982 +691.8653557 +769.749996 +789.2919634 +773.4318203 +767.4535468 +764.3382305 +738.2824606 +759.8067246 +770.5689929 +773.1179034 +802.2890969 +783.596782 +785.8624959 +793.5092994 +780.7644082 +783.8797197 +698.8505958 +673.4818213 +697.1094727 +669.5023932 +681.1918351 +691.8864018 +691.8864692 +679.9483045 +692.8813688 +675.2225804 +688.6531036 +684.9225069 +688.9019191 +688.404313 +667.7609945 +538.010141 +579.8860065 +382.3319599 +566.0231154 +486.2132962 +668.2586106 +608.2131254 +656.5694319 +622.4952431 +643.6361453 +645.8742094 +622.992812 +595.6343934 +579.9655087 +577.9758328 +607.5726976 +588.1731196 +583.1989005 +601.3549468 +622.4955499 +604.3395541 +599.3653203 +572.2555811 +581.7067227 +562.5558045 +590.162993 +575.4889207 +578.97091 +593.6450134 +584.9400546 +595.1373174 +603.8422783 +595.6347486 +597.8731628 +616.7753826 +610.3088443 +601.8525966 +613.293418 +615.0344233 +656.0721171 +632.1956541 +634.4340752 +656.0721259 +637.1699131 +625.7290984 +654.3311059 +656.8182442 +644.1338478 +662.7873446 +658.3268387 +632.2119179 +693.1467344 +535.2136396 +584.7076344 +661.3114036 +674.7419338 +666.5343875 +666.7831002 +630.9683509 +637.186188 +594.1587462 +648.8757236 +612.3148353 +626.9889335 +622.0146618 +639.6733249 +599.1330189 +600.3765864 +610.5738415 +609.3140404 +600.8578803 +605.0859867 +589.6657965 +590.4119866 +594.6400873 +634.1853968 +596.3809951 +621.5010088 +640.1545021 +624.485569 +575.2402672 +634.6827017 +613.2933273 +650.3515513 +620.5059352 +629.4596413 +642.6414168 +647.1181914 +628.2159607 +667.7613151 +631.200553 +645.6258531 +635.428623 +634.1850795 +621.500701 +624.7339818 +602.5984628 +622.4953979 +592.89854 +593.1472763 +566.28629 +575.4887241 +545.394441 +533.4562119 +524.0051313 +531.7152122 +525.4974291 +524.2538757 +544.1509343 +502.6158197 +494.9057222 +535.6947107 +483.4649402 +511.8182407 +509.5798377 +490.1802063 +507.0927333 +502.6159012 +497.6416434 +546.6381777 +502.1185119 +517.7874727 +522.5130291 +506.3466634 +525.4976447 +531.9641972 +516.0469987 +555.8407124 +516.295389 +530.969776 +567.7789676 +526.2437979 +528.4822143 +563.3020363 +528.9795945 +533.705192 +512.8133511 +539.6743313 +581.4581348 +789.6310784 +632.9417973 +615.0344501 +575.2403861 +580.7120516 +569.5199105 +550.1489466 +566.5640419 +570.0460317 +576.015157 +621.281028 +582.4817097 +594.171248 +636.4525553 +606.109499 +605.860786 +606.358214 +613.3221935 +617.0528972 +609.0940626 +618.2964647 +649.136948 +631.478284 +608.0992078 +643.4165361 +633.9654198 +649.854185 +629.9571251 +648.8593429 +660.5488712 +667.7616473 +630.70337 +636.4237713 +631.449511 +667.0155114 +661.0463951 +625.2316117 +621.2521663 +657.5642434 +629.2109079 +639.159451 +627.9673054 +619.7597657 +620.2571727 +638.1644885 +600.8575353 +642.8900605 +600.8575527 +606.5779214 +656.3203854 +637.4182317 +604.0907082 +595.6344541 +591.1576037 +632.4437175 +589.4166059 +598.1213803 +573.9964021 +581.2090065 +564.7940389 +576.2346814 +553.8506443 +556.8352512 +570.265791 +560.3173133 +536.9382871 +586.9296457 +601.1063049 +598.8678766 +642.3926815 +619.5111194 +595.137218 +579.7169866 +567.5300447 +578.2247271 +568.0274959 +564.0480906 +576.9812051 +567.0326742 +573.0018063 +582.4529244 +580.7119368 +597.127033 +582.7016556 +591.1578984 +605.3345607 +622.2470602 +694.3739247 +740.6345715 +707.5557431 +670.4975139 +641.6467763 +651.3465945 +659.305415 +649.854327 +650.1030392 +653.3362944 +683.6793335 +538.1820261 +581.2093563 +655.8233605 +663.0360559 +651.8727981 +633.2192794 +643.6652493 +608.8453487 +610.5863437 +620.5348878 +602.3787968 +597.6532383 +612.0786251 +594.6686747 +613.3221932 +602.3787959 +566.564043 +582.4817116 +569.5486045 +555.6206437 +590.1918327 +548.6566681 +584.2227075 +562.0871989 +547.8817176 +578.722183 +569.7685031 +566.0378028 +588.6711858 +584.6918342 +654.5798716 +648.8594689 +599.1168375 +618.7651369 +714.2709786 +573.4992353 +1192.152425 +1111.109688 +1143.901363 +1149.990931 +1237.122917 +1191.214242 +1174.350362 +1232.904955 +1250.702354 +1190.275907 +1223.21386 +1049.04627 +1001.716994 +1134.751834 +1008.205317 +847.668354 +800.8485335 +730.3241609 +731.0436179 +616.5250702 +805.5578291 +799.083314 +680.9412309 +632.3334293 +299.2432696 +967.0254569 +974.3940882 +1081.594813 +1080.190386 +1046.930579 +1059.110879 +1053.021119 +1093.776223 +1322.847739 +1340.749536 +1367.349658 +1254.925075 +1175.288096 +1075.038945 +1110.173102 +1076.912894 +1067.543846 +1061.453934 +1048.33734 +1103.146554 +1211.3596 +1153.271475 +1157.488009 +1032.410892 +1051.618361 +1034.28549 +1034.296171 +1060.051379 +1030.133519 +1009.92542 +1131.255508 +1131.724447 +1113.921547 +1050.680123 +989.7812034 +1026.322209 +1023.985571 +988.8452017 +1017.428189 +966.4218739 +972.5037705 +1008.106426 +1048.806202 +1276.063262 +1154.264707 +1061.510415 +1034.339966 +1038.55607 +1011.854077 +1002.484955 +1009.511796 +958.4500894 +1007.169516 +980.4675227 +972.0353141 +999.674219 +986.0889943 +972.035314 +1005.764148 +945.745262 +978.5371137 +979.0055613 +970.5733872 +1006.647389 +995.4027909 +1006.645355 +1053.490884 +994.4664242 +977.6008666 +1031.941309 +963.5464204 \ No newline at end of file diff --git a/eeco/tests/test_costs.py b/eeco/tests/test_costs.py index 707d5e4..13d803a 100644 --- a/eeco/tests/test_costs.py +++ b/eeco/tests/test_costs.py @@ -34,6 +34,18 @@ output_dir = "tests/data/output/" +def obtain_data_array(csv_filename, colname=""): + """Helper to load data to dict from CSV file""" + csv_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "data", "input", csv_filename + ) + df = pd.read_csv(csv_path) + if colname: + return df[colname].to_numpy() + else: + return df.to_numpy() + + def setup_cvx_vars_constraints(consumption_data_dict): """Helper to set up CVXPY variables and constraints.""" cvx_vars = {} @@ -723,6 +735,30 @@ def test_get_charge_dict(start_dt, end_dt, billing_path, resolution, expected): False, False, ), + # negative values within tolerance (i.e., should be treated as zeros) + ( + { + "electric_energy_0_2024-08-01_2024-08-31_0": np.ones(2976) * 0.05, + "gas_energy_0_2021-08-01_2024-08-31_0": np.ones(2976) * 0.570905, + "gas_energy_0_2021-08-01_2024-08-31_250": np.ones(2976) * 0.415764, + "gas_energy_0_2021-08-01_2024-08-31_4167": np.ones(2976) * 0.311744, + }, + { + ELECTRIC: np.zeros(96), + GAS: obtain_data_array( + "negative_purchases_within_tol.csv", + colname="wrrf_natural_gas_combust", + ), + }, + "15m", + None, + 0, + None, + None, + pytest.approx(9.0), + False, + False, + ), ], ) def test_calculate_cost_np( @@ -3086,6 +3122,39 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): }, }, ), + # negative values within tolerance (i.e., should be treated as zeros) + ( + { + "electric_energy_0_2024-08-01_2024-08-31_0": np.ones(2976) * 0.05, + "gas_energy_0_2021-08-01_2024-08-31_0": np.ones(2976) * 0.570905, + "gas_energy_0_2021-08-01_2024-08-31_250": np.ones(2976) * 0.415764, + "gas_energy_0_2021-08-01_2024-08-31_4167": np.ones(2976) * 0.311744, + }, + { + ELECTRIC: np.zeros(96), + GAS: obtain_data_array( + "negative_purchases_within_tol.csv", + colname="wrrf_natural_gas_combust", + ), + }, + "15m", + None, + pytest.approx(153276.17678277715), + { + "electric": { + "energy": 0.0, + "export": 0, + "customer": 0.0, + "demand": 0.0, + }, + "gas": { + "energy": 0.0, + "export": 0.0, + "customer": 0.0, + "demand": 0.0, + }, + }, + ), ], ) def test_calculate_itemized_cost_np( @@ -3102,11 +3171,14 @@ def test_calculate_itemized_cost_np( consumption_data_dict, resolution=resolution, decomposition_type=decomposition_type, + electric_consumption_units=u.kW, + gas_consumption_units=u.meter**3 / u.day, ) assert result["total"] == expected_cost for utility in expected_itemized: for charge_type in expected_itemized[utility]: + print(f"utility: {utility} & charge_type: {charge_type}") expected_value = expected_itemized[utility][charge_type] actual_value = result[utility][charge_type] assert actual_value == expected_value From df561c0fb810cbb6489478fd00d33445bedad20c Mon Sep 17 00:00:00 2001 From: Fletcher Chapin Date: Tue, 9 Dec 2025 11:22:46 -0800 Subject: [PATCH 18/26] Fixed errors in rebase so that only two tests fail now --- eeco/costs.py | 186 ++++++----------------------------- eeco/tests/test_emissions.py | 2 +- 2 files changed, 33 insertions(+), 155 deletions(-) diff --git a/eeco/costs.py b/eeco/costs.py index ec661f5..b89f6de 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -821,7 +821,7 @@ def calculate_energy_cost( elif isinstance(consumption_data, (cp.Expression, pyo.Var, pyo.Param)): # For tiered charges, approximate extimated consumption being split evenly - # if we have a finite next_limit OR if limit > 0 + # Only necessary if we have a finite next_limit OR if current limit > 0 # NOTE: this convex approximation breaks global optimality guarantees if not np.isinf(next_limit) or (not np.isinf(limit) and limit > 0): if isinstance(consumption_estimate, (float, int)): @@ -831,17 +831,17 @@ def calculate_energy_cost( cumulative_consumption = np.cumsum(consumption_estimate) + prev_consumption total_consumption = cumulative_consumption[-1] - start_idx = np.argmax(cumulative_consumption > float(limit)) - # if not found argmax returns 0, but whole charge array should be zeroed - if (start_idx == 0) and (total_consumption <= float(limit)): - charge_array[:] = 0 - else: - charge_array[:start_idx] = 0 # 0 for charge array before the start index - end_idx = np.argmax(cumulative_consumption > float(next_limit)) - # before applying end_idx ensure (1) that next limit exists - # and (2) that it is higher than total consumption - if not (np.isinf(next_limit) or (total_consumption <= float(next_limit))): - charge_array[end_idx:] = 0 # 0 for charge array after the end index + start_idx = np.argmax(cumulative_consumption > float(limit)) + # if not found argmax returns 0, but whole charge array should be zeroed + if (start_idx == 0) and (total_consumption <= float(limit)): + charge_array[:] = 0 + else: + charge_array[:start_idx] = 0 # 0 for charge array before the start index + end_idx = np.argmax(cumulative_consumption > float(next_limit)) + # before applying end_idx ensure (1) that next limit exists + # and (2) that it is higher than total consumption + if not (np.isinf(next_limit) or (total_consumption <= float(next_limit))): + charge_array[end_idx:] = 0 # 0 for charge array after the end index charge_expr, model = ut.multiply( consumption_data, charge_array, model=model, varstr=varstr + "_multiply" @@ -1037,6 +1037,8 @@ def get_converted_consumption_data( "imports": converted_consumption, "exports": converted_consumption, } + else: + raise NotImplementedError return consumption_data_dict @@ -1180,10 +1182,11 @@ def calculate_cost( The model object associated with the problem. Only used in the case of Pyomo, so `None` by default. - additional_objective_terms : pyomo.Expression, list - Additional terms to be added to the objective function. - Can be a single pyomo Expression or a list of pyomo Expressions. - 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 varstr_alias_func: function Function to generate variable name for pyomo, @@ -1249,13 +1252,6 @@ def calculate_cost( charge_limit = int(limit_str) key_substr = "_".join([utility, charge_type, name, eff_start, eff_end]) next_limit = get_next_limit(key_substr, charge_limit, charge_dict.keys()) - varstr_converted = varstr + "_converted" if varstr is not None else None - converted_data, model = ut.multiply( - consumption_data_dict[utility], - conversion_factor, - model=model, - varstr=varstr_converted, - ) # Only apply demand_scale_factor if charge spans more than one day charge_duration_days = get_charge_array_duration(key) @@ -1285,7 +1281,7 @@ def calculate_cost( new_cost, model = calculate_demand_cost( charge_array, - converted_data, + consumption_data_dict[utility]["imports"], limit=charge_limit, next_limit=next_limit, prev_demand=prev_demand, @@ -1359,6 +1355,7 @@ def build_pyomo_costing( desired_charge_type=None, demand_scale_factor=1, additional_objective_terms=None, + decomposition_type=None, varstr_alias_func=default_varstr_alias_func, ): """ @@ -1428,15 +1425,16 @@ def build_pyomo_costing( Applied to monthly charges where end_date - start_date > 1 day. Default is 1 - additional_objective_terms : list + additional_objective_terms : pyomo.Expression, list Additional terms to be added to the objective function. - Must be a list of pyomo Expressions. + Can be a single pyomo Expression or a list of pyomo Expressions. + 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": To be implemented - - Default None: No decomposition, treats all consumption as imports + - "binary_variable": `NotImplementedError` + - None (default): No decomposition, treats all consumption as imports varstr_alias_func: function Function to generate variable name for pyomo, @@ -1481,135 +1479,15 @@ def build_pyomo_costing( desired_charge_type=desired_charge_type, demand_scale_factor=demand_scale_factor, model=model, + decomposition_type=decomposition_type, varstr_alias_func=varstr_alias_func, ) - # Initialize definition of conversion factors for each utility type - conversion_factors = {} - conversion_factors[ELECTRIC] = (1 * electric_consumption_units).to(u.kW).magnitude - conversion_factors[GAS] = ( - (1 * gas_consumption_units).to(u.meter**3 / u.day).magnitude - ) - - # Ensure consumption_data_dict has imports/exports structure for each utility - for utility in consumption_data_dict.keys(): - # Check if this utility already has imports/exports structure - if ( - isinstance(consumption_data_dict[utility], dict) - and "imports" in consumption_data_dict[utility] - and "exports" in consumption_data_dict[utility] - ): - continue - else: # create imports/exports - conversion_factor = conversion_factors[utility] - - converted_consumption, model = ut.multiply( - consumption_data_dict[utility], - conversion_factor, - model=model, - varstr=utility + "_converted", - ) - - if decomposition_type == "absolute_value": - # Decompose consumption data into positive and negative components - # with constraint that total = positive - negative - # (where negative is stored as positive magnitude) - pos_name, neg_name = ut._get_decomposed_var_names(utility) - imports, exports, model = ut.decompose_consumption( - converted_consumption, - model=model, - varstr=utility, - decomposition_type="absolute_value", - ) - - consumption_data_dict[utility] = { - "imports": imports, - "exports": exports, - } - elif decomposition_type is None: - consumption_data_dict[utility] = { - "imports": converted_consumption, - "exports": converted_consumption, - } - - for key, charge_array in charge_dict.items(): - utility, charge_type, name, eff_start, eff_end, limit_str = key.split("_") - varstr = ut.sanitize_varstr( - varstr_alias_func(utility, charge_type, name, eff_start, eff_end, limit_str) - ) - - # if we want itemized costs skip irrelvant portions of the bill - if (desired_utility and utility not in desired_utility) or ( - desired_charge_type and charge_type not in desired_charge_type - ): - continue - - if utility == ELECTRIC: - divisor = n_per_hour - elif utility == GAS: - divisor = n_per_day / conversion_factors[utility] - else: - raise ValueError("Invalid utility: " + utility) - - charge_limit = int(limit_str) - key_substr = "_".join([utility, charge_type, name, eff_start, eff_end]) - next_limit = get_next_limit(key_substr, charge_limit, charge_dict.keys()) - - # Only apply demand_scale_factor if charge spans more than one day - charge_duration_days = get_charge_array_duration(key) - effective_scale_factor = demand_scale_factor if charge_duration_days > 1 else 1 - - if charge_type == DEMAND: - if prev_demand_dict is not None: - prev_demand = prev_demand_dict[key][DEMAND] - prev_demand_cost = prev_demand_dict[key]["cost"] - else: - prev_demand = 0 - prev_demand_cost = 0 - new_cost, model = calculate_demand_cost( - charge_array, - consumption_data_dict[utility]["imports"], - limit=charge_limit, - next_limit=next_limit, - prev_demand=prev_demand, - prev_demand_cost=prev_demand_cost, - consumption_estimate=consumption_estimate, - scale_factor=effective_scale_factor, - model=model, - varstr=varstr, - ) - cost += new_cost - elif charge_type == ENERGY: - if prev_consumption_dict is not None: - prev_consumption = prev_consumption_dict[key] - else: - prev_consumption = 0 - new_cost, model = calculate_energy_cost( - charge_array, - consumption_data_dict[utility]["imports"], - divisor, - limit=charge_limit, - next_limit=next_limit, - prev_consumption=prev_consumption, - consumption_estimate=consumption_estimate, - model=model, - varstr=varstr, - ) - cost += new_cost - elif charge_type == EXPORT: - new_cost, model = calculate_export_revenue( - charge_array, - consumption_data_dict[utility]["exports"], - divisor, - model=model, - varstr=varstr, - ) - cost -= new_cost - elif charge_type == CUSTOMER: - cost += charge_array.sum() - else: - raise ValueError("Invalid charge_type: " + charge_type) - return cost, model + model.objective = pyo.Objective(expr=model.electricity_cost, sense=pyo.minimize) + if additional_objective_terms is not None: + for term in additional_objective_terms: + model.objective.expr += term + return model def calculate_itemized_cost( diff --git a/eeco/tests/test_emissions.py b/eeco/tests/test_emissions.py index 267378a..3b7206c 100644 --- a/eeco/tests/test_emissions.py +++ b/eeco/tests/test_emissions.py @@ -130,7 +130,7 @@ def test_calculate_grid_emissions_cvx( "VirtualDemand_Electricity_InFlow", u.kg / u.kWh, "15m", - 276375.8735600004, + pytest.approx(276375.8735600004), ), ], ) From 399c5cbdf3dbff50ae694ede9df9c7261248b16b Mon Sep 17 00:00:00 2001 From: Fletcher Chapin Date: Tue, 9 Dec 2025 11:44:00 -0800 Subject: [PATCH 19/26] Fixing flake8 errors --- eeco/costs.py | 4 ++-- eeco/tests/test_costs.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/eeco/costs.py b/eeco/costs.py index b89f6de..fc6e761 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -836,12 +836,12 @@ def calculate_energy_cost( if (start_idx == 0) and (total_consumption <= float(limit)): charge_array[:] = 0 else: - charge_array[:start_idx] = 0 # 0 for charge array before the start index + charge_array[:start_idx] = 0 # 0 for charge array before start index end_idx = np.argmax(cumulative_consumption > float(next_limit)) # before applying end_idx ensure (1) that next limit exists # and (2) that it is higher than total consumption if not (np.isinf(next_limit) or (total_consumption <= float(next_limit))): - charge_array[end_idx:] = 0 # 0 for charge array after the end index + charge_array[end_idx:] = 0 # 0 for charge array after end index charge_expr, model = ut.multiply( consumption_data, charge_array, model=model, varstr=varstr + "_multiply" diff --git a/eeco/tests/test_costs.py b/eeco/tests/test_costs.py index 13d803a..071da55 100644 --- a/eeco/tests/test_costs.py +++ b/eeco/tests/test_costs.py @@ -746,7 +746,7 @@ def test_get_charge_dict(start_dt, end_dt, billing_path, resolution, expected): { ELECTRIC: np.zeros(96), GAS: obtain_data_array( - "negative_purchases_within_tol.csv", + "negative_purchases_within_tol.csv", colname="wrrf_natural_gas_combust", ), }, @@ -3133,7 +3133,7 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): { ELECTRIC: np.zeros(96), GAS: obtain_data_array( - "negative_purchases_within_tol.csv", + "negative_purchases_within_tol.csv", colname="wrrf_natural_gas_combust", ), }, From 502009c4757545bfe8de45bcf5e8f43f3785974c Mon Sep 17 00:00:00 2001 From: Fletcher Chapin Date: Mon, 15 Dec 2025 14:36:03 -0800 Subject: [PATCH 20/26] Trying to address double unit conversion --- eeco/costs.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/eeco/costs.py b/eeco/costs.py index fc6e761..f255f64 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -1630,8 +1630,6 @@ def calculate_itemized_cost( cost, model = calculate_cost( charge_dict, consumption_data_dict, - electric_consumption_units=electric_consumption_units, - gas_consumption_units=gas_consumption_units, resolution=resolution, prev_demand_dict=prev_demand_dict, consumption_estimate=consumption_estimate, @@ -1655,8 +1653,6 @@ def calculate_itemized_cost( cost, model = calculate_cost( charge_dict, consumption_data_dict, - electric_consumption_units=electric_consumption_units, - gas_consumption_units=gas_consumption_units, resolution=resolution, prev_demand_dict=prev_demand_dict, prev_consumption_dict=prev_consumption_dict, From dd660b1cb605634f812b371e43d43597a44b67fe Mon Sep 17 00:00:00 2001 From: Fletcher Chapin Date: Mon, 15 Dec 2025 16:50:37 -0800 Subject: [PATCH 21/26] Fixed unit conversion errors and all tests now passing --- eeco/costs.py | 31 +- eeco/tests/data/output/billing_scaled.csv | 384 ++++++++++---------- eeco/tests/data/output/billing_unscaled.csv | 384 ++++++++++---------- eeco/tests/test_costs.py | 134 +++++-- 4 files changed, 503 insertions(+), 430 deletions(-) diff --git a/eeco/costs.py b/eeco/costs.py index f255f64..df1f184 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -83,7 +83,7 @@ def get_charge_name(charge, index=None): return name.replace("_", "-") -def create_charge_array(charge, datetime, effective_start_date, effective_end_date): +def create_charge_array(charge, datetime, effective_start_date, effective_end_date, utility="electric"): """Creates a single charge array based on the given parameters. Parameters @@ -143,9 +143,15 @@ def create_charge_array(charge, datetime, effective_start_date, effective_end_da charge_array = apply_charge * charge[CHARGE_METRIC] except KeyError: warnings.warn( - "Please switch to new 'charge (metric)' and 'charge (imperial)' format", + "Please switch to new 'charge (metric)' and 'charge (imperial)' format. " + "Current behavior assumes units of therms (imperial) " + "and converts to cubic meters (metric) when unspecified.", DeprecationWarning, ) + # if not specified, assume the old format (imperial units) + if utility == GAS: + # convert from therms to default units of cubic meters + charge[CHARGE] /= 2.83168 # therm per cubic meter of CH4 charge_array = apply_charge * charge[CHARGE] return charge_array @@ -287,9 +293,14 @@ def get_charge_dict(start_dt, end_dt, rate_data, resolution="15m"): charge_limits = effective_charges[BASIC_CHARGE_LIMIT] warnings.warn( "Please switch to new 'basic_charge_limit (metric)' " - "and 'basic_charge_limit (imperial)' format", + "and 'basic_charge_limit (imperial)' format. " + "Current behavior assumes units of therms (imperial) " + "and converts to cubic meters (metric) when unspecified.", DeprecationWarning, ) + if utility == GAS: + # convert from therms to default units of cubic meters + charge_limits *= 2.83168 # cubic meters per therm of CH4 for limit in np.unique(charge_limits): if np.isnan(limit): limit_charges = effective_charges.loc[ @@ -314,7 +325,9 @@ def get_charge_dict(start_dt, end_dt, rate_data, resolution="15m"): charge_array = np.array([charge[CHARGE]]) warnings.warn( "Please switch to new 'charge (metric)' " - "and 'charge (imperial)' format", + "and 'charge (imperial)' format. Current behavior " + "assumes units of therms (imperial) and converts " + "to cubic meters (metric) when unspecified.", DeprecationWarning, ) key_str = default_varstr_alias_func( @@ -331,7 +344,7 @@ def get_charge_dict(start_dt, end_dt, rate_data, resolution="15m"): new_start = start + dt.timedelta(days=day) new_end = new_start + dt.timedelta(days=1) charge_array = create_charge_array( - charge, datetime, new_start, new_end + charge, datetime, new_start, new_end, utility=utility ) key_str = default_varstr_alias_func( utility, @@ -344,7 +357,7 @@ def get_charge_dict(start_dt, end_dt, rate_data, resolution="15m"): add_to_charge_array(charge_dict, key_str, charge_array) else: charge_array = create_charge_array( - charge, datetime, start, new_end + charge, datetime, start, new_end, utility=utility ) key_str = default_varstr_alias_func( utility, @@ -751,7 +764,7 @@ def calculate_energy_cost( consumption_estimate : float, array Estimate of the total monthly energy consumption from baseline data - in kWh, therms, or cubic meters. + in kWh, therms OR cubic meters. Only used when `consumption_data` is cvxpy.Expression or pyomo.environ.Var for convex relaxation of tiered charges, while numpy.ndarray `consumption_data` will use actual consumption and ignore the estimate. @@ -1606,8 +1619,8 @@ def calculate_itemized_cost( if decomposition_type is not None: for utility in consumption_data_dict.keys(): if isinstance(consumption_data_dict[utility], cp.Variable): - raise ValueError( - "decomposition types are not supported with CVXPY objects. " + raise NotImplementedError( + "Decomposition types are not supported with CVXPY objects. " "Use Pyomo instead for problems requiring decomposition_type." ) diff --git a/eeco/tests/data/output/billing_scaled.csv b/eeco/tests/data/output/billing_scaled.csv index f1f0e26..ecf10bd 100644 --- a/eeco/tests/data/output/billing_scaled.csv +++ b/eeco/tests/data/output/billing_scaled.csv @@ -1,193 +1,193 @@ DateTime,gas_customer_0_20230409_20230410_0,gas_energy_0_20230409_20230410_0,electric_customer_0_20230409_20230410_0,electric_energy_0_20230409_20230410_0,electric_demand_maximum_20230409_20230410_0 -2023-04-09 00:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 00:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 00:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 00:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 01:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 01:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 01:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 01:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 02:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 02:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 02:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 02:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 03:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 03:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 03:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 03:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 04:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 04:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 04:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 04:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 05:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 05:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 05:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 05:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 06:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 06:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 06:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 06:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 07:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 07:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 07:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 07:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 08:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 08:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 08:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 08:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 09:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 09:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 09:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 09:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 10:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 10:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 10:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 10:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 11:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 11:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 11:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 11:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 12:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 12:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 12:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 12:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 13:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 13:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 13:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 13:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 14:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 14:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 14:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 14:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 15:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 15:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 15:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 15:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 16:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 16:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 16:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 16:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 17:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 17:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 17:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 17:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 18:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 18:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 18:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 18:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 19:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 19:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 19:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 19:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 20:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 20:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 20:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 20:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 21:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 21:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 21:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 21:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 22:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 22:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 22:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 22:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 23:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 23:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 23:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-09 23:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 00:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 00:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 00:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 00:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 01:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 01:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 01:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 01:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 02:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 02:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 02:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 02:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 03:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 03:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 03:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 03:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 04:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 04:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 04:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 04:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 05:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 05:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 05:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 05:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 06:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 06:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 06:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 06:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 07:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 07:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 07:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 07:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 08:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 08:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 08:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 08:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 09:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 09:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 09:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 09:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 10:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 10:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 10:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 10:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 11:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 11:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 11:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 11:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 12:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 12:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 12:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 12:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 13:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 13:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 13:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 13:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 14:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 14:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 14:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 14:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 15:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 15:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 15:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 15:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 16:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 16:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 16:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 16:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 17:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 17:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 17:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 17:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 18:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 18:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 18:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 18:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 19:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 19:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 19:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 19:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 20:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 20:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 20:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 20:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 21:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 21:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 21:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 21:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 22:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 22:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 22:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 22:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 23:00:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 23:15:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 23:30:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 -2023-04-10 23:45:00,0.03234027777,0.2837,0.104166666666667,0.019934,0.4752 +2023-04-09 00:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 00:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 00:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 00:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 01:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 01:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 01:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 01:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 02:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 02:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 02:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 02:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 03:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 03:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 03:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 03:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 04:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 04:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 04:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 04:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 05:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 05:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 05:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 05:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 06:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 06:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 06:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 06:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 07:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 07:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 07:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 07:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 08:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 08:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 08:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 08:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 09:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 09:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 09:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 09:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 10:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 10:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 10:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 10:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 11:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 11:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 11:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 11:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 12:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 12:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 12:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 12:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 13:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 13:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 13:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 13:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 14:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 14:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 14:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 14:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 15:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 15:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 15:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 15:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 16:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 16:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 16:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 16:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 17:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 17:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 17:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 17:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 18:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 18:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 18:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 18:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 19:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 19:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 19:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 19:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 20:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 20:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 20:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 20:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 21:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 21:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 21:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 21:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 22:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 22:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 22:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 22:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 23:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 23:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 23:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-09 23:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 00:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 00:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 00:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 00:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 01:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 01:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 01:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 01:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 02:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 02:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 02:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 02:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 03:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 03:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 03:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 03:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 04:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 04:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 04:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 04:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 05:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 05:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 05:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 05:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 06:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 06:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 06:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 06:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 07:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 07:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 07:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 07:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 08:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 08:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 08:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 08:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 09:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 09:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 09:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 09:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 10:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 10:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 10:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 10:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 11:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 11:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 11:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 11:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 12:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 12:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 12:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 12:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 13:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 13:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 13:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 13:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 14:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 14:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 14:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 14:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 15:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 15:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 15:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 15:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 16:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 16:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 16:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 16:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 17:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 17:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 17:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 17:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 18:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 18:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 18:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 18:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 19:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 19:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 19:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 19:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 20:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 20:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 20:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 20:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 21:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 21:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 21:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 21:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 22:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 22:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 22:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 22:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 23:00:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 23:15:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 23:30:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 +2023-04-10 23:45:00,0.03234027777,0.100187874336083,0.104166666666667,0.019934,0.4752 diff --git a/eeco/tests/data/output/billing_unscaled.csv b/eeco/tests/data/output/billing_unscaled.csv index 6fbcf69..4b8bd8b 100644 --- a/eeco/tests/data/output/billing_unscaled.csv +++ b/eeco/tests/data/output/billing_unscaled.csv @@ -1,193 +1,193 @@ DateTime,gas_customer_0_20230409_20230410_0,gas_energy_0_20230409_20230410_0,electric_customer_0_20230409_20230410_0,electric_energy_0_20230409_20230410_0,electric_demand_maximum_20230409_20230410_0 -2023-04-09 00:00:00,93.14,0.2837,300.0,0.019934,7.128 -2023-04-09 00:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 00:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 00:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 01:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 01:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 01:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 01:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 02:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 02:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 02:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 02:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 03:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 03:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 03:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 03:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 04:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 04:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 04:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 04:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 05:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 05:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 05:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 05:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 06:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 06:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 06:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 06:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 07:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 07:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 07:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 07:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 08:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 08:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 08:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 08:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 09:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 09:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 09:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 09:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 10:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 10:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 10:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 10:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 11:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 11:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 11:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 11:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 12:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 12:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 12:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 12:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 13:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 13:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 13:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 13:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 14:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 14:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 14:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 14:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 15:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 15:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 15:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 15:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 16:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 16:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 16:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 16:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 17:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 17:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 17:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 17:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 18:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 18:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 18:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 18:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 19:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 19:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 19:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 19:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 20:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 20:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 20:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 20:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 21:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 21:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 21:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 21:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 22:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 22:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 22:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 22:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 23:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 23:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 23:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-09 23:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 00:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 00:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 00:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 00:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 01:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 01:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 01:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 01:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 02:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 02:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 02:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 02:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 03:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 03:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 03:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 03:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 04:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 04:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 04:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 04:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 05:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 05:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 05:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 05:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 06:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 06:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 06:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 06:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 07:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 07:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 07:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 07:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 08:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 08:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 08:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 08:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 09:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 09:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 09:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 09:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 10:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 10:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 10:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 10:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 11:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 11:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 11:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 11:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 12:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 12:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 12:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 12:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 13:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 13:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 13:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 13:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 14:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 14:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 14:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 14:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 15:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 15:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 15:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 15:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 16:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 16:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 16:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 16:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 17:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 17:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 17:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 17:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 18:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 18:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 18:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 18:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 19:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 19:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 19:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 19:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 20:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 20:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 20:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 20:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 21:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 21:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 21:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 21:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 22:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 22:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 22:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 22:45:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 23:00:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 23:15:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 23:30:00,0.0,0.2837,0.0,0.019934,7.128 -2023-04-10 23:45:00,0.0,0.2837,0.0,0.019934,7.128 +2023-04-09 00:00:00,93.14,0.100187874336083,300.0,0.019934,7.128 +2023-04-09 00:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 00:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 00:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 01:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 01:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 01:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 01:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 02:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 02:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 02:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 02:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 03:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 03:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 03:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 03:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 04:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 04:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 04:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 04:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 05:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 05:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 05:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 05:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 06:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 06:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 06:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 06:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 07:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 07:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 07:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 07:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 08:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 08:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 08:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 08:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 09:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 09:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 09:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 09:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 10:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 10:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 10:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 10:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 11:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 11:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 11:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 11:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 12:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 12:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 12:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 12:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 13:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 13:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 13:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 13:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 14:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 14:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 14:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 14:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 15:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 15:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 15:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 15:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 16:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 16:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 16:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 16:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 17:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 17:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 17:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 17:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 18:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 18:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 18:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 18:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 19:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 19:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 19:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 19:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 20:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 20:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 20:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 20:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 21:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 21:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 21:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 21:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 22:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 22:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 22:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 22:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 23:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 23:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 23:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-09 23:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 00:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 00:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 00:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 00:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 01:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 01:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 01:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 01:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 02:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 02:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 02:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 02:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 03:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 03:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 03:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 03:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 04:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 04:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 04:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 04:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 05:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 05:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 05:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 05:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 06:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 06:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 06:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 06:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 07:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 07:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 07:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 07:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 08:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 08:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 08:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 08:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 09:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 09:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 09:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 09:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 10:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 10:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 10:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 10:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 11:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 11:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 11:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 11:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 12:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 12:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 12:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 12:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 13:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 13:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 13:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 13:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 14:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 14:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 14:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 14:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 15:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 15:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 15:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 15:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 16:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 16:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 16:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 16:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 17:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 17:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 17:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 17:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 18:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 18:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 18:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 18:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 19:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 19:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 19:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 19:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 20:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 20:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 20:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 20:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 21:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 21:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 21:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 21:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 22:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 22:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 22:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 22:45:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 23:00:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 23:15:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 23:30:00,0,0.100187874336083,0,0.019934,7.128 +2023-04-10 23:45:00,0,0.100187874336083,0,0.019934,7.128 diff --git a/eeco/tests/test_costs.py b/eeco/tests/test_costs.py index 071da55..45a3c1a 100644 --- a/eeco/tests/test_costs.py +++ b/eeco/tests/test_costs.py @@ -469,7 +469,8 @@ def test_create_charge_array( "electric_energy_6_20240531_20240601_0": np.zeros(48), "electric_demand_maximum_20240531_20240601_0": np.ones(48) * 7.128, "gas_customer_0_20240531_20240601_0": np.array([93.14]), - "gas_energy_0_20240531_20240601_0": np.ones(48) * 0.2837, + # converted from 0.2837 therms + "gas_energy_0_20240531_20240601_0": np.ones(48) * 0.10018787433608317, "gas_energy_1_20240531_20240601_0": np.zeros(48), }, ), @@ -500,12 +501,12 @@ def test_create_charge_array( "gas_energy_0_20231231_20240101_0": np.concatenate( [ np.zeros(24), - np.ones(24) * 0.2837, + np.ones(24) * 0.10018787433608317, # converted from 0.2837 therms ] ), "gas_energy_1_20231231_20240101_0": np.concatenate( [ - np.ones(24) * 0.454, + np.ones(24) * 0.16032885071759523, # converted from 0.454 therms np.zeros(24), ] ), @@ -613,6 +614,9 @@ def test_get_charge_dict(start_dt, end_dt, billing_path, resolution, expected): result = costs.get_charge_dict(start_dt, end_dt, tariff_df, resolution=resolution) assert result.keys() == expected.keys() for key, val in result.items(): + print(key) + print(result[key][0]) + print(expected[key][0]) assert (result[key] == expected[key]).all() @@ -737,25 +741,26 @@ def test_get_charge_dict(start_dt, end_dt, billing_path, resolution, expected): ), # negative values within tolerance (i.e., should be treated as zeros) ( + # dictionary manually converted from $/therm to $/m3 { - "electric_energy_0_2024-08-01_2024-08-31_0": np.ones(2976) * 0.05, - "gas_energy_0_2021-08-01_2024-08-31_0": np.ones(2976) * 0.570905, - "gas_energy_0_2021-08-01_2024-08-31_250": np.ones(2976) * 0.415764, - "gas_energy_0_2021-08-01_2024-08-31_4167": np.ones(2976) * 0.311744, + "electric_energy_0_2024-08-01_2024-08-31_0": np.ones(2976) * 0.05 / 2.83168, + "gas_energy_0_2021-08-01_2024-08-31_0": np.ones(2976) * 0.570905 / 2.83168, + "gas_energy_0_2021-08-01_2024-08-31_708": np.ones(2976) * 0.415764 / 2.83168, + "gas_energy_0_2021-08-01_2024-08-31_11800": np.ones(2976) * 0.311744 / 2.83168, }, { ELECTRIC: np.zeros(96), GAS: obtain_data_array( "negative_purchases_within_tol.csv", colname="wrrf_natural_gas_combust", - ), + ) / 24, # convert from cubic meters / day to cubic meters / hour }, "15m", None, 0, None, None, - pytest.approx(9.0), + pytest.approx(2720.68223669), False, False, ), @@ -1857,7 +1862,7 @@ def test_calculate_demand_costs( {ELECTRIC: np.arange(96), GAS: np.arange(96)}, { "gas_energy_0_20240710_20240710_0": 0, - "gas_energy_0_20240710_20240710_5000": 0, + "gas_energy_0_20240710_20240710_14158": 0, "electric_customer_0_20240710_20240710_0": 0, "electric_energy_0_20240710_20240710_0": 0, "electric_energy_1_20240710_20240710_0": 0, @@ -1885,7 +1890,7 @@ def test_calculate_demand_costs( {ELECTRIC: np.arange(96), GAS: np.arange(96)}, { "gas_energy_0_20240710_20240710_0": 0, - "gas_energy_0_20240710_20240710_5000": 0, + "gas_energy_0_20240710_20240710_14158": 0, "electric_customer_0_20240710_20240710_0": 0, "electric_energy_0_20240710_20240710_0": 0, "electric_energy_1_20240710_20240710_0": 0, @@ -1910,10 +1915,10 @@ def test_calculate_demand_costs( np.datetime64("2024-07-11"), # Summer weekday input_dir + "billing_pge.csv", [ELECTRIC, GAS], - {ELECTRIC: np.arange(96), GAS: np.repeat(np.array([5100 / 24]), 96)}, + {ELECTRIC: np.arange(96), GAS: np.repeat(np.array([5100 * 2.83168 / 24]), 96)}, { "gas_energy_0_20240710_20240710_0": 0, - "gas_energy_0_20240710_20240710_5000": 0, + "gas_energy_0_20240710_20240710_14158": 0, "electric_customer_0_20240710_20240710_0": 0, "electric_energy_0_20240710_20240710_0": 0, "electric_energy_1_20240710_20240710_0": 0, @@ -1931,7 +1936,7 @@ def test_calculate_demand_costs( "electric_energy_13_20240710_20240710_0": 0, }, 0, - pytest.approx(200.016195), + pytest.approx(200.0996), ), ( np.datetime64("2024-07-13"), # Summer weekend @@ -1978,10 +1983,11 @@ def test_calculate_demand_costs( np.datetime64("2024-03-10"), # Winter weekend input_dir + "billing_pge.csv", GAS, - {ELECTRIC: np.arange(96), GAS: np.repeat(np.array([5100 / 24]), 96)}, + # converted from therms to cubic meters + {ELECTRIC: np.arange(96), GAS: np.repeat(np.array([5100 * 2.83168 / 24]), 96)}, None, 0, - pytest.approx(59.1), + pytest.approx(59.18348), # converted from therms to cubic meters ), ( np.datetime64("2024-03-09"), # Winter weekend @@ -1990,7 +1996,7 @@ def test_calculate_demand_costs( GAS, {ELECTRIC: np.arange(96), GAS: np.ones(96)}, None, - 5100, + 5100 * 2.83168, # converted from therms to cubic meters pytest.approx(0), ), ], @@ -3124,11 +3130,12 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): ), # negative values within tolerance (i.e., should be treated as zeros) ( + # manually converted from therms to cubic meters to mimic automated conversion in `create_charge_array` { - "electric_energy_0_2024-08-01_2024-08-31_0": np.ones(2976) * 0.05, - "gas_energy_0_2021-08-01_2024-08-31_0": np.ones(2976) * 0.570905, - "gas_energy_0_2021-08-01_2024-08-31_250": np.ones(2976) * 0.415764, - "gas_energy_0_2021-08-01_2024-08-31_4167": np.ones(2976) * 0.311744, + "electric_energy_0_2024-08-01_2024-08-31_0": np.ones(2976) * 0.05 / 2.83168, + "gas_energy_0_2021-08-01_2024-08-31_0": np.ones(2976) * 0.570905 / 2.83168, + "gas_energy_0_2021-08-01_2024-08-31_708": np.ones(2976) * 0.415764 / 2.83168, + "gas_energy_0_2021-08-01_2024-08-31_11800": np.ones(2976) * 0.311744 / 2.83168, }, { ELECTRIC: np.zeros(96), @@ -3139,7 +3146,7 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): }, "15m", None, - pytest.approx(153276.17678277715), + pytest.approx(2720.68223669), { "electric": { "energy": 0.0, @@ -3148,7 +3155,7 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): "demand": 0.0, }, "gas": { - "energy": 0.0, + "energy": 2720.6840707162232, "export": 0.0, "customer": 0.0, "demand": 0.0, @@ -3230,8 +3237,8 @@ def test_calculate_itemized_cost_np( "15m", "absolute_value", 240, - None, # No expected cost - should raise error - None, # No expected itemized - should raise error + None, # No expected cost - should raise NotImplementedError + None, # No expected itemized - should raise NotImplementedError ), ], ) @@ -3248,7 +3255,7 @@ def test_calculate_itemized_cost_cvx( cvx_vars, constraints = setup_cvx_vars_constraints(consumption_data_dict) if decomposition_type: - with pytest.raises(ValueError): + with pytest.raises(NotImplementedError): costs.calculate_itemized_cost( charge_dict, cvx_vars, @@ -3359,7 +3366,7 @@ def test_calculate_itemized_cost_cvx( }, }, ), - # energy and export charges with MW instead of kW units + # energy and export charges with MW units and MW timeseries ( { "electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05, @@ -3376,6 +3383,37 @@ def test_calculate_itemized_cost_cvx( 240, u.MW, u.meters**3 / u.hour, + pytest.approx(4.5), + { + "electric": { + "energy": pytest.approx(6.0), # 48*0.01 MW*1000*0.05/4 + "export": pytest.approx(-1.5), # -48*0.005 MW*1000*0.025/4 + "customer": 0.0, + "demand": 0.0, + }, + "gas": { + "energy": 0.0, + "export": 0.0, + "customer": 0.0, + "demand": 0.0, + }, + }, + ), + # energy and export charges with MW instead but kW timeseries + ( + { + "electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05, + "electric_export_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.025, + }, + { + ELECTRIC: np.concatenate([np.ones(48) * 10, -np.ones(48) * 5]), + GAS: np.ones(96), + }, + "15m", + "absolute_value", + 240, + u.MW, + u.meters**3 / u.hour, pytest.approx(4500), { "electric": { @@ -3392,6 +3430,24 @@ def test_calculate_itemized_cost_cvx( }, }, ), + # `binary_variable` for `decomposition_type` should raise `NotImplementedError` + ( + { + "electric_energy_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.05, + "electric_export_0_2024-07-10_2024-07-10_0": np.ones(96) * 0.025, + }, + { + ELECTRIC: np.concatenate([np.ones(48) * 10, -np.ones(48) * 5]), + GAS: np.ones(96), + }, + "15m", + "binary_variable", + 240, + None, + None, + None, # No expected cost - should raise NotImplementedError + None, # No expected cost - should raise NotImplementedError + ), ], ) def test_calculate_itemized_cost_pyo( @@ -3419,17 +3475,21 @@ def test_calculate_itemized_cost_pyo( if gas_consumption_units is not None: kwargs["gas_consumption_units"] = gas_consumption_units - result, model = costs.calculate_itemized_cost(charge_dict, pyo_vars, **kwargs) - solve_pyo_problem( - model, result["total"], decomposition_type, charge_dict, consumption_data_dict - ) + if decomposition_type == "binary_variable": + with pytest.raises(NotImplementedError): + result, model = costs.calculate_itemized_cost(charge_dict, pyo_vars, **kwargs) + else: + result, model = costs.calculate_itemized_cost(charge_dict, pyo_vars, **kwargs) + solve_pyo_problem( + model, result["total"], decomposition_type, charge_dict, consumption_data_dict + ) - assert pyo.value(result["total"]) == 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 + assert pyo.value(result["total"]) == 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 @pytest.mark.parametrize( From 1aa760c7bfb268e60c3a45f8ac4281c5a2af1f85 Mon Sep 17 00:00:00 2001 From: Fletcher Chapin Date: Mon, 15 Dec 2025 16:52:11 -0800 Subject: [PATCH 22/26] Autoreformatted with black --- eeco/costs.py | 10 +++++-- eeco/tests/test_costs.py | 61 ++++++++++++++++++++++++++++++---------- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/eeco/costs.py b/eeco/costs.py index df1f184..8133c4b 100644 --- a/eeco/costs.py +++ b/eeco/costs.py @@ -83,7 +83,9 @@ def get_charge_name(charge, index=None): return name.replace("_", "-") -def create_charge_array(charge, datetime, effective_start_date, effective_end_date, utility="electric"): +def create_charge_array( + charge, datetime, effective_start_date, effective_end_date, utility="electric" +): """Creates a single charge array based on the given parameters. Parameters @@ -344,7 +346,11 @@ def get_charge_dict(start_dt, end_dt, rate_data, resolution="15m"): new_start = start + dt.timedelta(days=day) new_end = new_start + dt.timedelta(days=1) charge_array = create_charge_array( - charge, datetime, new_start, new_end, utility=utility + charge, + datetime, + new_start, + new_end, + utility=utility, ) key_str = default_varstr_alias_func( utility, diff --git a/eeco/tests/test_costs.py b/eeco/tests/test_costs.py index 45a3c1a..b222934 100644 --- a/eeco/tests/test_costs.py +++ b/eeco/tests/test_costs.py @@ -501,12 +501,14 @@ def test_create_charge_array( "gas_energy_0_20231231_20240101_0": np.concatenate( [ np.zeros(24), - np.ones(24) * 0.10018787433608317, # converted from 0.2837 therms + np.ones(24) + * 0.10018787433608317, # converted from 0.2837 therms ] ), "gas_energy_1_20231231_20240101_0": np.concatenate( [ - np.ones(24) * 0.16032885071759523, # converted from 0.454 therms + np.ones(24) + * 0.16032885071759523, # converted from 0.454 therms np.zeros(24), ] ), @@ -743,17 +745,26 @@ def test_get_charge_dict(start_dt, end_dt, billing_path, resolution, expected): ( # dictionary manually converted from $/therm to $/m3 { - "electric_energy_0_2024-08-01_2024-08-31_0": np.ones(2976) * 0.05 / 2.83168, - "gas_energy_0_2021-08-01_2024-08-31_0": np.ones(2976) * 0.570905 / 2.83168, - "gas_energy_0_2021-08-01_2024-08-31_708": np.ones(2976) * 0.415764 / 2.83168, - "gas_energy_0_2021-08-01_2024-08-31_11800": np.ones(2976) * 0.311744 / 2.83168, + "electric_energy_0_2024-08-01_2024-08-31_0": np.ones(2976) + * 0.05 + / 2.83168, + "gas_energy_0_2021-08-01_2024-08-31_0": np.ones(2976) + * 0.570905 + / 2.83168, + "gas_energy_0_2021-08-01_2024-08-31_708": np.ones(2976) + * 0.415764 + / 2.83168, + "gas_energy_0_2021-08-01_2024-08-31_11800": np.ones(2976) + * 0.311744 + / 2.83168, }, { ELECTRIC: np.zeros(96), GAS: obtain_data_array( "negative_purchases_within_tol.csv", colname="wrrf_natural_gas_combust", - ) / 24, # convert from cubic meters / day to cubic meters / hour + ) + / 24, # convert from cubic meters / day to cubic meters / hour }, "15m", None, @@ -1915,7 +1926,10 @@ def test_calculate_demand_costs( np.datetime64("2024-07-11"), # Summer weekday input_dir + "billing_pge.csv", [ELECTRIC, GAS], - {ELECTRIC: np.arange(96), GAS: np.repeat(np.array([5100 * 2.83168 / 24]), 96)}, + { + ELECTRIC: np.arange(96), + GAS: np.repeat(np.array([5100 * 2.83168 / 24]), 96), + }, { "gas_energy_0_20240710_20240710_0": 0, "gas_energy_0_20240710_20240710_14158": 0, @@ -1984,7 +1998,10 @@ def test_calculate_demand_costs( input_dir + "billing_pge.csv", GAS, # converted from therms to cubic meters - {ELECTRIC: np.arange(96), GAS: np.repeat(np.array([5100 * 2.83168 / 24]), 96)}, + { + ELECTRIC: np.arange(96), + GAS: np.repeat(np.array([5100 * 2.83168 / 24]), 96), + }, None, 0, pytest.approx(59.18348), # converted from therms to cubic meters @@ -3132,10 +3149,18 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): ( # manually converted from therms to cubic meters to mimic automated conversion in `create_charge_array` { - "electric_energy_0_2024-08-01_2024-08-31_0": np.ones(2976) * 0.05 / 2.83168, - "gas_energy_0_2021-08-01_2024-08-31_0": np.ones(2976) * 0.570905 / 2.83168, - "gas_energy_0_2021-08-01_2024-08-31_708": np.ones(2976) * 0.415764 / 2.83168, - "gas_energy_0_2021-08-01_2024-08-31_11800": np.ones(2976) * 0.311744 / 2.83168, + "electric_energy_0_2024-08-01_2024-08-31_0": np.ones(2976) + * 0.05 + / 2.83168, + "gas_energy_0_2021-08-01_2024-08-31_0": np.ones(2976) + * 0.570905 + / 2.83168, + "gas_energy_0_2021-08-01_2024-08-31_708": np.ones(2976) + * 0.415764 + / 2.83168, + "gas_energy_0_2021-08-01_2024-08-31_11800": np.ones(2976) + * 0.311744 + / 2.83168, }, { ELECTRIC: np.zeros(96), @@ -3477,11 +3502,17 @@ def test_calculate_itemized_cost_pyo( if decomposition_type == "binary_variable": with pytest.raises(NotImplementedError): - result, model = costs.calculate_itemized_cost(charge_dict, pyo_vars, **kwargs) + result, model = costs.calculate_itemized_cost( + charge_dict, pyo_vars, **kwargs + ) else: result, model = costs.calculate_itemized_cost(charge_dict, pyo_vars, **kwargs) solve_pyo_problem( - model, result["total"], decomposition_type, charge_dict, consumption_data_dict + model, + result["total"], + decomposition_type, + charge_dict, + consumption_data_dict, ) assert pyo.value(result["total"]) == expected_cost From b1dc01f7c178dd44c649cb4b33a29c5262cfa297 Mon Sep 17 00:00:00 2001 From: Fletcher Chapin Date: Tue, 16 Dec 2025 13:23:38 -0800 Subject: [PATCH 23/26] Creating codecov.yml file to try to address patch coverage --- codecov.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..79f2fc5 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,10 @@ +coverage: + status: + project: + default: + target: auto + patch: + default: + enabled: yes + target: 100% + threshold: 5% From 3b8cadde48a906788f2153bf27d2410b270684d4 Mon Sep 17 00:00:00 2001 From: Fletcher Chapin Date: Tue, 16 Dec 2025 14:11:16 -0800 Subject: [PATCH 24/26] Fixed flake8 errors --- eeco/tests/test_costs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/eeco/tests/test_costs.py b/eeco/tests/test_costs.py index b222934..286f175 100644 --- a/eeco/tests/test_costs.py +++ b/eeco/tests/test_costs.py @@ -3147,7 +3147,8 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): ), # negative values within tolerance (i.e., should be treated as zeros) ( - # manually converted from therms to cubic meters to mimic automated conversion in `create_charge_array` + # manually converted from therms to cubic meters to + # mimic automated conversion in `create_charge_array` { "electric_energy_0_2024-08-01_2024-08-31_0": np.ones(2976) * 0.05 From f26de35a8786b3c5d8ee90d9ccee39368e83e6b2 Mon Sep 17 00:00:00 2001 From: Fletcher Chapin Date: Tue, 16 Dec 2025 14:20:04 -0800 Subject: [PATCH 25/26] Fixing documentation formatting --- docs/how_to_advanced.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/how_to_advanced.rst b/docs/how_to_advanced.rst index cae20ea..78d64b9 100644 --- a/docs/how_to_advanced.rst +++ b/docs/how_to_advanced.rst @@ -159,7 +159,7 @@ When `demand_scale_factor < 1.0`, demand charges are proportionally reduced to r For more details on applying the sequential optimization strategy, see: -  Bolorinos, J., Mauter, M.S. & Rajagopal, R. Integrated Energy Flexibility Management at Wastewater Treatment Facilities. *Environ. Sci. Technol.* **57**, 46, 18362–18371 (2023). DOI: [10.1021/acs.est.3c00365](https://doi.org/10.1021/acs.est.3c00365) + Bolorinos, J., Mauter, M.S. & Rajagopal, R. Integrated Energy Flexibility Management at Wastewater Treatment Facilities. *Environ. Sci. Technol.* **57**, 46, 18362–18371 (2023). DOI: [10.1021/acs.est.3c00365](https://doi.org/10.1021/acs.est.3c00365) In `bibtex` format: @@ -185,11 +185,11 @@ In `bibtex` format: .. _decompose-exports: How to Use `decomposition_type` -============================== +=============================== The `decomposition_type` parameter allows you to decompose consumption data into positive (imports) and negative (exports) components. This is useful when you have export charges or credits in your rate structure. +Options include: -Options: - Default `None` - `"binary_variable"`: To be implemented - `"absolute_value"` From 03da2db2331b0ec8f4f360cecd69fabe8bf9e7c6 Mon Sep 17 00:00:00 2001 From: Fletcher Chapin Date: Tue, 16 Dec 2025 14:27:32 -0800 Subject: [PATCH 26/26] Fixing whitespace error --- eeco/tests/test_costs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eeco/tests/test_costs.py b/eeco/tests/test_costs.py index 286f175..3c4e03c 100644 --- a/eeco/tests/test_costs.py +++ b/eeco/tests/test_costs.py @@ -3147,7 +3147,7 @@ def test_parametrize_rate_data_different_files(billing_file, variant_params): ), # negative values within tolerance (i.e., should be treated as zeros) ( - # manually converted from therms to cubic meters to + # manually converted from therms to cubic meters to # mimic automated conversion in `create_charge_array` { "electric_energy_0_2024-08-01_2024-08-31_0": np.ones(2976)