Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes issue236 by implementing inclusion_override logic #241

Merged
merged 8 commits into from
Sep 11, 2024
120 changes: 89 additions & 31 deletions rules-engine/src/rules_engine/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@ def get_outputs_oil_propane(
period_start_date=start_date,
period_end_date=input_val.period_end_date,
usage=input_val.gallons,
analysis_type_override=input_val.inclusion_override,
inclusion_override=True,
inclusion_override=bool(input_val.inclusion_override),
)
)
last_date = input_val.period_end_date
Expand All @@ -73,8 +72,7 @@ def get_outputs_natural_gas(
period_start_date=input_val.period_start_date,
period_end_date=input_val.period_end_date,
usage=input_val.usage_therms,
analysis_type_override=input_val.inclusion_override,
inclusion_override=True,
inclusion_override=bool(input_val.inclusion_override),
)
)

Expand Down Expand Up @@ -141,19 +139,13 @@ def get_outputs_normalized(

billing_records = []
for billing_period in intermediate_billing_periods:
# TODO: fix inclusion override to come from inputs
default_inclusion_by_calculation = True
if billing_period.analysis_type == AnalysisType.NOT_ALLOWED_IN_CALCULATIONS:
default_inclusion_by_calculation = False

billing_record = NormalizedBillingPeriodRecord(
period_start_date=billing_period.input.period_start_date,
period_end_date=billing_period.input.period_end_date,
usage=billing_period.input.usage,
analysis_type_override=billing_period.input.analysis_type_override,
inclusion_override=False,
inclusion_override=billing_period.input.inclusion_override,
analysis_type=billing_period.analysis_type,
default_inclusion_by_calculation=default_inclusion_by_calculation,
default_inclusion=billing_period.default_inclusion,
eliminated_as_outlier=billing_period.eliminated_as_outlier,
whole_home_heat_loss_rate=billing_period.ua,
)
Expand All @@ -180,6 +172,7 @@ def convert_to_intermediate_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 = []
default_inclusion = False

for billing_period in billing_periods:
# the HEAT Excel sheet is inclusive of the temperatures that fall on both the start and end dates
Expand All @@ -192,22 +185,23 @@ def convert_to_intermediate_billing_periods(
)

if fuel_type == FuelType.GAS:
analysis_type = _date_to_analysis_type_natural_gas(
analysis_type, default_inclusion = _date_to_analysis_type_natural_gas(
billing_period.period_end_date
)
elif fuel_type == FuelType.OIL or fuel_type == FuelType.PROPANE:
analysis_type = _date_to_analysis_type_oil_propane(
analysis_type, default_inclusion = _date_to_analysis_type_oil_propane(
billing_period.period_start_date, billing_period.period_end_date
)

if billing_period.analysis_type_override is not None:
analysis_type = billing_period.analysis_type_override
else:
raise ValueError("Unsupported fuel type.")

intermediate_billing_period = BillingPeriod(
input=billing_period,
avg_temps=temperature_input.temperatures[start_idx:end_idx],
usage=billing_period.usage,
analysis_type=analysis_type,
default_inclusion=default_inclusion,
inclusion_override=billing_period.inclusion_override,
)
intermediate_billing_periods.append(intermediate_billing_period)

Expand All @@ -216,7 +210,7 @@ def convert_to_intermediate_billing_periods(

def _date_to_analysis_type_oil_propane(
start_date: date, end_date: date
) -> AnalysisType:
) -> tuple[AnalysisType, bool]:
"""
Converts the dates from a billing period into an enum representing the period's usage in the rules engine.
"""
Expand All @@ -226,30 +220,52 @@ def _date_to_analysis_type_oil_propane(
or (start_date.month < 7 and end_date.month > 7)
or (start_date.month < 7 and start_date.year < end_date.year)
):
return AnalysisType.NOT_ALLOWED_IN_CALCULATIONS
_analysis_type = AnalysisType.NOT_ALLOWED_IN_CALCULATIONS
_default_inclusion = False
else:
return AnalysisType.ALLOWED_HEATING_USAGE
_analysis_type = AnalysisType.ALLOWED_HEATING_USAGE
_default_inclusion = True
return (_analysis_type, _default_inclusion)


def _date_to_analysis_type_natural_gas(d: date) -> AnalysisType:
def _date_to_analysis_type_natural_gas(d: date) -> tuple[AnalysisType, bool]:
"""
Converts the dates from a billing period into an enum representing the period's usage in the rules engine.
"""
months = {
1: AnalysisType.ALLOWED_HEATING_USAGE,
2: AnalysisType.ALLOWED_HEATING_USAGE,
3: AnalysisType.ALLOWED_HEATING_USAGE,
4: AnalysisType.NOT_ALLOWED_IN_CALCULATIONS,
4: AnalysisType.ALLOWED_HEATING_USAGE,
5: AnalysisType.NOT_ALLOWED_IN_CALCULATIONS,
6: AnalysisType.NOT_ALLOWED_IN_CALCULATIONS,
6: AnalysisType.ALLOWED_NON_HEATING_USAGE,
7: AnalysisType.ALLOWED_NON_HEATING_USAGE,
8: AnalysisType.ALLOWED_NON_HEATING_USAGE,
9: AnalysisType.ALLOWED_NON_HEATING_USAGE,
10: AnalysisType.NOT_ALLOWED_IN_CALCULATIONS,
11: AnalysisType.NOT_ALLOWED_IN_CALCULATIONS,
11: AnalysisType.ALLOWED_HEATING_USAGE,
12: AnalysisType.ALLOWED_HEATING_USAGE,
}
return months[d.month]

default_inclusion_by_month = {
1: True,
2: True,
3: True,
4: False,
5: False,
6: False,
7: True,
8: True,
9: True,
10: False,
11: False,
12: True,
}

_analysis_type = months[d.month]
_default_inclusion = default_inclusion_by_month[d.month]

return (_analysis_type, _default_inclusion)


def hdd(avg_temp: float, balance_point: float) -> float:
Expand Down Expand Up @@ -402,18 +418,56 @@ def _initialize_billing_periods(self, billing_periods: list[BillingPeriod]) -> N
self.bills_summer = []
self.bills_shoulder = []

# winter months 1; summer months -1; shoulder months 0
# winter months 1 (ALLOWED_HEATING_USAGE); summer months -1 (ALLOWED_NON_HEATING_USAGE); shoulder months 0 (NOT_ALLOWED...)
for billing_period in billing_periods:
billing_period.set_initial_balance_point(self.balance_point)

if billing_period.analysis_type == AnalysisType.ALLOWED_HEATING_USAGE:
"""
The UI depicts billing period usage as several distinctive icons on the left hand column of the screen; "analysis_type"
For winter "cusp" months, for example April and November in MA, the UI will show those rows grayed out; "default_inclusion"
The user has the ability to "include" those cusp months in calculations by checking a box on far right; "inclusion_override"
The user may also choose to "exclude" any other "allowed" month by checking a box on the far right; "inclusion_override"
The following code implements this algorithm and adds bills accordingly to winter, summer, or shoulder (i.e. excluded) lists
"""

_analysis_type = billing_period.analysis_type
_default_inclusion = billing_period.default_inclusion

# Only bills deemed ALLOWED by the AnalysisType algorithm can be included/excluded by the user
# if (
# _analysis_type == AnalysisType.ALLOWED_HEATING_USAGE
# or _analysis_type == AnalysisType.ALLOWED_NON_HEATING_USAGE
# ):
# if billing_period.inclusion_override:
# # The user has requested we override an inclusion algorithm decision
# if billing_period.winter_cusp_month == True:
# # This bill is on the cusp of winter; the user has requested we include it
# _analysis_type = AnalysisType.ALLOWED_HEATING_USAGE
# else:
# # The user has requested we exclude this bill from our calculations
# _analysis_type = AnalysisType.NOT_ALLOWED_IN_CALCULATIONS
# else:
# # The user has chosen to not override our automatic calculations, even for a winter cusp month
# if billing_period.winter_cusp_month == True:
# _analysis_type = AnalysisType.NOT_ALLOWED_IN_CALCULATIONS

# Assign the bill to the appropriate list for winter or summer calculations

if billing_period.inclusion_override:
_default_inclusion = not _default_inclusion

if (
_analysis_type == AnalysisType.ALLOWED_HEATING_USAGE
and _default_inclusion
):
self.bills_winter.append(billing_period)
elif (
billing_period.analysis_type == AnalysisType.NOT_ALLOWED_IN_CALCULATIONS
_analysis_type == AnalysisType.ALLOWED_NON_HEATING_USAGE
and _default_inclusion
):
self.bills_shoulder.append(billing_period)
else:
self.bills_summer.append(billing_period)
else: # the rest are excluded from calculations
self.bills_shoulder.append(billing_period)

self._calculate_avg_summer_usage()
self._calculate_avg_non_heating_usage()
Expand Down Expand Up @@ -633,11 +687,15 @@ def __init__(
avg_temps: list[float],
usage: float,
analysis_type: AnalysisType,
default_inclusion: bool,
inclusion_override: bool,
) -> None:
self.input = input
self.avg_temps = avg_temps
self.usage = usage
self.analysis_type = analysis_type
self.default_inclusion = default_inclusion
self.inclusion_override = inclusion_override
self.eliminated_as_outlier = False
self.ua = None

Expand All @@ -648,7 +706,7 @@ def set_initial_balance_point(self, balance_point: float) -> None:
self.total_hdd = period_hdd(self.avg_temps, self.balance_point)

def __str__(self) -> str:
return f"{self.input}, {self.ua}, {self.eliminated_as_outlier}, {self.days}, {self.avg_temps}, {self.usage}, {self.analysis_type}"
return f"{self.input}, {self.ua}, {self.eliminated_as_outlier}, {self.days}, {self.avg_temps}, {self.usage}, {self.analysis_type}, {self.default_inclusion}"

def __repr__(self) -> str:
return self.__str__()
37 changes: 15 additions & 22 deletions rules-engine/src/rules_engine/pydantic_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,20 @@ class AnalysisType(Enum):
Enum for analysis type.

'Inclusion' in calculations is now determined by
the default_inclusion_by_calculation and inclusion_override variables
the date_to_analysis_type calculation and the inclusion_override variable

TODO: determine if the following logic has actually been implemented...
Use HDDs to determine if shoulder months
are heating or non-heating or not allowed,
or included or excluded
"""

ALLOWED_HEATING_USAGE = 1 # winter months - allowed in heating usage calculations
ALLOWED_NON_HEATING_USAGE = (
-1
) # summer months - allowed in non-heating usage calculations
NOT_ALLOWED_IN_CALCULATIONS = (
0 # shoulder months that fall outside reasonable bounds
)
# winter months - allowed in heating usage calculations
ALLOWED_HEATING_USAGE = 1
# summer months - allowed in non-heating usage calculations
ALLOWED_NON_HEATING_USAGE = -1
# shoulder months - these fall outside reasonable bounds
NOT_ALLOWED_IN_CALCULATIONS = 0


class FuelType(Enum):
Expand Down Expand Up @@ -83,10 +84,7 @@ class OilPropaneBillingRecordInput(BaseModel):

period_end_date: date = Field(description="Oil-Propane!B")
gallons: float = Field(description="Oil-Propane!C")
inclusion_override: Optional[
Literal[AnalysisType.ALLOWED_HEATING_USAGE]
| Literal[AnalysisType.NOT_ALLOWED_IN_CALCULATIONS]
] = Field(description="Oil-Propane!F")
inclusion_override: Optional[bool] = Field(description="Oil-Propane!F")


class OilPropaneBillingInput(BaseModel):
Expand All @@ -102,7 +100,7 @@ class NaturalGasBillingRecordInput(BaseModel):
period_start_date: date = Field(description="Natural Gas!A")
period_end_date: date = Field(description="Natural Gas!B")
usage_therms: float = Field(description="Natural Gas!D")
inclusion_override: Optional[AnalysisType] = Field(description="Natural Gas!E")
inclusion_override: Optional[bool] = Field(description="Natural Gas!E")


class NaturalGasBillingInput(BaseModel):
Expand Down Expand Up @@ -147,33 +145,28 @@ class NormalizedBillingPeriodRecordBase(BaseModel):
"""
Base class for a normalized billing period record.

Holds data as fields.

analysis_type_override - for testing only, preserving compatibility
with the original heat calc spreadsheet, which allows users to override
the analysis type whereas the rules engine does not
Holds data inputs provided by the UI as fields.
"""

model_config = ConfigDict(validate_assignment=True)

period_start_date: date = Field(frozen=True)
period_end_date: date = Field(frozen=True)
usage: float = Field(frozen=True)
analysis_type_override: Optional[AnalysisType] = Field(frozen=True)
inclusion_override: bool
inclusion_override: bool = Field(frozen=True)


class NormalizedBillingPeriodRecord(NormalizedBillingPeriodRecordBase):
"""
Derived class for holding a normalized billing period record.

Holds data.
Holds generated data that is calculated by the rules engine.
"""

model_config = ConfigDict(validate_assignment=True)

analysis_type: AnalysisType = Field(frozen=True)
default_inclusion_by_calculation: bool = Field(frozen=True)
default_inclusion: bool = Field(frozen=True)
eliminated_as_outlier: bool = Field(frozen=True)
whole_home_heat_loss_rate: Optional[float] = Field(frozen=True)

Expand Down
Loading
Loading