From 816082c93c87b4f477d993eb4139e04c4eca4243 Mon Sep 17 00:00:00 2001 From: Alan Pinkert Date: Wed, 29 Nov 2023 01:55:56 +0000 Subject: [PATCH 01/15] added implementation for get_outputs_normalized Co-authored-by: Debajyoti Debnath Co-authored-by: Jonathan Kwan --- rules-engine/src/rules_engine/engine.py | 72 +++++++++++-------- .../src/rules_engine/pydantic_models.py | 7 ++ 2 files changed, 48 insertions(+), 31 deletions(-) diff --git a/rules-engine/src/rules_engine/engine.py b/rules-engine/src/rules_engine/engine.py index dea4a06e..56efde6c 100644 --- a/rules-engine/src/rules_engine/engine.py +++ b/rules-engine/src/rules_engine/engine.py @@ -12,6 +12,7 @@ DhwInput, FuelType, NaturalGasBillingInput, + NormalizedBillingPeriodRecordInput, OilPropaneBillingInput, SummaryInput, SummaryOutput, @@ -51,21 +52,40 @@ def get_outputs_normalized( summary_input: SummaryInput, dhw_input: Optional[DhwInput], temperature_input: TemperatureInput, - billing_periods: Any, + billing_periods: List[NormalizedBillingPeriodRecordInput], ) -> Tuple[SummaryOutput, BalancePointGraph]: - # smush together temp and billing periods - - # home = Home(summary_input, temperature_input, dhw_input, billing_periods) + # Build a list of lists of temperatures, where each list of temperatures contains all the temperatures + # in the corresponding billing period + intermediate_billing_periods = [] + initial_balance_point = 60 + + for billing_period in billing_periods: + temperatures = [] + for i, d in enumerate(temperature_input.dates): + # the HEAT Excel sheet is inclusive of the temperatures that fall on both the start and end dates + if billing_period.period_start_date <= d <= billing_period.period_end_date: + temperatures.append(temperature_input[i]) + + analysis_type = date_to_analysis_type(billing_period.period_end_date) + if billing_period.inclusion_override: + analysis_type = billing_period.inclusion_override + + intermediate_billing_period = BillingPeriod( + avg_temps=temperatures, + usage=billing_period.usage, + balance_point=initial_balance_point, + analysis_type=analysis_type + ) + intermediate_billing_periods.append(intermediate_billing_period) + + home = Home( + summary_input=summary_input, + billing_periods=intermediate_billing_periods, + initial_balance_point=initial_balance_point, + has_boiler_for_dhw=dhw_input is not None, + same_fuel_dhw_heating=dhw_input is not None, + ) # home.calculate() - - # summary_input: SummaryInput, summary_input - # temps: List[List[float]], temperature_input - # usages: List[float], billing_periods - # inclusion_codes: List[int], billing_periods* - # initial_balance_point: float = 60, n/a - # has_boiler_for_dhw: bool = False, dhw_input - # same_fuel_dhw_heating: bool = False, dhw_input - # return (home.summaryOutput, home.balancePointGraph) raise NotImplementedError @@ -190,9 +210,7 @@ class Home: def __init__( self, summary_input: SummaryInput, - temps: List[List[float]], - usages: List[float], - inclusion_codes: List[int], + billing_periods: List[BillingPeriod], initial_balance_point: float = 60, has_boiler_for_dhw: bool = False, same_fuel_dhw_heating: bool = False, @@ -203,31 +221,23 @@ def __init__( self.balance_point = initial_balance_point self.has_boiler_for_dhw = has_boiler_for_dhw self.same_fuel_dhw_heating = same_fuel_dhw_heating - self._initialize_billing_periods(temps, usages, inclusion_codes) + self._initialize_billing_periods(billing_periods) def _initialize_billing_periods( - self, temps: List[List[float]], usages: List[float], inclusion_codes: List[int] + self, billing_periods: List[BillingPeriod] ) -> None: """ TODO """ - # assume for now that temps and usages have the same number of elements - self.bills_winter = [] self.bills_summer = [] self.bills_shoulder = [] # winter months 1; summer months -1; shoulder months 0 - for i, usage in enumerate(usages): - billing_period = BillingPeriod( - avg_temps=temps[i], - usage=usage, - balance_point=self.balance_point, - inclusion_code=inclusion_codes[i], - ) - if inclusion_codes[i] == 1: + for billing_period in billing_periods: + if billing_period.analysis_type == AnalysisType.INCLUDE: self.bills_winter.append(billing_period) - elif inclusion_codes[i] == -1: + elif billing_period.analysis_type == AnalysisType.DO_NOT_INCLUDE: self.bills_summer.append(billing_period) else: self.bills_shoulder.append(billing_period) @@ -464,12 +474,12 @@ def __init__( avg_temps: List[float], usage: float, balance_point: float, - inclusion_code: int, + analysis_type: AnalysisType, ) -> None: self.avg_temps = avg_temps self.usage = usage self.balance_point = balance_point - self.inclusion_code = inclusion_code + self.analysis_type = analysis_type self.days = len(self.avg_temps) self.total_hdd = period_hdd(self.avg_temps, self.balance_point) diff --git a/rules-engine/src/rules_engine/pydantic_models.py b/rules-engine/src/rules_engine/pydantic_models.py index 5d4e1c1e..2a0e023b 100644 --- a/rules-engine/src/rules_engine/pydantic_models.py +++ b/rules-engine/src/rules_engine/pydantic_models.py @@ -80,6 +80,13 @@ class NaturalGasBillingInput(BaseModel): records: List[NaturalGasBillingRecordInput] +class NormalizedBillingPeriodRecordInput(BaseModel): + period_start_date: date + period_end_date: date + usage: float + inclusion_override: Optional[AnalysisType] + + class TemperatureInput(BaseModel): dates: List[date] temperatures: List[float] From c2ae6e759f4cd30c75c49259be91e3971e83d025 Mon Sep 17 00:00:00 2001 From: Alan Pinkert Date: Wed, 29 Nov 2023 02:20:50 +0000 Subject: [PATCH 02/15] fix lint, mypy --- rules-engine/src/rules_engine/engine.py | 103 +++++++++--------- .../tests/test_rules_engine/test_engine.py | 37 +++---- 2 files changed, 67 insertions(+), 73 deletions(-) diff --git a/rules-engine/src/rules_engine/engine.py b/rules-engine/src/rules_engine/engine.py index 56efde6c..5c73381e 100644 --- a/rules-engine/src/rules_engine/engine.py +++ b/rules-engine/src/rules_engine/engine.py @@ -27,7 +27,7 @@ def get_outputs_oil_propane( oil_propane_billing_input: OilPropaneBillingInput, ) -> Tuple[SummaryOutput, BalancePointGraph]: # TODO: normalize oil & propane to billing periods - billing_periods = NotImplementedError() + billing_periods: List[NormalizedBillingPeriodRecordInput] = [] return get_outputs_normalized( summary_input, dhw_input, temperature_input, billing_periods @@ -41,7 +41,7 @@ def get_outputs_natural_gas( natural_gas_billing_input: NaturalGasBillingInput, ) -> Tuple[SummaryOutput, BalancePointGraph]: # TODO: normalize natural gas to billing periods - billing_periods = NotImplementedError() + billing_periods: List[NormalizedBillingPeriodRecordInput] = [] return get_outputs_normalized( summary_input, dhw_input, temperature_input, billing_periods @@ -64,7 +64,7 @@ def get_outputs_normalized( for i, d in enumerate(temperature_input.dates): # the HEAT Excel sheet is inclusive of the temperatures that fall on both the start and end dates if billing_period.period_start_date <= d <= billing_period.period_end_date: - temperatures.append(temperature_input[i]) + temperatures.append(temperature_input.temperatures[i]) analysis_type = date_to_analysis_type(billing_period.period_end_date) if billing_period.inclusion_override: @@ -73,8 +73,7 @@ def get_outputs_normalized( intermediate_billing_period = BillingPeriod( avg_temps=temperatures, usage=billing_period.usage, - balance_point=initial_balance_point, - analysis_type=analysis_type + analysis_type=analysis_type, ) intermediate_billing_periods.append(intermediate_billing_period) @@ -223,9 +222,7 @@ def __init__( self.same_fuel_dhw_heating = same_fuel_dhw_heating self._initialize_billing_periods(billing_periods) - def _initialize_billing_periods( - self, billing_periods: List[BillingPeriod] - ) -> None: + def _initialize_billing_periods(self, billing_periods: List[BillingPeriod]) -> None: """ TODO """ @@ -235,6 +232,8 @@ def _initialize_billing_periods( # winter months 1; summer months -1; shoulder months 0 for billing_period in billing_periods: + billing_period.set_initial_balance_point(self.balance_point) + if billing_period.analysis_type == AnalysisType.INCLUDE: self.bills_winter.append(billing_period) elif billing_period.analysis_type == AnalysisType.DO_NOT_INCLUDE: @@ -247,46 +246,46 @@ def _initialize_billing_periods( for billing_period in self.bills_winter: self.initialize_ua(billing_period) - def _initialize_billing_periods_reworked( - self, billingperiods: NaturalGasBillingInput - ) -> None: - """ - TODO - """ - # assume for now that temps and usages have the same number of elements - - self.bills_winter = [] - self.bills_summer = [] - self.bills_shoulder = [] - - # ngb_start_date = billingperiods.period_start_date - # ngbs = billingperiods.records - - # TODO: fix these - usages: List[float] = [] - inclusion_codes: List[int] = [] - temps: List[List[float]] = [] - - # winter months 1; summer months -1; shoulder months 0 - for i, usage in enumerate(usages): - billing_period = BillingPeriod( - avg_temps=temps[i], - usage=usage, - balance_point=self.balance_point, - inclusion_code=inclusion_codes[i], - ) - - if inclusion_codes[i] == 1: - self.bills_winter.append(billing_period) - elif inclusion_codes[i] == -1: - self.bills_summer.append(billing_period) - else: - self.bills_shoulder.append(billing_period) - - self._calculate_avg_summer_usage() - self._calculate_avg_non_heating_usage() - for billing_period in self.bills_winter: - self.initialize_ua(billing_period) + # def _initialize_billing_periods_reworked( + # self, billingperiods: NaturalGasBillingInput + # ) -> None: + # """ + # TODO + # """ + # # assume for now that temps and usages have the same number of elements + + # self.bills_winter = [] + # self.bills_summer = [] + # self.bills_shoulder = [] + + # # ngb_start_date = billingperiods.period_start_date + # # ngbs = billingperiods.records + + # # TODO: fix these + # usages: List[float] = [] + # inclusion_codes: List[int] = [] + # temps: List[List[float]] = [] + + # # winter months 1; summer months -1; shoulder months 0 + # for i, usage in enumerate(usages): + # billing_period = BillingPeriod( + # avg_temps=temps[i], + # usage=usage, + # balance_point=self.balance_point, + # inclusion_code=inclusion_codes[i], + # ) + + # if inclusion_codes[i] == 1: + # self.bills_winter.append(billing_period) + # elif inclusion_codes[i] == -1: + # self.bills_summer.append(billing_period) + # else: + # self.bills_shoulder.append(billing_period) + + # self._calculate_avg_summer_usage() + # self._calculate_avg_non_heating_usage() + # for billing_period in self.bills_winter: + # self.initialize_ua(billing_period) def _calculate_avg_summer_usage(self) -> None: """ @@ -466,20 +465,22 @@ def calculate_partial_ua(self, billing_period: BillingPeriod) -> float: class BillingPeriod: avg_heating_usage: float + balance_point: float partial_ua: float ua: float + total_hdd: float def __init__( self, avg_temps: List[float], usage: float, - balance_point: float, analysis_type: AnalysisType, ) -> None: self.avg_temps = avg_temps self.usage = usage - self.balance_point = balance_point self.analysis_type = analysis_type - self.days = len(self.avg_temps) + + def set_initial_balance_point(self, balance_point: float) -> None: + self.balance_point = balance_point self.total_hdd = period_hdd(self.avg_temps, self.balance_point) diff --git a/rules-engine/tests/test_rules_engine/test_engine.py b/rules-engine/tests/test_rules_engine/test_engine.py index 0f756a7e..3f1cdc6a 100644 --- a/rules-engine/tests/test_rules_engine/test_engine.py +++ b/rules-engine/tests/test_rules_engine/test_engine.py @@ -3,6 +3,7 @@ from rules_engine import engine from rules_engine.pydantic_models import ( + AnalysisType, BalancePointGraph, DhwInput, FuelType, @@ -47,15 +48,12 @@ def test_average_indoor_temp(): def test_bp_ua_estimates(): - daily_temps_lists = [ - [28, 29, 30, 29], - [32, 35, 35, 38], - [41, 43, 42, 42], - [72, 71, 70, 69], + billing_periods = [ + engine.BillingPeriod([28, 29, 30, 29], 50, AnalysisType.INCLUDE), + engine.BillingPeriod([32, 35, 35, 38], 45, AnalysisType.INCLUDE), + engine.BillingPeriod([41, 43, 42, 42], 30, AnalysisType.INCLUDE), + engine.BillingPeriod([72, 71, 70, 69], 0.96, AnalysisType.DO_NOT_INCLUDE), ] - - usages = [50, 45, 30, 0.96] - inclusion_codes = [1, 1, 1, -1] heat_sys_efficiency = 0.88 living_area = 1000 thermostat_set_point = 68 @@ -73,9 +71,7 @@ def test_bp_ua_estimates(): home = engine.Home( summary_input, - daily_temps_lists, - usages, - inclusion_codes, + billing_periods, initial_balance_point=58, ) @@ -92,15 +88,14 @@ def test_bp_ua_estimates(): def test_bp_ua_with_outlier(): - daily_temps_lists = [ - [41.7, 41.6, 32, 25.4], - [28, 29, 30, 29], - [32, 35, 35, 38], - [41, 43, 42, 42], - [72, 71, 70, 69], + billing_periods = [ + engine.BillingPeriod([41.7, 41.6, 32, 25.4], 60, AnalysisType.INCLUDE), + engine.BillingPeriod([28, 29, 30, 29], 50, AnalysisType.INCLUDE), + engine.BillingPeriod([32, 35, 35, 38], 45, AnalysisType.INCLUDE), + engine.BillingPeriod([41, 43, 42, 42], 30, AnalysisType.INCLUDE), + engine.BillingPeriod([72, 71, 70, 69], 0.96, AnalysisType.DO_NOT_INCLUDE), ] - usages = [60, 50, 45, 30, 0.96] - inclusion_codes = [1, 1, 1, 1, -1] + heat_sys_efficiency = 0.88 living_area = 1000 @@ -119,9 +114,7 @@ def test_bp_ua_with_outlier(): home = engine.Home( summary_input, - daily_temps_lists, - usages, - inclusion_codes, + billing_periods, initial_balance_point=58, ) From be7987a5f40e32f738b3bc4cf36a04f340a541b8 Mon Sep 17 00:00:00 2001 From: Debajyoti Debnath Date: Thu, 30 Nov 2023 16:32:06 -0500 Subject: [PATCH 03/15] Use binary search to get temperature list for billing periods. In function get_outputs_normalized(), to get temperatures within a certain billing period, binary search (using the bisect module) is used to speed up the search process. Speed up is at least 30x. --- rules-engine/src/rules_engine/engine.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/rules-engine/src/rules_engine/engine.py b/rules-engine/src/rules_engine/engine.py index 5c73381e..5a9c64a5 100644 --- a/rules-engine/src/rules_engine/engine.py +++ b/rules-engine/src/rules_engine/engine.py @@ -1,5 +1,6 @@ from __future__ import annotations +import bisect import statistics as sts from datetime import date from typing import Any, List, Optional, Tuple @@ -60,18 +61,27 @@ def get_outputs_normalized( initial_balance_point = 60 for billing_period in billing_periods: - temperatures = [] - for i, d in enumerate(temperature_input.dates): - # the HEAT Excel sheet is inclusive of the temperatures that fall on both the start and end dates - if billing_period.period_start_date <= d <= billing_period.period_end_date: - temperatures.append(temperature_input.temperatures[i]) + # temperatures = [] + # for i, d in enumerate(temperature_input.dates): + # the HEAT Excel sheet is inclusive of the temperatures that fall on both the start and end dates + # if billing_period.period_start_date <= d <= billing_period.period_end_date: + # temperatures.append(temperature_input.temperatures[i]) + + start_idx = bisect.bisect_left( + temperature_input.dates, billing_period.period_start_date + ) + end_idx = bisect.bisect_left( + temperature_input.dates, billing_period.period_end_date + ) - analysis_type = date_to_analysis_type(billing_period.period_end_date) + # analysis_type = date_to_analysis_type(billing_period.period_end_date) + analysis_type = AnalysisType.INCLUDE if billing_period.inclusion_override: analysis_type = billing_period.inclusion_override intermediate_billing_period = BillingPeriod( - avg_temps=temperatures, + # avg_temps=temperatures, + avg_temps=temperature_input.temperatures[start_idx : end_idx + 1], usage=billing_period.usage, analysis_type=analysis_type, ) From 6a46074a1a0cf50584d273fd51a01bfb062e9753 Mon Sep 17 00:00:00 2001 From: Debajyoti Debnath Date: Thu, 30 Nov 2023 16:39:19 -0500 Subject: [PATCH 04/15] Revert to deducing analysis type from billing period date. Unintended change left over from previous commit. --- rules-engine/src/rules_engine/engine.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/rules-engine/src/rules_engine/engine.py b/rules-engine/src/rules_engine/engine.py index 5a9c64a5..a7b6b7e4 100644 --- a/rules-engine/src/rules_engine/engine.py +++ b/rules-engine/src/rules_engine/engine.py @@ -74,8 +74,7 @@ def get_outputs_normalized( temperature_input.dates, billing_period.period_end_date ) - # analysis_type = date_to_analysis_type(billing_period.period_end_date) - analysis_type = AnalysisType.INCLUDE + analysis_type = date_to_analysis_type(billing_period.period_end_date) if billing_period.inclusion_override: analysis_type = billing_period.inclusion_override From 2fecc0e3d6be7396618473449e3cd4e2dc24adf7 Mon Sep 17 00:00:00 2001 From: Alan Pinkert Date: Wed, 6 Dec 2023 00:50:05 +0000 Subject: [PATCH 05/15] cleanup comments Co-authored-by: Jonathan Kwan Co-authored-by: Debajyoti Debnath Co-authored-by: KKaempen --- rules-engine/src/rules_engine/engine.py | 69 ++----------------------- 1 file changed, 4 insertions(+), 65 deletions(-) diff --git a/rules-engine/src/rules_engine/engine.py b/rules-engine/src/rules_engine/engine.py index a7b6b7e4..d932667a 100644 --- a/rules-engine/src/rules_engine/engine.py +++ b/rules-engine/src/rules_engine/engine.py @@ -61,26 +61,21 @@ def get_outputs_normalized( initial_balance_point = 60 for billing_period in billing_periods: - # temperatures = [] - # for i, d in enumerate(temperature_input.dates): - # the HEAT Excel sheet is inclusive of the temperatures that fall on both the start and end dates - # if billing_period.period_start_date <= d <= billing_period.period_end_date: - # temperatures.append(temperature_input.temperatures[i]) + # the HEAT Excel sheet is inclusive of the temperatures that fall on both the start and end dates start_idx = bisect.bisect_left( temperature_input.dates, billing_period.period_start_date ) end_idx = bisect.bisect_left( temperature_input.dates, billing_period.period_end_date - ) + ) + 1 analysis_type = date_to_analysis_type(billing_period.period_end_date) if billing_period.inclusion_override: analysis_type = billing_period.inclusion_override intermediate_billing_period = BillingPeriod( - # avg_temps=temperatures, - avg_temps=temperature_input.temperatures[start_idx : end_idx + 1], + avg_temps=temperature_input.temperatures[start_idx : end_idx], usage=billing_period.usage, analysis_type=analysis_type, ) @@ -232,9 +227,6 @@ def __init__( self._initialize_billing_periods(billing_periods) def _initialize_billing_periods(self, billing_periods: List[BillingPeriod]) -> None: - """ - TODO - """ self.bills_winter = [] self.bills_summer = [] self.bills_shoulder = [] @@ -255,47 +247,6 @@ def _initialize_billing_periods(self, billing_periods: List[BillingPeriod]) -> N for billing_period in self.bills_winter: self.initialize_ua(billing_period) - # def _initialize_billing_periods_reworked( - # self, billingperiods: NaturalGasBillingInput - # ) -> None: - # """ - # TODO - # """ - # # assume for now that temps and usages have the same number of elements - - # self.bills_winter = [] - # self.bills_summer = [] - # self.bills_shoulder = [] - - # # ngb_start_date = billingperiods.period_start_date - # # ngbs = billingperiods.records - - # # TODO: fix these - # usages: List[float] = [] - # inclusion_codes: List[int] = [] - # temps: List[List[float]] = [] - - # # winter months 1; summer months -1; shoulder months 0 - # for i, usage in enumerate(usages): - # billing_period = BillingPeriod( - # avg_temps=temps[i], - # usage=usage, - # balance_point=self.balance_point, - # inclusion_code=inclusion_codes[i], - # ) - - # if inclusion_codes[i] == 1: - # self.bills_winter.append(billing_period) - # elif inclusion_codes[i] == -1: - # self.bills_summer.append(billing_period) - # else: - # self.bills_shoulder.append(billing_period) - - # self._calculate_avg_summer_usage() - # self._calculate_avg_non_heating_usage() - # for billing_period in self.bills_winter: - # self.initialize_ua(billing_period) - def _calculate_avg_summer_usage(self) -> None: """ Calculate average daily summer usage @@ -320,21 +271,9 @@ def _calculate_boiler_usage(self, fuel_multiplier: float) -> float: return 0 * fuel_multiplier - """ - your pseudocode looks correct provided there's outer logic that - check whether the home uses the same fuel for DHW as for heating. If not, anhu=0. - - From an OO design viewpoint, I don't see Summer_billingPeriods as a direct property - of the home. Rather, it's a property of the Location (an object defining the weather - station, and the Winter, Summer and Shoulder billing periods. Of course, Location - would be a property of the Home. - """ - def _calculate_avg_non_heating_usage(self) -> None: """ - Calculate avg non heating usage for this Home - Args: - #use_same_fuel_DHW_heating + Calculate avg non heating usage for this home """ if self.fuel_type == FuelType.GAS: From ddfd5a33d7a0856c689357d53058bb89ac97070c Mon Sep 17 00:00:00 2001 From: Alan Pinkert Date: Wed, 6 Dec 2023 01:36:15 +0000 Subject: [PATCH 06/15] Normalize input records Co-authored-by: Jonathan Kwan Co-authored-by: Debajyoti Debnath Co-authored-by: KKaempen --- rules-engine/src/rules_engine/engine.py | 30 ++++++++++++++++--- .../src/rules_engine/pydantic_models.py | 15 +++++++--- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/rules-engine/src/rules_engine/engine.py b/rules-engine/src/rules_engine/engine.py index d932667a..c285d160 100644 --- a/rules-engine/src/rules_engine/engine.py +++ b/rules-engine/src/rules_engine/engine.py @@ -2,7 +2,7 @@ import bisect import statistics as sts -from datetime import date +from datetime import date, timedelta from typing import Any, List, Optional, Tuple import numpy as np @@ -25,11 +25,24 @@ def get_outputs_oil_propane( summary_input: SummaryInput, dhw_input: Optional[DhwInput], temperature_input: TemperatureInput, - oil_propane_billing_input: OilPropaneBillingInput, + oil_propane_billing_input: OilPropaneBillingInput ) -> Tuple[SummaryOutput, BalancePointGraph]: - # TODO: normalize oil & propane to billing periods billing_periods: List[NormalizedBillingPeriodRecordInput] = [] + last_date = oil_propane_billing_input.preceding_delivery_date + for input_val in oil_propane_billing_input.records: + start_date = last_date + timedelta(days=1) + inclusion = AnalysisType.INCLUDE if input_val.inclusion_override else AnalysisType.DO_NOT_INCLUDE + billing_periods.append( + NormalizedBillingPeriodRecordInput( + period_start_date=start_date, + period_end_date=input_val.period_end_date, + usage=input_val.gallons, + inclusion_override=inclusion + ) + ) + last_date = input_val.period_end_date + return get_outputs_normalized( summary_input, dhw_input, temperature_input, billing_periods ) @@ -41,9 +54,18 @@ def get_outputs_natural_gas( temperature_input: TemperatureInput, natural_gas_billing_input: NaturalGasBillingInput, ) -> Tuple[SummaryOutput, BalancePointGraph]: - # TODO: normalize natural gas to billing periods billing_periods: List[NormalizedBillingPeriodRecordInput] = [] + for input_val in natural_gas_billing_input.records: + billing_periods.append( + NormalizedBillingPeriodRecordInput( + period_start_date=input_val.period_start_date, + period_end_date=input_val.period_end_date, + usage=input_val.usage_therms, + inclusion_override=input_val.inclusion_override + ) + ) + return get_outputs_normalized( summary_input, dhw_input, temperature_input, billing_periods ) diff --git a/rules-engine/src/rules_engine/pydantic_models.py b/rules-engine/src/rules_engine/pydantic_models.py index 2a0e023b..ec9b9d5d 100644 --- a/rules-engine/src/rules_engine/pydantic_models.py +++ b/rules-engine/src/rules_engine/pydantic_models.py @@ -18,9 +18,9 @@ class AnalysisType(Enum): class FuelType(Enum): """Enum for fuel types. Values are BTU per usage""" - GAS = 100000 - OIL = 139600 - PROPANE = 91333 + GAS = 100000 # BTU / therm + OIL = 139600 # BTU / gal + PROPANE = 91333 # BTU / gal def validate_fuel_type(value: Any) -> FuelType: @@ -57,7 +57,7 @@ class DhwInput(BaseModel): stand_by_losses: float = Field(description="DHW!B6") -class OilPropaneBillingInput(BaseModel): +class OilPropaneBillingRecordInput(BaseModel): """From Oil-Propane tab""" period_end_date: date = Field(description="Oil-Propane!B") @@ -65,6 +65,13 @@ class OilPropaneBillingInput(BaseModel): inclusion_override: Optional[bool] = Field(description="Oil-Propane!F") +class OilPropaneBillingInput(BaseModel): + """From Oil-Propane tab. Container for holding all rows of the billing input table.""" + + records: List[OilPropaneBillingRecordInput] + preceding_delivery_date: date = Field(description="Oil-Propane!B6") + + class NaturalGasBillingRecordInput(BaseModel): """From Natural Gas tab. A single row of the Billing input table.""" From e58a72e2204a1851e9037bacca0a65c12fd1eb49 Mon Sep 17 00:00:00 2001 From: Alan Pinkert Date: Wed, 6 Dec 2023 01:57:34 +0000 Subject: [PATCH 07/15] start on outputs --- rules-engine/src/rules_engine/engine.py | 32 ++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/rules-engine/src/rules_engine/engine.py b/rules-engine/src/rules_engine/engine.py index c285d160..f9aa3285 100644 --- a/rules-engine/src/rules_engine/engine.py +++ b/rules-engine/src/rules_engine/engine.py @@ -50,7 +50,6 @@ def get_outputs_oil_propane( def get_outputs_natural_gas( summary_input: SummaryInput, - dhw_input: Optional[DhwInput], temperature_input: TemperatureInput, natural_gas_billing_input: NaturalGasBillingInput, ) -> Tuple[SummaryOutput, BalancePointGraph]: @@ -67,7 +66,7 @@ def get_outputs_natural_gas( ) return get_outputs_normalized( - summary_input, dhw_input, temperature_input, billing_periods + summary_input, None, temperature_input, billing_periods ) @@ -110,7 +109,34 @@ def get_outputs_normalized( has_boiler_for_dhw=dhw_input is not None, same_fuel_dhw_heating=dhw_input is not None, ) - # home.calculate() + home.calculate() + + # TODO: rename functions to "get_..." and don't use "_output" suffix + # average_indoor_temperature_output = average_indoor_temp( + # tstat_set=summary_input.thermostat_set_point, + # tstat_setback=summary_input.setback_temperature, + # setback_daily_hrs=summary_input.setback_hours_per_day + # ) + # average_heat_load_output = average_heat_load( + # design_set_point=..., # TODO: where does this come from? + # avg_indoor_temp=average_indoor_temperature_output, + # balance_point=home.balance_point, + # design_temp=home., + # ua, + # ) + # maximum_heat_load_output = max_heat_load(...) + + # summary_output = SummaryOutput( + # estimated_balance_point=, + # other_fuel_usage=home.avg_non_heating_usage, + # average_indoor_temperature=average_indoor_temperature_output, + # difference_between_ti_and_tbp=, + # design_temperature=, + # whole_home_heat_loss_rate=home.avg_ua, + # standard_deviation_of_heat_loss_rate=home.stdev_pct, + # average_heat_load=, + # maximum_heat_load=, + # ) # return (home.summaryOutput, home.balancePointGraph) raise NotImplementedError From ec9d5fa5c207915a19dba30bab83bee5e7f41138 Mon Sep 17 00:00:00 2001 From: Alan Pinkert Date: Wed, 6 Dec 2023 01:58:37 +0000 Subject: [PATCH 08/15] lints --- rules-engine/src/rules_engine/engine.py | 34 +++++++++++-------- .../src/rules_engine/pydantic_models.py | 6 ++-- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/rules-engine/src/rules_engine/engine.py b/rules-engine/src/rules_engine/engine.py index f9aa3285..f93dea68 100644 --- a/rules-engine/src/rules_engine/engine.py +++ b/rules-engine/src/rules_engine/engine.py @@ -25,20 +25,24 @@ def get_outputs_oil_propane( summary_input: SummaryInput, dhw_input: Optional[DhwInput], temperature_input: TemperatureInput, - oil_propane_billing_input: OilPropaneBillingInput + oil_propane_billing_input: OilPropaneBillingInput, ) -> Tuple[SummaryOutput, BalancePointGraph]: billing_periods: List[NormalizedBillingPeriodRecordInput] = [] last_date = oil_propane_billing_input.preceding_delivery_date for input_val in oil_propane_billing_input.records: start_date = last_date + timedelta(days=1) - inclusion = AnalysisType.INCLUDE if input_val.inclusion_override else AnalysisType.DO_NOT_INCLUDE + inclusion = ( + AnalysisType.INCLUDE + if input_val.inclusion_override + else AnalysisType.DO_NOT_INCLUDE + ) billing_periods.append( NormalizedBillingPeriodRecordInput( - period_start_date=start_date, - period_end_date=input_val.period_end_date, - usage=input_val.gallons, - inclusion_override=inclusion + period_start_date=start_date, + period_end_date=input_val.period_end_date, + usage=input_val.gallons, + inclusion_override=inclusion, ) ) last_date = input_val.period_end_date @@ -58,10 +62,10 @@ def get_outputs_natural_gas( for input_val in natural_gas_billing_input.records: billing_periods.append( NormalizedBillingPeriodRecordInput( - period_start_date=input_val.period_start_date, - period_end_date=input_val.period_end_date, - usage=input_val.usage_therms, - inclusion_override=input_val.inclusion_override + period_start_date=input_val.period_start_date, + period_end_date=input_val.period_end_date, + usage=input_val.usage_therms, + inclusion_override=input_val.inclusion_override, ) ) @@ -82,21 +86,21 @@ def get_outputs_normalized( initial_balance_point = 60 for billing_period in billing_periods: - # the HEAT Excel sheet is inclusive of the temperatures that fall on both the start and end dates start_idx = bisect.bisect_left( temperature_input.dates, billing_period.period_start_date ) - end_idx = bisect.bisect_left( - temperature_input.dates, billing_period.period_end_date - ) + 1 + end_idx = ( + bisect.bisect_left(temperature_input.dates, billing_period.period_end_date) + + 1 + ) analysis_type = date_to_analysis_type(billing_period.period_end_date) if billing_period.inclusion_override: analysis_type = billing_period.inclusion_override intermediate_billing_period = BillingPeriod( - avg_temps=temperature_input.temperatures[start_idx : end_idx], + avg_temps=temperature_input.temperatures[start_idx:end_idx], usage=billing_period.usage, analysis_type=analysis_type, ) diff --git a/rules-engine/src/rules_engine/pydantic_models.py b/rules-engine/src/rules_engine/pydantic_models.py index ec9b9d5d..57901a51 100644 --- a/rules-engine/src/rules_engine/pydantic_models.py +++ b/rules-engine/src/rules_engine/pydantic_models.py @@ -18,9 +18,9 @@ class AnalysisType(Enum): class FuelType(Enum): """Enum for fuel types. Values are BTU per usage""" - GAS = 100000 # BTU / therm - OIL = 139600 # BTU / gal - PROPANE = 91333 # BTU / gal + GAS = 100000 # BTU / therm + OIL = 139600 # BTU / gal + PROPANE = 91333 # BTU / gal def validate_fuel_type(value: Any) -> FuelType: From 53af648b4c928824d0e869d6b1b5cab28c3a117f Mon Sep 17 00:00:00 2001 From: Jonathan Kwan Date: Wed, 13 Dec 2023 01:58:59 +0000 Subject: [PATCH 09/15] Continued filling out the summary outputs Co-authored-by: Erika Nesse Co-authored-by: Debajyoti Debnath Co-authored-by: thatoldplatitude Co-authored-by: dwindleduck Co-authored-by: mdfasano --- rules-engine/src/rules_engine/engine.py | 68 ++++++++++--------- .../src/rules_engine/pydantic_models.py | 7 +- .../tests/test_rules_engine/test_engine.py | 10 ++- .../tests/test_rules_engine/test_examples.py | 2 +- 4 files changed, 48 insertions(+), 39 deletions(-) diff --git a/rules-engine/src/rules_engine/engine.py b/rules-engine/src/rules_engine/engine.py index f93dea68..641e42ee 100644 --- a/rules-engine/src/rules_engine/engine.py +++ b/rules-engine/src/rules_engine/engine.py @@ -10,6 +10,7 @@ from rules_engine.pydantic_models import ( AnalysisType, BalancePointGraph, + Constants, DhwInput, FuelType, NaturalGasBillingInput, @@ -115,35 +116,36 @@ def get_outputs_normalized( ) home.calculate() - # TODO: rename functions to "get_..." and don't use "_output" suffix - # average_indoor_temperature_output = average_indoor_temp( - # tstat_set=summary_input.thermostat_set_point, - # tstat_setback=summary_input.setback_temperature, - # setback_daily_hrs=summary_input.setback_hours_per_day - # ) - # average_heat_load_output = average_heat_load( - # design_set_point=..., # TODO: where does this come from? - # avg_indoor_temp=average_indoor_temperature_output, - # balance_point=home.balance_point, - # design_temp=home., - # ua, - # ) - # maximum_heat_load_output = max_heat_load(...) - - # summary_output = SummaryOutput( - # estimated_balance_point=, - # other_fuel_usage=home.avg_non_heating_usage, - # average_indoor_temperature=average_indoor_temperature_output, - # difference_between_ti_and_tbp=, - # design_temperature=, - # whole_home_heat_loss_rate=home.avg_ua, - # standard_deviation_of_heat_loss_rate=home.stdev_pct, - # average_heat_load=, - # maximum_heat_load=, - # ) - # return (home.summaryOutput, home.balancePointGraph) + average_indoor_temperature = get_average_indoor_temperature( + thermostat_set_point=summary_input.thermostat_set_point, + setback_temperature=summary_input.setback_temperature, + setback_hours_per_day=summary_input.setback_hours_per_day + ) + average_heat_load = get_average_heat_load( + design_set_point=Constants.DESIGN_SET_POINT, + avg_indoor_temp=average_indoor_temperature, + balance_point=home.balance_point, + design_temp=summary_input.design_temperature, + ua=home.avg_ua, + ) + maximum_heat_load= get_maximum_heat_load( + design_set_point=Constants.DESIGN_SET_POINT, + design_temp=summary_input.design_temperature, + ua=home.avg_ua) + + summary_output = SummaryOutput( + estimated_balance_point=home.balance_point, + other_fuel_usage=home.avg_non_heating_usage, + average_indoor_temperature=average_indoor_temperature, + difference_between_ti_and_tbp=average_indoor_temperature - home.balance_point, + design_temperature=summary_input.design_temperature, + whole_home_heat_loss_rate=home.avg_ua, + standard_deviation_of_heat_loss_rate=home.stdev_pct, + average_heat_load=average_heat_load, + maximum_heat_load=maximum_heat_load, + ) + return (summary_output) # TODO: add BalancePointGraph - raise NotImplementedError def date_to_analysis_type(d: date) -> AnalysisType: @@ -193,8 +195,8 @@ def period_hdd(avg_temps: List[float], balance_point: float) -> float: return sum([hdd(temp, balance_point) for temp in avg_temps]) -def average_indoor_temp( - tstat_set: float, tstat_setback: float, setback_daily_hrs: float +def get_average_indoor_temperature( + thermostat_set_point: float, setback_temperature: float, setback_hours_per_day: float ) -> float: """ Calculates the average indoor temperature. @@ -209,11 +211,11 @@ def average_indoor_temp( # again, not sure if we should check for valid values here or whether we can # assume those kinds of checks will be handled at the point of user entry return ( - (24 - setback_daily_hrs) * tstat_set + setback_daily_hrs * tstat_setback + (24 - setback_hours_per_day) * thermostat_set_point + setback_hours_per_day * setback_temperature ) / 24 -def average_heat_load( +def get_average_heat_load( design_set_point: float, avg_indoor_temp: float, balance_point: float, @@ -237,7 +239,7 @@ def average_heat_load( return (design_set_point - (avg_indoor_temp - balance_point) - design_temp) * ua -def max_heat_load(design_set_point: float, design_temp: float, ua: float) -> float: +def get_maximum_heat_load(design_set_point: float, design_temp: float, ua: float) -> float: """ Calculate the max heat load. diff --git a/rules-engine/src/rules_engine/pydantic_models.py b/rules-engine/src/rules_engine/pydantic_models.py index 57901a51..86a90d06 100644 --- a/rules-engine/src/rules_engine/pydantic_models.py +++ b/rules-engine/src/rules_engine/pydantic_models.py @@ -3,6 +3,7 @@ """ from datetime import date +from dataclasses import dataclass from enum import Enum from typing import Annotated, Any, List, Optional @@ -47,6 +48,7 @@ class SummaryInput(BaseModel): thermostat_set_point: float = Field(description="Summary!B17") setback_temperature: Optional[float] = Field(description="Summary!B18") setback_hours_per_day: Optional[float] = Field(description="Summary!B19") + design_temperature: float = Field(description="TDesign") class DhwInput(BaseModel): @@ -132,6 +134,7 @@ class BalancePointGraph(BaseModel): records: List[BalancePointGraphRow] - +@dataclass class Constants: - balance_point_sensitivity: float = 0.5 + BALANCE_POINT_SENSITIVITY: float = 0.5 + DESIGN_SET_POINT: float = 70 diff --git a/rules-engine/tests/test_rules_engine/test_engine.py b/rules-engine/tests/test_rules_engine/test_engine.py index 3f1cdc6a..c24864b4 100644 --- a/rules-engine/tests/test_rules_engine/test_engine.py +++ b/rules-engine/tests/test_rules_engine/test_engine.py @@ -37,14 +37,14 @@ def test_period_hdd(temps, expected_result): assert engine.period_hdd(temps, 60) == expected_result -def test_average_indoor_temp(): +def test_get_average_indoor_temperature(): set_temp = 68 setback = 62 setback_hrs = 8 # when there is no setback, just put 0 for the setback parameters - assert engine.average_indoor_temp(set_temp, 0, 0) == set_temp - assert engine.average_indoor_temp(set_temp, setback, setback_hrs) == 66 + assert engine.get_average_indoor_temperature(set_temp, 0, 0) == set_temp + assert engine.get_average_indoor_temperature(set_temp, setback, setback_hrs) == 66 def test_bp_ua_estimates(): @@ -60,6 +60,7 @@ def test_bp_ua_estimates(): setback_temperature = 60 setback_hours_per_day = 8 fuel_type = FuelType.GAS + design_temperature = 60 summary_input = SummaryInput( living_area=living_area, fuel_type=fuel_type, @@ -67,6 +68,7 @@ def test_bp_ua_estimates(): thermostat_set_point=thermostat_set_point, setback_temperature=setback_temperature, setback_hours_per_day=setback_hours_per_day, + design_temperature=design_temperature ) home = engine.Home( @@ -103,6 +105,7 @@ def test_bp_ua_with_outlier(): setback_temperature = 60 setback_hours_per_day = 8 fuel_type = FuelType.GAS + design_temperature = 60 summary_input = SummaryInput( living_area=living_area, fuel_type=fuel_type, @@ -110,6 +113,7 @@ def test_bp_ua_with_outlier(): thermostat_set_point=thermostat_set_point, setback_temperature=setback_temperature, setback_hours_per_day=setback_hours_per_day, + design_temperature=design_temperature ) home = engine.Home( diff --git a/rules-engine/tests/test_rules_engine/test_examples.py b/rules-engine/tests/test_rules_engine/test_examples.py index b4c0520e..84f320ee 100644 --- a/rules-engine/tests/test_rules_engine/test_examples.py +++ b/rules-engine/tests/test_rules_engine/test_examples.py @@ -103,7 +103,7 @@ def data(request): def test_average_indoor_temp(data: Example) -> None: - avg_indoor_temp = engine.average_indoor_temp( + avg_indoor_temp = engine.get_average_indoor_temperature( data.summary.thermostat_set_point, data.summary.setback_temperature or 0, data.summary.setback_hours_per_day or 0, From be9b24ec45d44e4779eed1acfe44292ecb57f40f Mon Sep 17 00:00:00 2001 From: Jonathan Kwan Date: Wed, 13 Dec 2023 02:00:52 +0000 Subject: [PATCH 10/15] Fixed black --- rules-engine/src/rules_engine/engine.py | 27 +++++++++++-------- .../src/rules_engine/pydantic_models.py | 1 + .../tests/test_rules_engine/test_engine.py | 4 +-- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/rules-engine/src/rules_engine/engine.py b/rules-engine/src/rules_engine/engine.py index 641e42ee..68ebb874 100644 --- a/rules-engine/src/rules_engine/engine.py +++ b/rules-engine/src/rules_engine/engine.py @@ -119,19 +119,20 @@ def get_outputs_normalized( average_indoor_temperature = get_average_indoor_temperature( thermostat_set_point=summary_input.thermostat_set_point, setback_temperature=summary_input.setback_temperature, - setback_hours_per_day=summary_input.setback_hours_per_day + setback_hours_per_day=summary_input.setback_hours_per_day, ) average_heat_load = get_average_heat_load( - design_set_point=Constants.DESIGN_SET_POINT, + design_set_point=Constants.DESIGN_SET_POINT, avg_indoor_temp=average_indoor_temperature, balance_point=home.balance_point, design_temp=summary_input.design_temperature, ua=home.avg_ua, ) - maximum_heat_load= get_maximum_heat_load( - design_set_point=Constants.DESIGN_SET_POINT, - design_temp=summary_input.design_temperature, - ua=home.avg_ua) + maximum_heat_load = get_maximum_heat_load( + design_set_point=Constants.DESIGN_SET_POINT, + design_temp=summary_input.design_temperature, + ua=home.avg_ua, + ) summary_output = SummaryOutput( estimated_balance_point=home.balance_point, @@ -144,8 +145,7 @@ def get_outputs_normalized( average_heat_load=average_heat_load, maximum_heat_load=maximum_heat_load, ) - return (summary_output) # TODO: add BalancePointGraph - + return summary_output # TODO: add BalancePointGraph def date_to_analysis_type(d: date) -> AnalysisType: @@ -196,7 +196,9 @@ def period_hdd(avg_temps: List[float], balance_point: float) -> float: def get_average_indoor_temperature( - thermostat_set_point: float, setback_temperature: float, setback_hours_per_day: float + thermostat_set_point: float, + setback_temperature: float, + setback_hours_per_day: float, ) -> float: """ Calculates the average indoor temperature. @@ -211,7 +213,8 @@ def get_average_indoor_temperature( # again, not sure if we should check for valid values here or whether we can # assume those kinds of checks will be handled at the point of user entry return ( - (24 - setback_hours_per_day) * thermostat_set_point + setback_hours_per_day * setback_temperature + (24 - setback_hours_per_day) * thermostat_set_point + + setback_hours_per_day * setback_temperature ) / 24 @@ -239,7 +242,9 @@ def get_average_heat_load( return (design_set_point - (avg_indoor_temp - balance_point) - design_temp) * ua -def get_maximum_heat_load(design_set_point: float, design_temp: float, ua: float) -> float: +def get_maximum_heat_load( + design_set_point: float, design_temp: float, ua: float +) -> float: """ Calculate the max heat load. diff --git a/rules-engine/src/rules_engine/pydantic_models.py b/rules-engine/src/rules_engine/pydantic_models.py index 86a90d06..720a7702 100644 --- a/rules-engine/src/rules_engine/pydantic_models.py +++ b/rules-engine/src/rules_engine/pydantic_models.py @@ -134,6 +134,7 @@ class BalancePointGraph(BaseModel): records: List[BalancePointGraphRow] + @dataclass class Constants: BALANCE_POINT_SENSITIVITY: float = 0.5 diff --git a/rules-engine/tests/test_rules_engine/test_engine.py b/rules-engine/tests/test_rules_engine/test_engine.py index c24864b4..d49d2bdc 100644 --- a/rules-engine/tests/test_rules_engine/test_engine.py +++ b/rules-engine/tests/test_rules_engine/test_engine.py @@ -68,7 +68,7 @@ def test_bp_ua_estimates(): thermostat_set_point=thermostat_set_point, setback_temperature=setback_temperature, setback_hours_per_day=setback_hours_per_day, - design_temperature=design_temperature + design_temperature=design_temperature, ) home = engine.Home( @@ -113,7 +113,7 @@ def test_bp_ua_with_outlier(): thermostat_set_point=thermostat_set_point, setback_temperature=setback_temperature, setback_hours_per_day=setback_hours_per_day, - design_temperature=design_temperature + design_temperature=design_temperature, ) home = engine.Home( From 790255d60e0e2eec94de56a2d85819656c9ecb3d Mon Sep 17 00:00:00 2001 From: Jonathan Kwan Date: Wed, 13 Dec 2023 02:01:34 +0000 Subject: [PATCH 11/15] Fixed isort --- rules-engine/src/rules_engine/pydantic_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rules-engine/src/rules_engine/pydantic_models.py b/rules-engine/src/rules_engine/pydantic_models.py index 720a7702..3b3199c0 100644 --- a/rules-engine/src/rules_engine/pydantic_models.py +++ b/rules-engine/src/rules_engine/pydantic_models.py @@ -2,8 +2,8 @@ Data models for input and output data in the rules engine. """ -from datetime import date from dataclasses import dataclass +from datetime import date from enum import Enum from typing import Annotated, Any, List, Optional From 27ab4c4a5478507cb394d237eb70f255ac7528cc Mon Sep 17 00:00:00 2001 From: Jonathan Kwan Date: Wed, 13 Dec 2023 02:07:59 +0000 Subject: [PATCH 12/15] Fixed pydocstyle --- rules-engine/src/rules_engine/engine.py | 10 +++++----- rules-engine/src/rules_engine/pydantic_models.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/rules-engine/src/rules_engine/engine.py b/rules-engine/src/rules_engine/engine.py index 68ebb874..ddc71bc3 100644 --- a/rules-engine/src/rules_engine/engine.py +++ b/rules-engine/src/rules_engine/engine.py @@ -145,8 +145,8 @@ def get_outputs_normalized( average_heat_load=average_heat_load, maximum_heat_load=maximum_heat_load, ) - return summary_output # TODO: add BalancePointGraph - + #return summary_output # TODO: add BalancePointGraph + raise NotImplementedError def date_to_analysis_type(d: date) -> AnalysisType: months = { @@ -204,10 +204,10 @@ def get_average_indoor_temperature( Calculates the average indoor temperature. Args: - tstat_set: the temp in F at which the home is normally set - tstat_setback: temp in F at which the home is set during off + thermostat_set_point: the temp in F at which the home is normally set + setback_temperature: temp in F at which the home is set during off hours - setback_daily_hrs: average # of hours per day the home is at + setback_hours_per_day: average # of hours per day the home is at setback temp """ # again, not sure if we should check for valid values here or whether we can diff --git a/rules-engine/src/rules_engine/pydantic_models.py b/rules-engine/src/rules_engine/pydantic_models.py index 3b3199c0..a3177738 100644 --- a/rules-engine/src/rules_engine/pydantic_models.py +++ b/rules-engine/src/rules_engine/pydantic_models.py @@ -46,8 +46,8 @@ class SummaryInput(BaseModel): ) heating_system_efficiency: float = Field(description="Summary!B12") thermostat_set_point: float = Field(description="Summary!B17") - setback_temperature: Optional[float] = Field(description="Summary!B18") - setback_hours_per_day: Optional[float] = Field(description="Summary!B19") + setback_temperature: float = Field(description="Summary!B18") + setback_hours_per_day: float = Field(description="Summary!B19") design_temperature: float = Field(description="TDesign") From 99186516c5b9629788d5a99c8e208e76c0d2fd5e Mon Sep 17 00:00:00 2001 From: Erika Nesse Date: Wed, 20 Dec 2023 00:57:43 +0000 Subject: [PATCH 13/15] Fixed types to make tests pass Co-authored-by: harry Co-authored-by: Debajyoti Debnath Co-authored-by: dwindleduck Co-authored-by: Alan Pinkert --- rules-engine/src/rules_engine/engine.py | 13 ++++++++++--- rules-engine/src/rules_engine/pydantic_models.py | 4 ++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/rules-engine/src/rules_engine/engine.py b/rules-engine/src/rules_engine/engine.py index ddc71bc3..e306f04b 100644 --- a/rules-engine/src/rules_engine/engine.py +++ b/rules-engine/src/rules_engine/engine.py @@ -145,9 +145,10 @@ def get_outputs_normalized( average_heat_load=average_heat_load, maximum_heat_load=maximum_heat_load, ) - #return summary_output # TODO: add BalancePointGraph + # return summary_output # TODO: add BalancePointGraph raise NotImplementedError + def date_to_analysis_type(d: date) -> AnalysisType: months = { 1: AnalysisType.INCLUDE, @@ -197,8 +198,8 @@ def period_hdd(avg_temps: List[float], balance_point: float) -> float: def get_average_indoor_temperature( thermostat_set_point: float, - setback_temperature: float, - setback_hours_per_day: float, + setback_temperature: Optional[float], + setback_hours_per_day: Optional[float], ) -> float: """ Calculates the average indoor temperature. @@ -210,6 +211,12 @@ def get_average_indoor_temperature( setback_hours_per_day: average # of hours per day the home is at setback temp """ + if setback_temperature is None: + setback_temperature = thermostat_set_point + + if setback_hours_per_day is None: + setback_hours_per_day = 0 + # again, not sure if we should check for valid values here or whether we can # assume those kinds of checks will be handled at the point of user entry return ( diff --git a/rules-engine/src/rules_engine/pydantic_models.py b/rules-engine/src/rules_engine/pydantic_models.py index a3177738..3b3199c0 100644 --- a/rules-engine/src/rules_engine/pydantic_models.py +++ b/rules-engine/src/rules_engine/pydantic_models.py @@ -46,8 +46,8 @@ class SummaryInput(BaseModel): ) heating_system_efficiency: float = Field(description="Summary!B12") thermostat_set_point: float = Field(description="Summary!B17") - setback_temperature: float = Field(description="Summary!B18") - setback_hours_per_day: float = Field(description="Summary!B19") + setback_temperature: Optional[float] = Field(description="Summary!B18") + setback_hours_per_day: Optional[float] = Field(description="Summary!B19") design_temperature: float = Field(description="TDesign") From 21591a643e37655f82e482e04382cb1db354204e Mon Sep 17 00:00:00 2001 From: Erika Nesse Date: Wed, 20 Dec 2023 01:55:11 +0000 Subject: [PATCH 14/15] Made convert_to_intermediate_billing_periods Added test for convert_to_intermediate_billing_periods Filled out date_to_analysis_type Removed numpy Co-authored-by: harry Co-authored-by: Debajyoti Debnath Co-authored-by: dwindleduck Co-authored-by: Alan Pinkert Co-authored-by: wertheis --- rules-engine/src/rules_engine/engine.py | 83 +++++++----- .../src/rules_engine/pydantic_models.py | 12 +- .../tests/test_rules_engine/test_engine.py | 120 ++++++++++++++++++ 3 files changed, 175 insertions(+), 40 deletions(-) diff --git a/rules-engine/src/rules_engine/engine.py b/rules-engine/src/rules_engine/engine.py index e306f04b..41e3a928 100644 --- a/rules-engine/src/rules_engine/engine.py +++ b/rules-engine/src/rules_engine/engine.py @@ -5,11 +5,10 @@ from datetime import date, timedelta from typing import Any, List, Optional, Tuple -import numpy as np - from rules_engine.pydantic_models import ( AnalysisType, BalancePointGraph, + BalancePointGraphRow, Constants, DhwInput, FuelType, @@ -81,31 +80,10 @@ def get_outputs_normalized( temperature_input: TemperatureInput, billing_periods: List[NormalizedBillingPeriodRecordInput], ) -> Tuple[SummaryOutput, BalancePointGraph]: - # Build a list of lists of temperatures, where each list of temperatures contains all the temperatures - # in the corresponding billing period - intermediate_billing_periods = [] initial_balance_point = 60 - - for billing_period in billing_periods: - # the HEAT Excel sheet is inclusive of the temperatures that fall on both the start and end dates - start_idx = bisect.bisect_left( - temperature_input.dates, billing_period.period_start_date - ) - end_idx = ( - bisect.bisect_left(temperature_input.dates, billing_period.period_end_date) - + 1 - ) - - analysis_type = date_to_analysis_type(billing_period.period_end_date) - if billing_period.inclusion_override: - analysis_type = billing_period.inclusion_override - - intermediate_billing_period = BillingPeriod( - avg_temps=temperature_input.temperatures[start_idx:end_idx], - usage=billing_period.usage, - analysis_type=analysis_type, - ) - intermediate_billing_periods.append(intermediate_billing_period) + intermediate_billing_periods = convert_to_intermediate_billing_periods( + temperature_input=temperature_input, billing_periods=billing_periods + ) home = Home( summary_input=summary_input, @@ -145,8 +123,42 @@ def get_outputs_normalized( average_heat_load=average_heat_load, maximum_heat_load=maximum_heat_load, ) - # return summary_output # TODO: add BalancePointGraph - raise NotImplementedError + + # TODO: fill out balance point graph + balance_point_graph = BalancePointGraph(records=[]) + return (summary_output, balance_point_graph) + + +def convert_to_intermediate_billing_periods( + temperature_input: TemperatureInput, + billing_periods: List[NormalizedBillingPeriodRecordInput], +) -> List[BillingPeriod]: + # Build a list of lists of temperatures, where each list of temperatures contains all the temperatures + # in the corresponding billing period + intermediate_billing_periods = [] + + for billing_period in billing_periods: + # the HEAT Excel sheet is inclusive of the temperatures that fall on both the start and end dates + start_idx = bisect.bisect_left( + temperature_input.dates, billing_period.period_start_date + ) + end_idx = ( + bisect.bisect_left(temperature_input.dates, billing_period.period_end_date) + + 1 + ) + + analysis_type = date_to_analysis_type(billing_period.period_end_date) + if billing_period.inclusion_override: + analysis_type = billing_period.inclusion_override + + intermediate_billing_period = BillingPeriod( + avg_temps=temperature_input.temperatures[start_idx:end_idx], + usage=billing_period.usage, + analysis_type=analysis_type, + ) + intermediate_billing_periods.append(intermediate_billing_period) + + return intermediate_billing_periods def date_to_analysis_type(d: date) -> AnalysisType: @@ -164,9 +176,7 @@ def date_to_analysis_type(d: date) -> AnalysisType: 11: AnalysisType.DO_NOT_INCLUDE, 12: AnalysisType.INCLUDE, } - - # TODO: finish implementation and unit test - raise NotImplementedError + return months[d.month] def hdd(avg_temp: float, balance_point: float) -> float: @@ -213,7 +223,7 @@ def get_average_indoor_temperature( """ if setback_temperature is None: setback_temperature = thermostat_set_point - + if setback_hours_per_day is None: setback_hours_per_day = 0 @@ -370,9 +380,9 @@ def _calculate_balance_point_and_ua( self._refine_balance_point(initial_balance_point_sensitivity) while self.stdev_pct > stdev_pct_max: - biggest_outlier_idx = np.argmax( - [abs(bill.ua - self.avg_ua) for bill in self.bills_winter] - ) + outliers = [abs(bill.ua - self.avg_ua) for bill in self.bills_winter] + biggest_outlier = max(outliers) + biggest_outlier_idx = outliers.index(biggest_outlier) outlier = self.bills_winter.pop( biggest_outlier_idx ) # removes the biggest outlier @@ -420,6 +430,9 @@ def _refine_balance_point(self, balance_point_sensitivity: float) -> None: if stdev_pct_i >= self.stdev_pct: directions_to_check.pop(0) else: + # TODO: For balance point graph, store the old balance + # point in a list to keep track of all intermediate balance + # point temperatures? self.balance_point, self.avg_ua, self.stdev_pct = ( bp_i, avg_ua_i, diff --git a/rules-engine/src/rules_engine/pydantic_models.py b/rules-engine/src/rules_engine/pydantic_models.py index 3b3199c0..ab40b66e 100644 --- a/rules-engine/src/rules_engine/pydantic_models.py +++ b/rules-engine/src/rules_engine/pydantic_models.py @@ -122,11 +122,13 @@ class SummaryOutput(BaseModel): class BalancePointGraphRow(BaseModel): """From Summary page""" - balance_pt: float = Field(description="Summary!G33:35") - ua: float = Field(description="Summary!H33:35") - change_in_ua: float = Field(description="Summary!I33:35") - pct_change: float = Field(description="Summary!J33:35") - std_dev: float = Field(description="Summary!K33:35") + balance_point: float = Field(description="Summary!G33:35") # degree F + heat_loss_rate: float = Field(description="Summary!H33:35") # BTU / (hr-deg. F) + change_in_heat_loss_rate: float = Field( + description="Summary!I33:35" + ) # BTU / (hr-deg. F) + percent_change_in_heat_loss_rate: float = Field(description="Summary!J33:35") + standard_deviation: float = Field(description="Summary!K33:35") class BalancePointGraph(BaseModel): diff --git a/rules-engine/tests/test_rules_engine/test_engine.py b/rules-engine/tests/test_rules_engine/test_engine.py index d49d2bdc..57603850 100644 --- a/rules-engine/tests/test_rules_engine/test_engine.py +++ b/rules-engine/tests/test_rules_engine/test_engine.py @@ -1,3 +1,5 @@ +from datetime import date + import pytest from pytest import approx @@ -8,8 +10,10 @@ DhwInput, FuelType, NaturalGasBillingInput, + NormalizedBillingPeriodRecordInput, SummaryInput, SummaryOutput, + TemperatureInput, ) @@ -37,6 +41,20 @@ def test_period_hdd(temps, expected_result): assert engine.period_hdd(temps, 60) == expected_result +def test_date_to_analysis_type(): + test_date = date.fromisoformat("2019-01-04") + assert engine.date_to_analysis_type(test_date) == AnalysisType.INCLUDE + + dates = ["2019-01-04", "2019-07-04", "2019-12-04"] + types = [engine.date_to_analysis_type(date.fromisoformat(d)) for d in dates] + expected_types = [ + AnalysisType.INCLUDE, + AnalysisType.INCLUDE_IN_OTHER_ANALYSIS, + AnalysisType.INCLUDE, + ] + assert types == expected_types + + def test_get_average_indoor_temperature(): set_temp = 68 setback = 62 @@ -132,3 +150,105 @@ def test_bp_ua_with_outlier(): assert ua_3 == approx(1479.6, abs=1) assert home.avg_ua == approx(1515.1, abs=1) assert home.stdev_pct == approx(0.0474, abs=0.01) + + +def test_convert_to_intermediate_billing_periods(): + temperature_input = TemperatureInput( + dates=[ + date(2022, 12, 1), + date(2022, 12, 2), + date(2022, 12, 3), + date(2022, 12, 4), + date(2023, 1, 1), + date(2023, 1, 2), + date(2023, 1, 3), + date(2023, 1, 4), + date(2023, 2, 1), + date(2023, 2, 2), + date(2023, 2, 3), + date(2023, 2, 4), + date(2023, 3, 1), + date(2023, 3, 2), + date(2023, 3, 3), + date(2023, 3, 4), + date(2023, 4, 1), + date(2023, 4, 2), + date(2023, 4, 3), + date(2023, 4, 4), + ], + temperatures=[ + 41.7, + 41.6, + 32, + 25.4, + 28, + 29, + 30, + 29, + 32, + 35, + 35, + 38, + 41, + 43, + 42, + 42, + 72, + 71, + 70, + 69, + ], + ) + + billing_periods = [ + NormalizedBillingPeriodRecordInput( + period_start_date=date(2022, 12, 1), + period_end_date=date(2022, 12, 4), + usage=60, + inclusion_override=None, + ), + NormalizedBillingPeriodRecordInput( + period_start_date=date(2023, 1, 1), + period_end_date=date(2023, 1, 4), + usage=50, + inclusion_override=None, + ), + NormalizedBillingPeriodRecordInput( + period_start_date=date(2023, 2, 1), + period_end_date=date(2023, 2, 4), + usage=45, + inclusion_override=None, + ), + NormalizedBillingPeriodRecordInput( + period_start_date=date(2023, 3, 1), + period_end_date=date(2023, 3, 4), + usage=30, + inclusion_override=None, + ), + NormalizedBillingPeriodRecordInput( + period_start_date=date(2023, 4, 1), + period_end_date=date(2023, 4, 4), + usage=0.96, + inclusion_override=None, + ), + ] + + results = engine.convert_to_intermediate_billing_periods( + temperature_input, billing_periods + ) + + expected_results = [ + engine.BillingPeriod([41.7, 41.6, 32, 25.4], 60, AnalysisType.INCLUDE), + engine.BillingPeriod([28, 29, 30, 29], 50, AnalysisType.INCLUDE), + engine.BillingPeriod([32, 35, 35, 38], 45, AnalysisType.INCLUDE), + engine.BillingPeriod([41, 43, 42, 42], 30, AnalysisType.INCLUDE), + engine.BillingPeriod([72, 71, 70, 69], 0.96, AnalysisType.DO_NOT_INCLUDE), + ] + + for i in range(len(expected_results)): + result = results[i] + expected_result = expected_results[i] + + assert result.avg_temps == expected_result.avg_temps + assert result.usage == expected_result.usage + assert result.analysis_type == expected_result.analysis_type From 0ab26c45d3fab3813caea8b6ddf6b3ae47fe0a0e Mon Sep 17 00:00:00 2001 From: Alan Pinkert Date: Wed, 20 Dec 2023 02:10:35 +0000 Subject: [PATCH 15/15] eliminated numpy dependency from pyproject.toml --- rules-engine/pyproject.toml | 1 - rules-engine/requirements-dev.txt | 10 +--------- rules-engine/requirements.txt | 2 -- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/rules-engine/pyproject.toml b/rules-engine/pyproject.toml index 7e33cc9c..b6a3c442 100644 --- a/rules-engine/pyproject.toml +++ b/rules-engine/pyproject.toml @@ -7,7 +7,6 @@ name="rules-engine" version="0.0.1" requires-python=">=3.8" dependencies = [ - "numpy", "pydantic" ] diff --git a/rules-engine/requirements-dev.txt b/rules-engine/requirements-dev.txt index 8f7d6896..039ada18 100644 --- a/rules-engine/requirements-dev.txt +++ b/rules-engine/requirements-dev.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.8 +# This file is autogenerated by pip-compile with Python 3.10 # by the following command: # # pip-compile --extra=dev --output-file=requirements-dev.txt pyproject.toml @@ -10,10 +10,6 @@ black==23.3.0 # via rules-engine (pyproject.toml) click==8.1.3 # via black -colorama==0.4.6 - # via - # click - # pytest exceptiongroup==1.1.1 # via pytest iniconfig==2.0.0 @@ -26,8 +22,6 @@ mypy-extensions==1.0.0 # via # black # mypy -numpy==1.24.4 - # via rules-engine (pyproject.toml) packaging==23.1 # via # black @@ -55,8 +49,6 @@ tomli==2.0.1 # pytest typing-extensions==4.6.3 # via - # annotated-types - # black # mypy # pydantic # pydantic-core diff --git a/rules-engine/requirements.txt b/rules-engine/requirements.txt index e0560696..dff10bbb 100644 --- a/rules-engine/requirements.txt +++ b/rules-engine/requirements.txt @@ -6,8 +6,6 @@ # annotated-types==0.5.0 # via pydantic -numpy==1.24.4 - # via rules-engine (pyproject.toml) pydantic==2.3.0 # via rules-engine (pyproject.toml) pydantic-core==2.6.3