From ac2ce8e081ece3f22993302d7568f083f79788e1 Mon Sep 17 00:00:00 2001
From: Jonathan Kwan <jkwan2011@users.noreply.github.com>
Date: Tue, 27 Feb 2024 21:09:35 -0500
Subject: [PATCH] Calculate boiler usage (#153)

* Implemented calculate_boiler_usage for fuel oil

Co-authored-by: thatoldplatitude <thatoldplatitude@users.noreply.github.com>
Co-authored-by: dwindleduck <dwindleduck@users.noreply.github.com>
Co-authored-by: Alan Pinkert <alanisaac@users.noreply.github.com>
Co-authored-by: Debajyoti Debnath <debajyotid2@users.noreply.github.com>
Co-authored-by: David Egan <davideganse@gmail.com>
Co-authored-by: Chad Stoughton <cwstoughton@users.noreply.github.com>

* Renamed _calculate_boiler_usage to calculate_dhw_usage
Added unit test for calculate_dhw_usage

* Fixed static checks

---------

Co-authored-by: thatoldplatitude <thatoldplatitude@users.noreply.github.com>
Co-authored-by: dwindleduck <dwindleduck@users.noreply.github.com>
Co-authored-by: Alan Pinkert <alanisaac@users.noreply.github.com>
Co-authored-by: Debajyoti Debnath <debajyotid2@users.noreply.github.com>
Co-authored-by: David Egan <davideganse@gmail.com>
Co-authored-by: Chad Stoughton <cwstoughton@users.noreply.github.com>
---
 rules-engine/src/rules_engine/engine.py       | 56 +++++++++--------
 .../src/rules_engine/pydantic_models.py       | 13 +++-
 .../tests/test_rules_engine/test_engine.py    | 61 +++++++++++++++++++
 3 files changed, 103 insertions(+), 27 deletions(-)

diff --git a/rules-engine/src/rules_engine/engine.py b/rules-engine/src/rules_engine/engine.py
index 0f9fa9a4..3bc8b6bb 100644
--- a/rules-engine/src/rules_engine/engine.py
+++ b/rules-engine/src/rules_engine/engine.py
@@ -89,9 +89,8 @@ def get_outputs_normalized(
     home = Home(
         summary_input=summary_input,
         billing_periods=intermediate_billing_periods,
+        dhw_input=dhw_input,
         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()
 
@@ -277,6 +276,30 @@ def get_maximum_heat_load(
     return (design_set_point - design_temp) * ua
 
 
+def calculate_dhw_usage(dhw_input: DhwInput, heating_system_efficiency: float) -> float:
+    """
+    Calculate non-heating usage with oil or propane
+    """
+    if dhw_input.estimated_water_heating_efficiency is not None:
+        heating_system_efficiency = dhw_input.estimated_water_heating_efficiency
+
+    stand_by_losses = Constants.DEFAULT_STAND_BY_LOSSES
+    if dhw_input.stand_by_losses is not None:
+        stand_by_losses = dhw_input.stand_by_losses
+
+    daily_fuel_oil_use_for_dhw = (
+        dhw_input.number_of_occupants
+        * Constants.DAILY_DHW_CONSUMPTION_PER_OCCUPANT
+        * Constants.WATER_WEIGHT
+        * (Constants.LEAVING_WATER_TEMPERATURE - Constants.ENTERING_WATER_TEMPERATURE)
+        * Constants.SPECIFIC_HEAT_OF_WATER
+        / Constants.FUEL_OIL_BTU_PER_GAL
+        / (heating_system_efficiency * (1 - stand_by_losses))
+    )
+
+    return daily_fuel_oil_use_for_dhw
+
+
 class Home:
     """
     Defines attributes and methods for calculating home heat metrics.
@@ -291,16 +314,14 @@ def __init__(
         self,
         summary_input: SummaryInput,
         billing_periods: List[BillingPeriod],
+        dhw_input: Optional[DhwInput],
         initial_balance_point: float = 60,
-        has_boiler_for_dhw: bool = False,
-        same_fuel_dhw_heating: bool = False,
     ):
         self.fuel_type = summary_input.fuel_type
         self.heat_sys_efficiency = summary_input.heating_system_efficiency
         self.thermostat_set_point = summary_input.thermostat_set_point
         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.dhw_input = dhw_input
         self._initialize_billing_periods(billing_periods)
 
     def _initialize_billing_periods(self, billing_periods: List[BillingPeriod]) -> None:
@@ -335,19 +356,6 @@ def _calculate_avg_summer_usage(self) -> None:
         else:
             self.avg_summer_usage = 0
 
-    def _calculate_boiler_usage(self, fuel_multiplier: float) -> float:
-        """
-        Calculate boiler usage with oil or propane
-        Args:
-            fuel_multiplier: a constant that's determined by the fuel
-            type
-        """
-
-        # self.num_occupants: the number of occupants in Home
-        # self.water_heat_efficiency: a number indicating how efficient the heating system is
-
-        return 0 * fuel_multiplier
-
     def _calculate_avg_non_heating_usage(self) -> None:
         """
         Calculate avg non heating usage for this home
@@ -355,11 +363,11 @@ def _calculate_avg_non_heating_usage(self) -> None:
 
         if self.fuel_type == FuelType.GAS:
             self.avg_non_heating_usage = self.avg_summer_usage
-        elif self.has_boiler_for_dhw and self.same_fuel_dhw_heating:
-            fuel_multiplier = 1  # default multiplier, for oil, placeholder number
-            if self.fuel_type == FuelType.PROPANE:
-                fuel_multiplier = 2  # a placeholder number
-            self.avg_non_heating_usage = self._calculate_boiler_usage(fuel_multiplier)
+        elif self.dhw_input is not None and self.fuel_type == FuelType.OIL:
+            # TODO: support non-heating usage for Propane in addition to fuel oil
+            self.avg_non_heating_usage = calculate_dhw_usage(
+                self.dhw_input, self.heat_sys_efficiency
+            )
         else:
             self.avg_non_heating_usage = 0
 
diff --git a/rules-engine/src/rules_engine/pydantic_models.py b/rules-engine/src/rules_engine/pydantic_models.py
index 3b36de42..d1f08702 100644
--- a/rules-engine/src/rules_engine/pydantic_models.py
+++ b/rules-engine/src/rules_engine/pydantic_models.py
@@ -55,8 +55,8 @@ class DhwInput(BaseModel):
     """From DHW (Domestic Hot Water) Tab"""
 
     number_of_occupants: int = Field(description="DHW!B4")
-    estimated_water_heating_efficiency: float = Field(description="DHW!B5")
-    stand_by_losses: float = Field(description="DHW!B6")
+    estimated_water_heating_efficiency: Optional[float] = Field(description="DHW!B5")
+    stand_by_losses: Optional[float] = Field(description="DHW!B6")
 
 
 class OilPropaneBillingRecordInput(BaseModel):
@@ -144,4 +144,11 @@ class BalancePointGraph(BaseModel):
 @dataclass
 class Constants:
     BALANCE_POINT_SENSITIVITY: float = 0.5
-    DESIGN_SET_POINT: float = 70
+    DESIGN_SET_POINT: float = 70  # deg. F
+    DAILY_DHW_CONSUMPTION_PER_OCCUPANT: float = 15.78  # Gal/day/person
+    WATER_WEIGHT: float = 8.33  # lbs/gal
+    ENTERING_WATER_TEMPERATURE: float = 55  # deg. F
+    LEAVING_WATER_TEMPERATURE: float = 125  # deg. F
+    SPECIFIC_HEAT_OF_WATER: float = 1.00  # BTU/lbs-deg. F
+    DEFAULT_STAND_BY_LOSSES: float = 0.05  #
+    FUEL_OIL_BTU_PER_GAL: float = 139000
diff --git a/rules-engine/tests/test_rules_engine/test_engine.py b/rules-engine/tests/test_rules_engine/test_engine.py
index 6e516276..723bff83 100644
--- a/rules-engine/tests/test_rules_engine/test_engine.py
+++ b/rules-engine/tests/test_rules_engine/test_engine.py
@@ -210,6 +210,7 @@ def test_bp_ua_estimates(sample_summary_inputs, sample_billing_periods):
     home = engine.Home(
         sample_summary_inputs,
         sample_billing_periods,
+        dhw_input=None,
         initial_balance_point=58,
     )
 
@@ -229,6 +230,7 @@ def test_bp_ua_with_outlier(sample_summary_inputs, sample_billing_periods_with_o
     home = engine.Home(
         sample_summary_inputs,
         sample_billing_periods_with_outlier,
+        dhw_input=None,
         initial_balance_point=58,
     )
 
@@ -284,3 +286,62 @@ def test_get_outputs_normalized(
     assert summary_output.standard_deviation_of_heat_loss_rate == approx(
         0.0463, abs=0.01
     )
+
+
+@pytest.mark.parametrize(
+    "sample_dhw_inputs, summary_input_heating_system_efficiency, expected_fuel_oil_usage",
+    [
+        (
+            DhwInput(
+                number_of_occupants=2,
+                estimated_water_heating_efficiency=None,
+                stand_by_losses=None,
+            ),
+            0.80,
+            0.17,
+        ),
+        (
+            DhwInput(
+                number_of_occupants=2,
+                estimated_water_heating_efficiency=0.8,
+                stand_by_losses=None,
+            ),
+            0.85,
+            0.17,
+        ),
+        (
+            DhwInput(
+                number_of_occupants=4,
+                estimated_water_heating_efficiency=0.8,
+                stand_by_losses=None,
+            ),
+            0.84,
+            0.35,
+        ),
+        (
+            DhwInput(
+                number_of_occupants=5,
+                estimated_water_heating_efficiency=0.8,
+                stand_by_losses=None,
+            ),
+            0.83,
+            0.43,
+        ),
+        (
+            DhwInput(
+                number_of_occupants=5,
+                estimated_water_heating_efficiency=0.8,
+                stand_by_losses=0.10,
+            ),
+            0.82,
+            0.46,
+        ),
+    ],
+)
+def test_calculate_dhw_usage(
+    sample_dhw_inputs, summary_input_heating_system_efficiency, expected_fuel_oil_usage
+):
+    fuel_oil_usage = engine.calculate_dhw_usage(
+        sample_dhw_inputs, summary_input_heating_system_efficiency
+    )
+    assert fuel_oil_usage == approx(expected_fuel_oil_usage, abs=0.01)