Skip to content

Commit

Permalink
new ahu fault 16 added for ERV and pytests but still need to test on …
Browse files Browse the repository at this point in the history
…real world data in an example ipynb notebook before pushing new rev to pypy
  • Loading branch information
bbartling committed Sep 1, 2024
1 parent 35e089e commit f6d06d5
Show file tree
Hide file tree
Showing 3 changed files with 366 additions and 0 deletions.
3 changes: 3 additions & 0 deletions examples/brick_model_and_sqlite/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# BRICK and SQL Experiment

* **NOTE** - This process is a bit experimental and a learning experience for me on the database side.

This project demonstrates how to loop over a BRICK model and apply fault detection to datasets purely based on the BRICK model that has time series references. By leveraging the BRICK schema and time series data, the project allows efficient querying and fault detection across multiple Air Handling Units (AHUs).

## Project Overview
Expand Down Expand Up @@ -27,6 +29,7 @@ pip install rdflib sqlite3 open-fdd
* It sets up the necessary tables and populates them with the sensor data.

### 2. run `2_make_rdf.py`
* **NOTES** - the script is hard coded to look for the `sensor_names` for the AHU points. Some research needs to be done to learn how to build an rdf BRICK model properly.
* This script builds the BRICK RDF turtle file, specifically modeling the AHUs with their respective sensors.
* The RDF file is created based on the sensor data, with time series references included.

Expand Down
173 changes: 173 additions & 0 deletions open_fdd/air_handling_unit/faults/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2251,3 +2251,176 @@ def apply(self, df: pd.DataFrame) -> pd.DataFrame:
print(f"Error: {e.message}")
sys.stdout.flush()
raise e


class FaultConditionSixteen(FaultCondition):
"""Class provides the definitions for Fault Condition 16.
ERV Ineffective Process based on outdoor air temperature ranges.
"""

def __init__(self, dict_):
super().__init__()

# Threshold parameters for efficiency ranges based on heating and cooling
self.erv_efficiency_min_heating = dict_.get("ERV_EFFICIENCY_MIN_HEATING", 0.7)
self.erv_efficiency_max_heating = dict_.get("ERV_EFFICIENCY_MAX_HEATING", 0.8)
self.erv_efficiency_min_cooling = dict_.get("ERV_EFFICIENCY_MIN_COOLING", 0.5)
self.erv_efficiency_max_cooling = dict_.get("ERV_EFFICIENCY_MAX_COOLING", 0.6)

self.oat_low_threshold = dict_.get("OAT_LOW_THRES", 32.0)
self.oat_high_threshold = dict_.get("OAT_HIGH_THRES", 80.0)

# Validate that threshold parameters are floats and within 0.0 and 1.0 for efficiency values
for param, value in [
("erv_efficiency_min_heating", self.erv_efficiency_min_heating),
("erv_efficiency_max_heating", self.erv_efficiency_max_heating),
("erv_efficiency_min_cooling", self.erv_efficiency_min_cooling),
("erv_efficiency_max_cooling", self.erv_efficiency_max_cooling),
("oat_low_threshold", self.oat_low_threshold),
("oat_high_threshold", self.oat_high_threshold),
]:
if not isinstance(value, float):
raise InvalidParameterError(
f"The parameter '{param}' should be a float, but got {type(value).__name__}."
)
if "erv_efficiency" in param and not (0.0 <= value <= 1.0):
raise InvalidParameterError(
f"The parameter '{param}' should be a float between 0.0 and 1.0 to represent a percentage, but got {value}."
)

# Other attributes
self.erv_oat_enter_col = dict_.get("ERV_OAT_ENTER_COL", "erv_oat_enter")
self.erv_oat_leaving_col = dict_.get("ERV_OAT_LEAVING_COL", "erv_oat_leaving")
self.erv_eat_enter_col = dict_.get("ERV_EAT_ENTER_COL", "erv_eat_enter")
self.erv_eat_leaving_col = dict_.get("ERV_EAT_LEAVING_COL", "erv_eat_leaving")
self.supply_vfd_speed_col = dict_.get(
"SUPPLY_VFD_SPEED_COL", "supply_vfd_speed"
)
self.rolling_window_size = dict_.get("ROLLING_WINDOW_SIZE", 1)
self.troubleshoot_mode = dict_.get("TROUBLESHOOT_MODE", False)

self.equation_string = (
"fc16_flag = 1 if temperature deltas and expected efficiency is ineffective "
"for N consecutive values else 0 \n"
)
self.description_string = (
"Fault Condition 16: ERV is an ineffective heat transfer fault. "
"This fault occurs when the ERV's efficiency "
"is outside the acceptable range based on the delta temperature across the "
"ERV outside air enter temperature and ERV outside air leaving temperature, "
"indicating poor heat transfer. "
"It considers both heating and cooling conditions where each have acceptable "
"ranges in percentage for expected heat transfer efficiency. The percentage needs "
"to be a float between 0.0 and 1.0."
)
self.required_column_description = (
"Required inputs are the ERV outside air entering temperature, ERV outside air leaving temperature, "
"ERV exhaust entering temperature, ERV exhaust leaving temperature, "
"and AHU supply fan VFD speed."
)
self.error_string = "One or more required columns are missing or None."

self.set_attributes(dict_)

# Set required columns specific to this fault condition
self.required_columns = [
self.erv_oat_enter_col,
self.erv_oat_leaving_col,
self.erv_eat_enter_col,
self.erv_eat_leaving_col,
self.supply_vfd_speed_col,
]

# Check if any of the required columns are None
if any(col is None for col in self.required_columns):
raise MissingColumnError(
f"{self.error_string}\n"
f"{self.equation_string}\n"
f"{self.description_string}\n"
f"{self.required_column_description}\n"
f"Missing columns: {self.required_columns}"
)

# Ensure all required columns are strings
self.required_columns = [str(col) for col in self.required_columns]

self.mapped_columns = (
f"Your config dictionary is mapped as: {', '.join(self.required_columns)}"
)

def get_required_columns(self) -> str:
"""Returns a string representation of the required columns."""
return (
f"{self.equation_string}"
f"{self.description_string}\n"
f"{self.required_column_description}\n"
f"{self.mapped_columns}"
)

def apply(self, df: pd.DataFrame) -> pd.DataFrame:
try:
# Calculate ERV efficiency
df["erv_efficiency_oa"] = (
df[self.erv_oat_leaving_col] - df[self.erv_oat_enter_col]
) / (df[self.erv_eat_enter_col] - df[self.erv_oat_enter_col])

# Fan must be on for a fault to be considered
fan_on = df[self.supply_vfd_speed_col] > 0.1

# Combined heating and cooling checks
cold_outside = df[self.erv_oat_enter_col] <= self.oat_low_threshold
hot_outside = df[self.erv_oat_enter_col] >= self.oat_high_threshold

heating_fault = (
(
(df["erv_efficiency_oa"] < self.erv_efficiency_min_heating)
| (df["erv_efficiency_oa"] > self.erv_efficiency_max_heating)
)
& cold_outside
& fan_on
)

cooling_fault = (
(
(df["erv_efficiency_oa"] < self.erv_efficiency_min_cooling)
| (df["erv_efficiency_oa"] > self.erv_efficiency_max_cooling)
)
& hot_outside
& fan_on
)

df["combined_checks"] = heating_fault | cooling_fault

# Apply rolling sum
df["fc16_flag"] = (
df["combined_checks"]
.rolling(window=self.rolling_window_size)
.sum()
.ge(self.rolling_window_size)
.astype(int)
)

if self.troubleshoot_mode:
print("Troubleshoot mode enabled - not removing helper columns")
sys.stdout.flush()

# Drop helper cols if not in troubleshoot mode
if not self.troubleshoot_mode:
df.drop(
columns=[
"combined_checks",
"erv_efficiency_oa",
],
inplace=True,
)

return df

except MissingColumnError as e:
print(f"Error: {e.message}")
sys.stdout.flush()
raise e
except InvalidParameterError as e:
print(f"Error: {e.message}")
sys.stdout.flush()
raise e
190 changes: 190 additions & 0 deletions open_fdd/tests/ahu/test_ahu_fc16.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import pandas as pd
import pytest
from open_fdd.air_handling_unit.faults import FaultConditionSixteen
from open_fdd.air_handling_unit.faults.fault_condition import (
InvalidParameterError,
MissingColumnError,
)

"""
To see print statements in pytest run with:
$ py -3.12 -m pytest open_fdd/tests/ahu/test_ahu_fc16.py -rP -s
ERV effectiveness should be within specified thresholds based on OAT.
"""


# Constants
TEST_ERV_EFFICIENCY_MIN_HEATING = 0.65
TEST_ERV_EFFICIENCY_MAX_HEATING = 0.8
TEST_ERV_EFFICIENCY_MIN_COOLING = 0.45
TEST_ERV_EFFICIENCY_MAX_COOLING = 0.6
TEST_OAT_LOW_THRESHOLD = 32.0
TEST_OAT_HIGH_THRESHOLD = 80.0
TEST_ERV_DEGF_ERR_THRES = 2.0
TEST_ERV_OAT_ENTER_COL = "erv_oat_enter"
TEST_ERV_OAT_LEAVING_COL = "erv_oat_leaving"
TEST_ERV_EAT_ENTER_COL = "erv_eat_enter"
TEST_ERV_EAT_LEAVING_COL = "erv_eat_leaving"
TEST_SUPPLY_VFD_SPEED_COL = "supply_vfd_speed"
ROLLING_WINDOW_SIZE = 1

# Initialize FaultConditionSixteen with a dictionary
fault_condition_params = {
"ERV_EFFICIENCY_MIN_HEATING": TEST_ERV_EFFICIENCY_MIN_HEATING,
"ERV_EFFICIENCY_MAX_HEATING": TEST_ERV_EFFICIENCY_MAX_HEATING,
"ERV_EFFICIENCY_MIN_COOLING": TEST_ERV_EFFICIENCY_MIN_COOLING,
"ERV_EFFICIENCY_MAX_COOLING": TEST_ERV_EFFICIENCY_MAX_COOLING,
"OAT_LOW_THRESHOLD": TEST_OAT_LOW_THRESHOLD,
"OAT_HIGH_THRESHOLD": TEST_OAT_HIGH_THRESHOLD,
"ERV_DEGF_ERR_THRES": TEST_ERV_DEGF_ERR_THRES,
"ERV_OAT_ENTER_COL": TEST_ERV_OAT_ENTER_COL,
"ERV_OAT_LEAVING_COL": TEST_ERV_OAT_LEAVING_COL,
"ERV_EAT_ENTER_COL": TEST_ERV_EAT_ENTER_COL,
"ERV_EAT_LEAVING_COL": TEST_ERV_EAT_LEAVING_COL,
"SUPPLY_VFD_SPEED_COL": TEST_SUPPLY_VFD_SPEED_COL,
"TROUBLESHOOT_MODE": False,
"ROLLING_WINDOW_SIZE": ROLLING_WINDOW_SIZE,
}

fc16 = FaultConditionSixteen(fault_condition_params)


class TestFaultConditionSixteen:

def no_fault_htg_df(self) -> pd.DataFrame:
data = {
TEST_ERV_OAT_ENTER_COL: [10, 10, 10, 10, 10, 10],
TEST_ERV_OAT_LEAVING_COL: [50.0, 50.5, 50.8, 50.6, 50.2, 50.4],
TEST_ERV_EAT_ENTER_COL: [70, 70, 70, 70, 70, 70],
TEST_ERV_EAT_LEAVING_COL: [60, 60.5, 60.2, 60.4, 60.1, 60.3],
TEST_SUPPLY_VFD_SPEED_COL: [0.5, 0.6, 0.5, 0.7, 0.5, 0.6],
}
return pd.DataFrame(data)

def fault_htg_df_low_eff(self) -> pd.DataFrame:
data = {
TEST_ERV_OAT_ENTER_COL: [10, 10, 10, 10, 10, 10],
TEST_ERV_OAT_LEAVING_COL: [20.0, 20.5, 20.8, 20.6, 20.2, 20.4],
TEST_ERV_EAT_ENTER_COL: [70, 70, 70, 70, 70, 70],
TEST_ERV_EAT_LEAVING_COL: [60, 60.5, 60.2, 60.4, 60.1, 60.3],
TEST_SUPPLY_VFD_SPEED_COL: [0.5, 0.6, 0.5, 0.7, 0.5, 0.6],
}
return pd.DataFrame(data)

def fault_htg_df_high_eff(self) -> pd.DataFrame:
data = {
TEST_ERV_OAT_ENTER_COL: [10, 10, 10, 10, 10, 10],
TEST_ERV_OAT_LEAVING_COL: [90.0, 90.5, 90.8, 90.6, 90.2, 90.4],
TEST_ERV_EAT_ENTER_COL: [70, 70, 70, 70, 70, 70],
TEST_ERV_EAT_LEAVING_COL: [60, 60.5, 60.2, 60.4, 60.1, 60.3],
TEST_SUPPLY_VFD_SPEED_COL: [0.5, 0.6, 0.5, 0.7, 0.5, 0.6],
}
return pd.DataFrame(data)

def test_no_fault_htg(self):
results = fc16.apply(self.no_fault_htg_df())
actual = results["fc16_flag"].sum()
expected = 0
message = f"FC16 no_fault_htg actual is {actual} and expected is {expected}"
assert actual == expected, message

def test_fault_htg_low_eff(self):
results = fc16.apply(self.fault_htg_df_low_eff())
actual = results["fc16_flag"].sum()
expected = 6
message = (
f"FC16 fault_htg_low_eff actual is {actual} and expected is {expected}"
)
assert actual == expected, message

def test_fault_htg_high_eff(self):
results = fc16.apply(self.fault_htg_df_high_eff())
actual = results["fc16_flag"].sum()
expected = 6
message = (
f"FC16 fault_htg_high_eff actual is {actual} and expected is {expected}"
)
assert actual == expected, message


class TestFaultOnInvalidParams:

def test_invalid_param_type(self):
"""Test that InvalidParameterError is raised for non-float parameters."""
with pytest.raises(InvalidParameterError) as excinfo:
FaultConditionSixteen(
{
"ERV_EFFICIENCY_MIN_HEATING": "0.65", # Invalid, should be float
"ERV_EFFICIENCY_MAX_HEATING": 0.8,
"ERV_EFFICIENCY_MIN_COOLING": 0.45,
"ERV_EFFICIENCY_MAX_COOLING": 0.6,
"OAT_LOW_THRESHOLD": 32.0,
"OAT_HIGH_THRESHOLD": 80.0,
"ERV_DEGF_ERR_THRES": 2.0,
"ERV_OAT_ENTER_COL": TEST_ERV_OAT_ENTER_COL,
"ERV_OAT_LEAVING_COL": TEST_ERV_OAT_LEAVING_COL,
"ERV_EAT_ENTER_COL": TEST_ERV_EAT_ENTER_COL,
"ERV_EAT_LEAVING_COL": TEST_ERV_EAT_LEAVING_COL,
"SUPPLY_VFD_SPEED_COL": TEST_SUPPLY_VFD_SPEED_COL,
}
)
assert "should be a float" in str(excinfo.value)

def test_invalid_efficiency_value(self):
"""Test that InvalidParameterError is raised if efficiency values are out of 0.0 - 1.0 range."""
with pytest.raises(InvalidParameterError) as excinfo:
FaultConditionSixteen(
{
"ERV_EFFICIENCY_MIN_HEATING": 75.0, # Invalid, should be between 0.0 and 1.0
"ERV_EFFICIENCY_MAX_HEATING": 0.8,
"ERV_EFFICIENCY_MIN_COOLING": 0.45,
"ERV_EFFICIENCY_MAX_COOLING": 0.6,
"OAT_LOW_THRESHOLD": 32.0,
"OAT_HIGH_THRESHOLD": 80.0,
"ERV_DEGF_ERR_THRES": 2.0,
"ERV_OAT_ENTER_COL": TEST_ERV_OAT_ENTER_COL,
"ERV_OAT_LEAVING_COL": TEST_ERV_OAT_LEAVING_COL,
"ERV_EAT_ENTER_COL": TEST_ERV_EAT_ENTER_COL,
"ERV_EAT_LEAVING_COL": TEST_ERV_EAT_LEAVING_COL,
"SUPPLY_VFD_SPEED_COL": TEST_SUPPLY_VFD_SPEED_COL,
}
)
assert "should be a float between 0.0 and 1.0" in str(excinfo.value)


class TestFaultOnMissingColumns:

def test_missing_column(self):
"""Test that MissingColumnError is raised if any required column is None or missing."""
with pytest.raises(MissingColumnError) as excinfo:
FaultConditionSixteen(
{
"ERV_EFFICIENCY_MIN_HEATING": 0.65,
"ERV_EFFICIENCY_MAX_HEATING": 0.8,
"ERV_EFFICIENCY_MIN_COOLING": 0.45,
"ERV_EFFICIENCY_MAX_COOLING": 0.6,
"OAT_LOW_THRESHOLD": 32.0,
"OAT_HIGH_THRESHOLD": 80.0,
"ERV_DEGF_ERR_THRES": 2.0,
"ERV_OAT_ENTER_COL": TEST_ERV_OAT_ENTER_COL,
"ERV_OAT_LEAVING_COL": None, # Missing column
"ERV_EAT_ENTER_COL": TEST_ERV_EAT_ENTER_COL,
"ERV_EAT_LEAVING_COL": TEST_ERV_EAT_LEAVING_COL,
"SUPPLY_VFD_SPEED_COL": TEST_SUPPLY_VFD_SPEED_COL,
}
).apply(
pd.DataFrame(
{
TEST_ERV_OAT_ENTER_COL: [10, 10, 10],
TEST_ERV_EAT_ENTER_COL: [70, 70, 70],
TEST_ERV_EAT_LEAVING_COL: [60, 60, 60],
TEST_SUPPLY_VFD_SPEED_COL: [0.5, 0.5, 0.5],
}
)
)
assert "One or more required columns are missing or None" in str(excinfo.value)


if __name__ == "__main__":
pytest.main()

0 comments on commit f6d06d5

Please sign in to comment.