open-fdd 0.1.7__py3-none-any.whl → 0.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,186 +1,89 @@
1
1
  import pandas as pd
2
2
  import numpy as np
3
- from open_fdd.air_handling_unit.faults.fault_condition import (
4
- FaultCondition,
5
- MissingColumnError,
6
- InvalidParameterError,
7
- )
3
+ from open_fdd.core.base_fault import BaseFaultCondition
4
+ from open_fdd.core.mixins import FaultConditionMixin
8
5
  from open_fdd.air_handling_unit.faults.helper_utils import SharedUtils
9
6
  import operator
10
7
  import sys
8
+ from open_fdd.core.exceptions import MissingColumnError, InvalidParameterError
11
9
 
12
10
 
13
- class FaultConditionOne(FaultCondition):
11
+ class FaultConditionOne(BaseFaultCondition, FaultConditionMixin):
14
12
  """Class provides the definitions for Fault Condition 1.
15
13
  AHU low duct static pressure fan fault.
16
14
 
17
15
  py -3.12 -m pytest open_fdd/tests/ahu/test_ahu_fc1.py -rP -s
18
16
  """
19
17
 
20
- def __init__(self, dict_):
21
- super().__init__()
22
-
23
- # Threshold parameters
24
- self.vfd_speed_percent_err_thres = dict_.get(
25
- "VFD_SPEED_PERCENT_ERR_THRES", None
26
- )
27
- self.vfd_speed_percent_max = dict_.get("VFD_SPEED_PERCENT_MAX", None)
28
- self.duct_static_inches_err_thres = dict_.get(
29
- "DUCT_STATIC_INCHES_ERR_THRES", None
30
- )
31
-
32
- # Validate that threshold parameters are floats
33
- for param, value in [
34
- ("vfd_speed_percent_err_thres", self.vfd_speed_percent_err_thres),
35
- ("vfd_speed_percent_max", self.vfd_speed_percent_max),
36
- ("duct_static_inches_err_thres", self.duct_static_inches_err_thres),
37
- ]:
38
- if not isinstance(value, float):
39
- raise InvalidParameterError(
40
- f"The parameter '{param}' should be a float, but got {type(value).__name__}."
41
- )
42
-
43
- # Other attributes
18
+ def _init_specific_attributes(self, dict_):
19
+ # Initialize specific attributes
44
20
  self.duct_static_col = dict_.get("DUCT_STATIC_COL", None)
45
21
  self.supply_vfd_speed_col = dict_.get("SUPPLY_VFD_SPEED_COL", None)
46
22
  self.duct_static_setpoint_col = dict_.get("DUCT_STATIC_SETPOINT_COL", None)
47
- self.troubleshoot_mode = dict_.get("TROUBLESHOOT_MODE", False)
48
- self.rolling_window_size = dict_.get("ROLLING_WINDOW_SIZE", None)
49
-
50
- self.equation_string = (
51
- "fc1_flag = 1 if (DSP < DPSP - εDSP) and (VFDSPD >= VFDSPD_max - εVFDSPD) "
52
- "for N consecutive values else 0 \n"
23
+ self.duct_static_inches_err_thres = dict_.get(
24
+ "DUCT_STATIC_INCHES_ERR_THRES", None
53
25
  )
54
- self.description_string = (
55
- "Fault Condition 1: Duct static too low at fan at full speed \n"
26
+ self.vfd_speed_percent_max = dict_.get("VFD_SPEED_PERCENT_MAX", None)
27
+ self.vfd_speed_percent_err_thres = dict_.get(
28
+ "VFD_SPEED_PERCENT_ERR_THRES", None
56
29
  )
57
- self.required_column_description = "Required inputs are the duct static pressure, setpoint, and supply fan VFD speed \n"
58
- self.error_string = f"One or more required columns are missing or None \n"
59
-
60
- self.set_attributes(dict_)
61
30
 
62
- # Set required columns specific to this fault condition
31
+ # Set required columns
63
32
  self.required_columns = [
64
33
  self.duct_static_col,
65
34
  self.supply_vfd_speed_col,
66
35
  self.duct_static_setpoint_col,
67
36
  ]
68
37
 
69
- # Check if any of the required columns are None
70
- if any(col is None for col in self.required_columns):
71
- raise MissingColumnError(
72
- f"{self.error_string}"
73
- f"{self.equation_string}"
74
- f"{self.description_string}"
75
- f"{self.required_column_description}"
76
- f"{self.required_columns}"
77
- )
78
-
79
- # Ensure all required columns are strings
80
- self.required_columns = [str(col) for col in self.required_columns]
81
-
82
- self.mapped_columns = (
83
- f"Your config dictionary is mapped as: {', '.join(self.required_columns)}"
84
- )
85
-
86
- def get_required_columns(self) -> str:
87
- """Called from IPython to print out."""
88
- return (
89
- f"{self.equation_string}"
90
- f"{self.description_string}"
91
- f"{self.required_column_description}"
92
- f"{self.mapped_columns}"
38
+ # Set documentation strings
39
+ self.equation_string = "fc1_flag = 1 if (DP < DPSP - εDP) and (VFDSPD >= VFDSPD_max - εVFDSPD) for N consecutive values else 0 \n"
40
+ self.description_string = (
41
+ "Fault Condition 1: Duct static too low at fan at full speed \n"
93
42
  )
43
+ self.required_column_description = "Required inputs are the duct static pressure, setpoint, and supply fan VFD speed \n"
44
+ self.error_string = "One or more required columns are missing or None \n"
94
45
 
46
+ @FaultConditionMixin._handle_errors
95
47
  def apply(self, df: pd.DataFrame) -> pd.DataFrame:
96
- try:
97
- # Ensure all required columns are present
98
- self.check_required_columns(df)
48
+ self._apply_common_checks(df)
49
+ self._apply_analog_checks(df, [self.supply_vfd_speed_col])
99
50
 
100
- if self.troubleshoot_mode:
101
- self.troubleshoot_cols(df)
102
-
103
- # Check analog outputs [data with units of %] are floats only
104
- columns_to_check = [self.supply_vfd_speed_col]
105
- self.check_analog_pct(df, columns_to_check)
106
-
107
- # Perform checks
108
- static_check = (
109
- df[self.duct_static_col]
110
- < df[self.duct_static_setpoint_col] - self.duct_static_inches_err_thres
111
- )
112
- fan_check = (
113
- df[self.supply_vfd_speed_col]
114
- >= self.vfd_speed_percent_max - self.vfd_speed_percent_err_thres
115
- )
51
+ # Convert VFD speed from percentage to fraction if needed
52
+ if (df[self.supply_vfd_speed_col] > 1.0).any():
53
+ df[self.supply_vfd_speed_col] = df[self.supply_vfd_speed_col] / 100.0
116
54
 
117
- # Combined condition check
118
- combined_check = static_check & fan_check
55
+ # Convert thresholds from percentage to fraction
56
+ vfd_speed_max = self.vfd_speed_percent_max / 100.0
57
+ vfd_speed_err_thres = self.vfd_speed_percent_err_thres / 100.0
119
58
 
120
- # Rolling sum to count consecutive trues
121
- rolling_sum = combined_check.rolling(window=self.rolling_window_size).sum()
122
-
123
- # Set flag to 1 if rolling sum equals the window size
124
- df["fc1_flag"] = (rolling_sum == self.rolling_window_size).astype(int)
125
-
126
- return df
59
+ # Specific checks
60
+ static_check = (
61
+ df[self.duct_static_col]
62
+ < df[self.duct_static_setpoint_col] - self.duct_static_inches_err_thres
63
+ )
64
+ fan_check = df[self.supply_vfd_speed_col] >= vfd_speed_max - vfd_speed_err_thres
65
+ combined_check = static_check & fan_check
127
66
 
128
- except MissingColumnError as e:
129
- print(f"Error: {e.message}")
130
- sys.stdout.flush()
131
- raise e
132
- except InvalidParameterError as e:
133
- print(f"Error: {e.message}")
134
- sys.stdout.flush()
135
- raise e
67
+ self._set_fault_flag(df, combined_check, "fc1_flag")
68
+ return df
136
69
 
137
70
 
138
- class FaultConditionTwo(FaultCondition):
71
+ class FaultConditionTwo(BaseFaultCondition, FaultConditionMixin):
139
72
  """Class provides the definitions for Fault Condition 2.
140
73
  Mix temperature too low; should be between outside and return air.
141
74
  """
142
75
 
143
- def __init__(self, dict_):
144
- super().__init__()
145
-
146
- # Threshold parameters
147
- self.mix_degf_err_thres = dict_.get("MIX_DEGF_ERR_THRES", None)
148
- self.return_degf_err_thres = dict_.get("RETURN_DEGF_ERR_THRES", None)
149
- self.outdoor_degf_err_thres = dict_.get("OUTDOOR_DEGF_ERR_THRES", None)
150
-
151
- # Validate that threshold parameters are floats
152
- for param, value in [
153
- ("mix_degf_err_thres", self.mix_degf_err_thres),
154
- ("return_degf_err_thres", self.return_degf_err_thres),
155
- ("outdoor_degf_err_thres", self.outdoor_degf_err_thres),
156
- ]:
157
- if not isinstance(value, float):
158
- raise InvalidParameterError(
159
- f"The parameter '{param}' should be a float, but got {type(value).__name__}."
160
- )
161
-
162
- # Other attributes
76
+ def _init_specific_attributes(self, dict_):
77
+ # Initialize specific attributes
163
78
  self.mat_col = dict_.get("MAT_COL", None)
164
79
  self.rat_col = dict_.get("RAT_COL", None)
165
80
  self.oat_col = dict_.get("OAT_COL", None)
166
81
  self.supply_vfd_speed_col = dict_.get("SUPPLY_VFD_SPEED_COL", None)
167
- self.troubleshoot_mode = dict_.get("TROUBLESHOOT_MODE", False)
168
- self.rolling_window_size = dict_.get("ROLLING_WINDOW_SIZE", None)
169
-
170
- self.equation_string = (
171
- "fc2_flag = 1 if (MAT + εMAT < min(RAT - εRAT, OAT - εOAT)) and (VFDSPD > 0) "
172
- "for N consecutive values else 0 \n"
173
- )
174
- self.description_string = "Fault Condition 2: Mix temperature too low; should be between outside and return air \n"
175
- self.required_column_description = (
176
- "Required inputs are the mix air temperature, return air temperature, outside air temperature, "
177
- "and supply fan VFD speed \n"
178
- )
179
- self.error_string = "One or more required columns are missing or None \n"
180
-
181
- self.set_attributes(dict_)
82
+ self.mix_degf_err_thres = dict_.get("MIX_DEGF_ERR_THRES", None)
83
+ self.outdoor_degf_err_thres = dict_.get("OUTDOOR_DEGF_ERR_THRES", None)
84
+ self.return_degf_err_thres = dict_.get("RETURN_DEGF_ERR_THRES", None)
182
85
 
183
- # Set required columns specific to this fault condition
86
+ # Set required columns
184
87
  self.required_columns = [
185
88
  self.mat_col,
186
89
  self.rat_col,
@@ -188,119 +91,47 @@ class FaultConditionTwo(FaultCondition):
188
91
  self.supply_vfd_speed_col,
189
92
  ]
190
93
 
191
- # Check if any of the required columns are None
192
- if any(col is None for col in self.required_columns):
193
- raise MissingColumnError(
194
- f"{self.error_string}"
195
- f"{self.equation_string}"
196
- f"{self.description_string}"
197
- f"{self.required_column_description}"
198
- f"{self.required_columns}"
199
- )
94
+ # Set documentation strings
95
+ self.equation_string = "fc2_flag = 1 if (MAT - εMAT < min(RAT - εRAT, OAT - εOAT)) and (VFDSPD > 0) for N consecutive values else 0 \n"
96
+ self.description_string = "Fault Condition 2: Mix temperature too low; should be between outside and return air \n"
97
+ self.required_column_description = "Required inputs are the mixed air temperature, return air temperature, outside air temperature, and supply fan VFD speed \n"
98
+ self.error_string = "One or more required columns are missing or None \n"
200
99
 
201
- # Ensure all required columns are strings
202
- self.required_columns = [str(col) for col in self.required_columns]
100
+ @FaultConditionMixin._handle_errors
101
+ def apply(self, df: pd.DataFrame) -> pd.DataFrame:
102
+ self._apply_common_checks(df)
103
+ self._apply_analog_checks(df, [self.supply_vfd_speed_col])
203
104
 
204
- self.mapped_columns = (
205
- f"Your config dictionary is mapped as: {', '.join(self.required_columns)}"
105
+ # Specific checks
106
+ mat_check = df[self.mat_col] - self.mix_degf_err_thres
107
+ temp_min_check = np.minimum(
108
+ df[self.rat_col] - self.return_degf_err_thres,
109
+ df[self.oat_col] - self.outdoor_degf_err_thres,
206
110
  )
207
-
208
- def get_required_columns(self) -> str:
209
- """Returns a string representation of the required columns."""
210
- return (
211
- f"{self.equation_string}"
212
- f"{self.description_string}"
213
- f"{self.required_column_description}"
214
- f"{self.mapped_columns}"
111
+ combined_check = (mat_check < temp_min_check) & (
112
+ df[self.supply_vfd_speed_col] > 0.01
215
113
  )
216
114
 
217
- def apply(self, df: pd.DataFrame) -> pd.DataFrame:
218
- try:
219
- # Ensure all required columns are present
220
- self.check_required_columns(df)
221
-
222
- if self.troubleshoot_mode:
223
- self.troubleshoot_cols(df)
224
-
225
- # Check analog outputs [data with units of %] are floats only
226
- columns_to_check = [self.supply_vfd_speed_col]
227
- self.check_analog_pct(df, columns_to_check)
228
-
229
- # Perform checks
230
- mat_check = df[self.mat_col] + self.mix_degf_err_thres
231
- temp_min_check = np.minimum(
232
- df[self.rat_col] - self.return_degf_err_thres,
233
- df[self.oat_col] - self.outdoor_degf_err_thres,
234
- )
235
-
236
- combined_check = (mat_check < temp_min_check) & (
237
- df[self.supply_vfd_speed_col] > 0.01
238
- )
239
-
240
- # Rolling sum to count consecutive trues
241
- rolling_sum = combined_check.rolling(window=self.rolling_window_size).sum()
242
-
243
- # Set flag to 1 if rolling sum equals the window size
244
- df["fc2_flag"] = (rolling_sum >= self.rolling_window_size).astype(int)
245
-
246
- return df
247
-
248
- except MissingColumnError as e:
249
- print(f"Error: {e.message}")
250
- sys.stdout.flush()
251
- raise e
252
- except InvalidParameterError as e:
253
- print(f"Error: {e.message}")
254
- sys.stdout.flush()
255
- raise e
115
+ self._set_fault_flag(df, combined_check, "fc2_flag")
116
+ return df
256
117
 
257
118
 
258
- class FaultConditionThree(FaultCondition):
119
+ class FaultConditionThree(BaseFaultCondition, FaultConditionMixin):
259
120
  """Class provides the definitions for Fault Condition 3.
260
121
  Mix temperature too high; should be between outside and return air.
261
122
  """
262
123
 
263
- def __init__(self, dict_):
264
- super().__init__()
265
-
266
- # Threshold parameters
267
- self.mix_degf_err_thres = dict_.get("MIX_DEGF_ERR_THRES", None)
268
- self.return_degf_err_thres = dict_.get("RETURN_DEGF_ERR_THRES", None)
269
- self.outdoor_degf_err_thres = dict_.get("OUTDOOR_DEGF_ERR_THRES", None)
270
-
271
- # Validate that threshold parameters are floats
272
- for param, value in [
273
- ("mix_degf_err_thres", self.mix_degf_err_thres),
274
- ("return_degf_err_thres", self.return_degf_err_thres),
275
- ("outdoor_degf_err_thres", self.outdoor_degf_err_thres),
276
- ]:
277
- if not isinstance(value, float):
278
- raise InvalidParameterError(
279
- f"The parameter '{param}' should be a float, but got {type(value).__name__}."
280
- )
281
-
282
- # Other attributes
124
+ def _init_specific_attributes(self, dict_):
125
+ # Initialize specific attributes
283
126
  self.mat_col = dict_.get("MAT_COL", None)
284
127
  self.rat_col = dict_.get("RAT_COL", None)
285
128
  self.oat_col = dict_.get("OAT_COL", None)
286
129
  self.supply_vfd_speed_col = dict_.get("SUPPLY_VFD_SPEED_COL", None)
287
- self.troubleshoot_mode = dict_.get("TROUBLESHOOT_MODE", False)
288
- self.rolling_window_size = dict_.get("ROLLING_WINDOW_SIZE", None)
289
-
290
- self.equation_string = (
291
- "fc3_flag = 1 if (MAT - εMAT > max(RAT + εRAT, OAT + εOAT)) and (VFDSPD > 0) "
292
- "for N consecutive values else 0 \n"
293
- )
294
- self.description_string = "Fault Condition 3: Mix temperature too high; should be between outside and return air \n"
295
- self.required_column_description = (
296
- "Required inputs are the mix air temperature, return air temperature, outside air temperature, "
297
- "and supply fan VFD speed \n"
298
- )
299
- self.error_string = "One or more required columns are missing or None \n"
300
-
301
- self.set_attributes(dict_)
130
+ self.mix_degf_err_thres = dict_.get("MIX_DEGF_ERR_THRES", None)
131
+ self.outdoor_degf_err_thres = dict_.get("OUTDOOR_DEGF_ERR_THRES", None)
132
+ self.return_degf_err_thres = dict_.get("RETURN_DEGF_ERR_THRES", None)
302
133
 
303
- # Set required columns specific to this fault condition
134
+ # Set required columns
304
135
  self.required_columns = [
305
136
  self.mat_col,
306
137
  self.rat_col,
@@ -308,74 +139,32 @@ class FaultConditionThree(FaultCondition):
308
139
  self.supply_vfd_speed_col,
309
140
  ]
310
141
 
311
- # Check if any of the required columns are None
312
- if any(col is None for col in self.required_columns):
313
- raise MissingColumnError(
314
- f"{self.error_string}"
315
- f"{self.equation_string}"
316
- f"{self.description_string}"
317
- f"{self.required_column_description}"
318
- f"{self.required_columns}"
319
- )
142
+ # Set documentation strings
143
+ self.equation_string = "fc3_flag = 1 if (MAT - εMAT > max(RAT + εRAT, OAT + εOAT)) and (VFDSPD > 0) for N consecutive values else 0 \n"
144
+ self.description_string = "Fault Condition 3: Mix temperature too high; should be between outside and return air \n"
145
+ self.required_column_description = "Required inputs are the mixed air temperature, return air temperature, outside air temperature, and supply fan VFD speed \n"
146
+ self.error_string = "One or more required columns are missing or None \n"
320
147
 
321
- # Ensure all required columns are strings
322
- self.required_columns = [str(col) for col in self.required_columns]
148
+ @FaultConditionMixin._handle_errors
149
+ def apply(self, df: pd.DataFrame) -> pd.DataFrame:
150
+ self._apply_common_checks(df)
151
+ self._apply_analog_checks(df, [self.supply_vfd_speed_col])
323
152
 
324
- self.mapped_columns = (
325
- f"Your config dictionary is mapped as: {', '.join(self.required_columns)}"
153
+ # Specific checks
154
+ mat_check = df[self.mat_col] - self.mix_degf_err_thres
155
+ temp_max_check = np.maximum(
156
+ df[self.rat_col] + self.return_degf_err_thres,
157
+ df[self.oat_col] + self.outdoor_degf_err_thres,
326
158
  )
327
-
328
- def get_required_columns(self) -> str:
329
- """Returns a string representation of the required columns."""
330
- return (
331
- f"{self.equation_string}"
332
- f"{self.description_string}"
333
- f"{self.required_column_description}"
334
- f"{self.mapped_columns}"
159
+ combined_check = (mat_check > temp_max_check) & (
160
+ df[self.supply_vfd_speed_col] > 0.01
335
161
  )
336
162
 
337
- def apply(self, df: pd.DataFrame) -> pd.DataFrame:
338
- try:
339
- # Ensure all required columns are present
340
- self.check_required_columns(df)
341
-
342
- if self.troubleshoot_mode:
343
- self.troubleshoot_cols(df)
344
-
345
- # Check analog outputs [data with units of %] are floats only
346
- columns_to_check = [self.supply_vfd_speed_col]
347
- self.check_analog_pct(df, columns_to_check)
348
-
349
- # Perform checks
350
- mat_check = df[self.mat_col] - self.mix_degf_err_thres
351
- temp_max_check = np.maximum(
352
- df[self.rat_col] + self.return_degf_err_thres,
353
- df[self.oat_col] + self.outdoor_degf_err_thres,
354
- )
355
-
356
- combined_check = (mat_check > temp_max_check) & (
357
- df[self.supply_vfd_speed_col] > 0.01
358
- )
359
-
360
- # Rolling sum to count consecutive trues
361
- rolling_sum = combined_check.rolling(window=self.rolling_window_size).sum()
362
-
363
- # Set flag to 1 if rolling sum equals the window size
364
- df["fc3_flag"] = (rolling_sum >= self.rolling_window_size).astype(int)
365
-
366
- return df
367
-
368
- except MissingColumnError as e:
369
- print(f"Error: {e.message}")
370
- sys.stdout.flush()
371
- raise e
372
- except InvalidParameterError as e:
373
- print(f"Error: {e.message}")
374
- sys.stdout.flush()
375
- raise e
163
+ self._set_fault_flag(df, combined_check, "fc3_flag")
164
+ return df
376
165
 
377
166
 
378
- class FaultConditionFour(FaultCondition):
167
+ class FaultConditionFour(BaseFaultCondition, FaultConditionMixin):
379
168
  """Class provides the definitions for Fault Condition 4.
380
169
 
381
170
  This fault flags excessive operating states on the AHU
@@ -387,15 +176,12 @@ class FaultConditionFour(FaultCondition):
387
176
  py -3.12 -m pytest open_fdd/tests/ahu/test_ahu_fc4.py -rP -s
388
177
  """
389
178
 
390
- def __init__(self, dict_):
391
- super().__init__()
392
-
179
+ def _init_specific_attributes(self, dict_):
393
180
  # Threshold parameters
394
181
  self.delta_os_max = dict_.get("DELTA_OS_MAX", None)
395
182
  self.ahu_min_oa_dpr = dict_.get("AHU_MIN_OA_DPR", None)
396
183
 
397
184
  # Validate that delta_os_max can be either a float or an integer
398
- # if not isinstance(self.delta_os_max, (float, int)):
399
185
  if not isinstance(self.delta_os_max, (int)):
400
186
  raise InvalidParameterError(
401
187
  f"The parameter 'delta_os_max' should be an integer data type, but got {type(self.delta_os_max).__name__}."
@@ -412,8 +198,8 @@ class FaultConditionFour(FaultCondition):
412
198
  self.heating_sig_col = dict_.get("HEATING_SIG_COL", None)
413
199
  self.cooling_sig_col = dict_.get("COOLING_SIG_COL", None)
414
200
  self.supply_vfd_speed_col = dict_.get("SUPPLY_VFD_SPEED_COL", None)
415
- self.troubleshoot_mode = dict_.get("TROUBLESHOOT_MODE", False)
416
201
 
202
+ # Set documentation strings
417
203
  self.equation_string = (
418
204
  "fc4_flag = 1 if excessive mode changes (> δOS_max) occur "
419
205
  "within an hour across heating, econ, econ+mech, mech clg, and min OA modes \n"
@@ -425,8 +211,6 @@ class FaultConditionFour(FaultCondition):
425
211
  )
426
212
  self.error_string = "One or more required columns are missing or None \n"
427
213
 
428
- self.set_attributes(dict_)
429
-
430
214
  # Set required columns, making heating and cooling optional
431
215
  self.required_columns = [
432
216
  self.economizer_sig_col,
@@ -439,137 +223,48 @@ class FaultConditionFour(FaultCondition):
439
223
  if self.cooling_sig_col:
440
224
  self.required_columns.append(self.cooling_sig_col)
441
225
 
442
- # Check if any of the required columns are None
443
- if any(col is None for col in self.required_columns):
444
- raise MissingColumnError(
445
- f"{self.error_string}"
446
- f"{self.equation_string}"
447
- f"{self.description_string}"
448
- f"{self.required_column_description}"
449
- f"{self.required_columns}"
450
- )
451
- # Ensure all required columns are strings
452
- self.required_columns = [str(col) for col in self.required_columns]
453
-
454
- self.mapped_columns = (
455
- f"Your config dictionary is mapped as: {', '.join(self.required_columns)}"
456
- )
457
-
458
- def get_required_columns(self) -> str:
459
- """Returns a string representation of the required columns."""
460
- return (
461
- f"{self.equation_string}"
462
- f"{self.description_string}"
463
- f"{self.required_column_description}"
464
- f"{self.mapped_columns}"
465
- )
466
-
226
+ @FaultConditionMixin._handle_errors
467
227
  def apply(self, df: pd.DataFrame) -> pd.DataFrame:
468
- try:
469
- # Ensure all required columns are present
470
- self.check_required_columns(df)
471
-
472
- # If the optional columns are not present, create them with all values set to 0.0
473
- if self.heating_sig_col not in df.columns:
474
- df[self.heating_sig_col] = 0.0
475
- if self.cooling_sig_col not in df.columns:
476
- df[self.cooling_sig_col] = 0.0
477
-
478
- if self.troubleshoot_mode:
479
- self.troubleshoot_cols(df)
480
-
481
- # Check analog outputs [data with units of %] are floats only
482
- columns_to_check = [
483
- self.economizer_sig_col,
484
- self.heating_sig_col,
485
- self.cooling_sig_col,
486
- self.supply_vfd_speed_col,
487
- ]
488
-
489
- for col in columns_to_check:
490
- self.check_analog_pct(df, [col])
491
-
492
- print("=" * 50)
493
- print("Warning: The program is in FC4 and resampling the data")
494
- print("to compute AHU OS state changes per hour")
495
- print("to flag any hunting issue")
496
- print("and this usually takes a while to run...")
497
- print("=" * 50)
498
-
499
- sys.stdout.flush()
500
-
501
- # AHU htg only mode based on OA damper @ min oa and only htg pid/vlv modulating
502
- df["heating_mode"] = (
503
- (df[self.heating_sig_col] > 0)
504
- & (df[self.cooling_sig_col] == 0)
505
- & (df[self.supply_vfd_speed_col] > 0)
506
- & (df[self.economizer_sig_col] == self.ahu_min_oa_dpr)
507
- )
508
-
509
- # AHU econ only mode based on OA damper modulating and clg htg = zero
510
- df["econ_only_cooling_mode"] = (
511
- (df[self.heating_sig_col] == 0)
512
- & (df[self.cooling_sig_col] == 0)
513
- & (df[self.supply_vfd_speed_col] > 0)
514
- & (df[self.economizer_sig_col] > self.ahu_min_oa_dpr)
515
- )
516
-
517
- # AHU econ+mech clg mode based on OA damper modulating for cooling and clg pid/vlv modulating
518
- df["econ_plus_mech_cooling_mode"] = (
519
- (df[self.heating_sig_col] == 0)
520
- & (df[self.cooling_sig_col] > 0)
521
- & (df[self.supply_vfd_speed_col] > 0)
522
- & (df[self.economizer_sig_col] > self.ahu_min_oa_dpr)
523
- )
524
-
525
- # AHU mech mode based on OA damper @ min OA and clg pid/vlv modulating
526
- df["mech_cooling_only_mode"] = (
527
- (df[self.heating_sig_col] == 0)
528
- & (df[self.cooling_sig_col] > 0)
529
- & (df[self.supply_vfd_speed_col] > 0)
530
- & (df[self.economizer_sig_col] == self.ahu_min_oa_dpr)
531
- )
532
-
533
- # AHU minimum OA mode without heating or cooling (ventilation mode)
534
- df["min_oa_mode_only"] = (
535
- (df[self.heating_sig_col] == 0)
536
- & (df[self.cooling_sig_col] == 0)
537
- & (df[self.supply_vfd_speed_col] > 0)
538
- & (df[self.economizer_sig_col] == self.ahu_min_oa_dpr)
539
- )
540
-
541
- # Fill non-finite values with zero or drop them
542
- df = df.fillna(0)
228
+ """Apply the fault condition to the DataFrame."""
229
+ # Apply common checks
230
+ self._apply_common_checks(df)
231
+ # Add analog checks for supply_vfd_speed_col
232
+ self._apply_analog_checks(df, [self.supply_vfd_speed_col])
233
+
234
+ # Convert VFD speed from percentage to fraction if needed
235
+ if (df[self.supply_vfd_speed_col] > 1.0).any():
236
+ df[self.supply_vfd_speed_col] = df[self.supply_vfd_speed_col] / 100.0
237
+
238
+ # Calculate operating state changes
239
+ df["os_change"] = 0
240
+ df.loc[df[self.economizer_sig_col] > 0, "os_change"] += 1
241
+ df.loc[df[self.supply_vfd_speed_col] > self.ahu_min_oa_dpr, "os_change"] += 1
242
+ if self.heating_sig_col:
243
+ df.loc[df[self.heating_sig_col] > 0, "os_change"] += 1
244
+ if self.cooling_sig_col:
245
+ df.loc[df[self.cooling_sig_col] > 0, "os_change"] += 1
543
246
 
544
- df = df.astype(int)
545
- df = df.resample("60min").apply(lambda x: (x.eq(1) & x.shift().ne(1)).sum())
247
+ # Calculate changes in operating state
248
+ df["os_change_diff"] = df["os_change"].diff().abs()
249
+ df["os_change_diff"] = df["os_change_diff"].fillna(0)
546
250
 
547
- df["fc4_flag"] = (
548
- df[df.columns].gt(self.delta_os_max).any(axis=1).astype(int)
549
- )
251
+ # Calculate rolling sum of changes
252
+ df["os_change_sum"] = df["os_change_diff"].rolling(window=60).sum()
550
253
 
551
- return df
254
+ # Set fault flag
255
+ df["fc4_flag"] = (df["os_change_sum"] > self.delta_os_max).astype(int)
552
256
 
553
- except MissingColumnError as e:
554
- print(f"Error: {e.message}")
555
- sys.stdout.flush()
556
- raise e
557
- except InvalidParameterError as e:
558
- print(f"Error: {e.message}")
559
- sys.stdout.flush()
560
- raise e
257
+ return df
561
258
 
562
259
 
563
- class FaultConditionFive(FaultCondition):
260
+ class FaultConditionFive(BaseFaultCondition, FaultConditionMixin):
564
261
  """Class provides the definitions for Fault Condition 5.
565
262
  SAT too low; should be higher than MAT in HTG MODE
566
263
  --Broken heating valve or other mechanical issue
567
264
  related to heat valve not working as designed
568
265
  """
569
266
 
570
- def __init__(self, dict_):
571
- super().__init__()
572
-
267
+ def _init_specific_attributes(self, dict_):
573
268
  # Threshold parameters
574
269
  self.mix_degf_err_thres = dict_.get("MIX_DEGF_ERR_THRES", None)
575
270
  self.supply_degf_err_thres = dict_.get("SUPPLY_DEGF_ERR_THRES", None)
@@ -591,9 +286,8 @@ class FaultConditionFive(FaultCondition):
591
286
  self.sat_col = dict_.get("SAT_COL", None)
592
287
  self.heating_sig_col = dict_.get("HEATING_SIG_COL", None)
593
288
  self.supply_vfd_speed_col = dict_.get("SUPPLY_VFD_SPEED_COL", None)
594
- self.troubleshoot_mode = dict_.get("TROUBLESHOOT_MODE", False)
595
- self.rolling_window_size = dict_.get("ROLLING_WINDOW_SIZE", None)
596
289
 
290
+ # Set documentation strings
597
291
  self.equation_string = (
598
292
  "fc5_flag = 1 if (SAT + εSAT <= MAT - εMAT + ΔT_supply_fan) and "
599
293
  "(heating signal > 0) and (VFDSPD > 0) for N consecutive values else 0 \n"
@@ -608,8 +302,6 @@ class FaultConditionFive(FaultCondition):
608
302
  )
609
303
  self.error_string = "One or more required columns are missing or None \n"
610
304
 
611
- self.set_attributes(dict_)
612
-
613
305
  # Set required columns specific to this fault condition
614
306
  self.required_columns = [
615
307
  self.mat_col,
@@ -618,72 +310,33 @@ class FaultConditionFive(FaultCondition):
618
310
  self.supply_vfd_speed_col,
619
311
  ]
620
312
 
621
- # Check if any of the required columns are None
622
- if any(col is None for col in self.required_columns):
623
- raise MissingColumnError(
624
- f"{self.error_string}"
625
- f"{self.equation_string}"
626
- f"{self.description_string}"
627
- f"{self.required_column_description}"
628
- f"{self.required_columns}"
629
- )
630
-
631
- # Ensure all required columns are strings
632
- self.required_columns = [str(col) for col in self.required_columns]
633
-
634
- self.mapped_columns = (
635
- f"Your config dictionary is mapped as: {', '.join(self.required_columns)}"
636
- )
637
-
638
- def get_required_columns(self) -> str:
639
- """Returns a string representation of the required columns."""
640
- return (
641
- f"{self.equation_string}"
642
- f"{self.description_string}"
643
- f"{self.required_column_description}"
644
- f"{self.mapped_columns}"
645
- )
646
-
313
+ @FaultConditionMixin._handle_errors
647
314
  def apply(self, df: pd.DataFrame) -> pd.DataFrame:
648
- try:
649
- # Ensure all required columns are present
650
- self.check_required_columns(df)
651
-
652
- # Check analog outputs [data with units of %] are floats only
653
- columns_to_check = [self.supply_vfd_speed_col, self.heating_sig_col]
654
- self.check_analog_pct(df, columns_to_check)
655
-
656
- # Perform checks
657
- sat_check = df[self.sat_col] + self.supply_degf_err_thres
658
- mat_check = (
659
- df[self.mat_col] - self.mix_degf_err_thres + self.delta_t_supply_fan
660
- )
315
+ """Apply the fault condition to the DataFrame."""
316
+ # Apply common checks
317
+ self._apply_common_checks(df)
661
318
 
662
- combined_check = (
663
- (sat_check <= mat_check)
664
- & (df[self.heating_sig_col] > 0.01)
665
- & (df[self.supply_vfd_speed_col] > 0.01)
666
- )
319
+ # Check analog outputs [data with units of %] are floats only
320
+ columns_to_check = [self.supply_vfd_speed_col, self.heating_sig_col]
321
+ self._apply_analog_checks(df, columns_to_check)
667
322
 
668
- # Rolling sum to count consecutive trues
669
- rolling_sum = combined_check.rolling(window=self.rolling_window_size).sum()
323
+ # Perform checks
324
+ sat_check = df[self.sat_col] + self.supply_degf_err_thres
325
+ mat_check = df[self.mat_col] - self.mix_degf_err_thres + self.delta_t_supply_fan
670
326
 
671
- # Set flag to 1 if rolling sum equals the window size
672
- df["fc5_flag"] = (rolling_sum == self.rolling_window_size).astype(int)
327
+ combined_check = (
328
+ (sat_check <= mat_check)
329
+ & (df[self.heating_sig_col] > 0.01)
330
+ & (df[self.supply_vfd_speed_col] > 0.01)
331
+ )
673
332
 
674
- return df
333
+ # Set fault flag
334
+ self._set_fault_flag(df, combined_check, "fc5_flag")
675
335
 
676
- except MissingColumnError as e:
677
- print(f"Error: {e.message}")
678
- sys.stdout.flush()
679
- raise e
680
- except InvalidParameterError as e:
681
- print(f"Error: {e.message}")
682
- sys.stdout.flush()
683
- raise e
336
+ return df
684
337
 
685
338
 
686
- class FaultConditionSix(FaultCondition):
339
+ class FaultConditionSix(BaseFaultCondition, FaultConditionMixin):
687
340
  """Class provides the definitions for Fault Condition 6.
688
341
 
689
342
  This fault related to knowing the design air flow for
@@ -700,9 +353,7 @@ class FaultConditionSix(FaultCondition):
700
353
  py -3.12 -m pytest open_fdd/tests/ahu/test_ahu_fc6.py -rP -s
701
354
  """
702
355
 
703
- def __init__(self, dict_):
704
- super().__init__()
705
-
356
+ def _init_specific_attributes(self, dict_):
706
357
  # Threshold parameters
707
358
  self.airflow_err_thres = dict_.get("AIRFLOW_ERR_THRES", None)
708
359
  self.ahu_min_oa_cfm_design = dict_.get("AHU_MIN_OA_CFM_DESIGN", None)
@@ -738,9 +389,8 @@ class FaultConditionSix(FaultCondition):
738
389
  self.economizer_sig_col = dict_.get("ECONOMIZER_SIG_COL", None)
739
390
  self.heating_sig_col = dict_.get("HEATING_SIG_COL", None)
740
391
  self.cooling_sig_col = dict_.get("COOLING_SIG_COL", None)
741
- self.troubleshoot_mode = dict_.get("TROUBLESHOOT_MODE", False)
742
- self.rolling_window_size = dict_.get("ROLLING_WINDOW_SIZE", None)
743
392
 
393
+ # Set documentation strings
744
394
  self.equation_string = (
745
395
  "fc6_flag = 1 if |OA_frac_calc - OA_min| > airflow_err_thres "
746
396
  "in non-economizer modes, considering htg and mech clg OS \n"
@@ -756,8 +406,6 @@ class FaultConditionSix(FaultCondition):
756
406
  )
757
407
  self.error_string = "One or more required columns are missing or None \n"
758
408
 
759
- self.set_attributes(dict_)
760
-
761
409
  # Set required columns specific to this fault condition
762
410
  self.required_columns = [
763
411
  self.supply_fan_air_volume_col,
@@ -770,103 +418,66 @@ class FaultConditionSix(FaultCondition):
770
418
  self.cooling_sig_col,
771
419
  ]
772
420
 
773
- # Check if any of the required columns are None
774
- if any(col is None for col in self.required_columns):
775
- raise MissingColumnError(
776
- f"{self.error_string}"
777
- f"{self.equation_string}"
778
- f"{self.description_string}"
779
- f"{self.required_column_description}"
780
- f"{self.required_columns}"
781
- )
421
+ @FaultConditionMixin._handle_errors
422
+ def apply(self, df: pd.DataFrame) -> pd.DataFrame:
423
+ """Apply the fault condition to the DataFrame."""
424
+ # Apply common checks
425
+ self._apply_common_checks(df)
782
426
 
783
- # Ensure all required columns are strings
784
- self.required_columns = [str(col) for col in self.required_columns]
427
+ # Check for zeros in the columns that could lead to division by zero errors
428
+ cols_to_check = [self.rat_col, self.oat_col, self.supply_fan_air_volume_col]
429
+ if df[cols_to_check].eq(0).any().any():
430
+ print(f"Warning: Zero values found in columns: {cols_to_check}")
431
+ print("This may cause division by zero errors.")
432
+ sys.stdout.flush()
785
433
 
786
- self.mapped_columns = (
787
- f"Your config dictionary is mapped as: {', '.join(self.required_columns)}"
788
- )
434
+ # Check analog outputs [data with units of %] are floats only
435
+ columns_to_check = [
436
+ self.supply_vfd_speed_col,
437
+ self.economizer_sig_col,
438
+ self.heating_sig_col,
439
+ self.cooling_sig_col,
440
+ ]
441
+ self._apply_analog_checks(df, columns_to_check)
789
442
 
790
- def get_required_columns(self) -> str:
791
- """Returns a string representation of the required columns."""
792
- return (
793
- f"{self.equation_string}"
794
- f"{self.description_string}"
795
- f"{self.required_column_description}"
796
- f"{self.mapped_columns}"
443
+ # Calculate intermediate values
444
+ rat_minus_oat = abs(df[self.rat_col] - df[self.oat_col])
445
+ percent_oa_calc = (df[self.mat_col] - df[self.rat_col]) / (
446
+ df[self.oat_col] - df[self.rat_col]
797
447
  )
798
448
 
799
- def apply(self, df: pd.DataFrame) -> pd.DataFrame:
800
- try:
801
- # Ensure all required columns are present
802
- self.check_required_columns(df)
803
-
804
- # Check for zeros in the columns that could lead to division by zero errors
805
- cols_to_check = [self.rat_col, self.oat_col, self.supply_fan_air_volume_col]
806
- if df[cols_to_check].eq(0).any().any():
807
- print(f"Warning: Zero values found in columns: {cols_to_check}")
808
- print("This may cause division by zero errors.")
809
- sys.stdout.flush()
810
-
811
- # Check analog outputs [data with units of %] are floats only
812
- columns_to_check = [
813
- self.supply_vfd_speed_col,
814
- self.economizer_sig_col,
815
- self.heating_sig_col,
816
- self.cooling_sig_col,
817
- ]
818
- self.check_analog_pct(df, columns_to_check)
819
-
820
- # Calculate intermediate values
821
- rat_minus_oat = abs(df[self.rat_col] - df[self.oat_col])
822
- percent_oa_calc = (df[self.mat_col] - df[self.rat_col]) / (
823
- df[self.oat_col] - df[self.rat_col]
824
- )
825
-
826
- # Replace negative values in percent_oa_calc with zero using vectorized operation
827
- percent_oa_calc = percent_oa_calc.clip(lower=0)
828
-
829
- perc_OAmin = self.ahu_min_oa_cfm_design / df[self.supply_fan_air_volume_col]
830
- percent_oa_calc_minus_perc_OAmin = abs(percent_oa_calc - perc_OAmin)
831
-
832
- # Combined checks for OS 1 and OS 4 modes
833
- os1_htg_mode_check = (
834
- (rat_minus_oat >= self.oat_rat_delta_min)
835
- & (percent_oa_calc_minus_perc_OAmin > self.airflow_err_thres)
836
- & (df[self.heating_sig_col] > 0.0)
837
- & (df[self.supply_vfd_speed_col] > 0.0)
838
- )
449
+ # Replace negative values in percent_oa_calc with zero using vectorized operation
450
+ percent_oa_calc = percent_oa_calc.clip(lower=0)
839
451
 
840
- os4_clg_mode_check = (
841
- (rat_minus_oat >= self.oat_rat_delta_min)
842
- & (percent_oa_calc_minus_perc_OAmin > self.airflow_err_thres)
843
- & (df[self.heating_sig_col] == 0.0)
844
- & (df[self.cooling_sig_col] > 0.0)
845
- & (df[self.supply_vfd_speed_col] > 0.0)
846
- & (df[self.economizer_sig_col] == self.ahu_min_oa_dpr)
847
- )
452
+ perc_OAmin = self.ahu_min_oa_cfm_design / df[self.supply_fan_air_volume_col]
453
+ percent_oa_calc_minus_perc_OAmin = abs(percent_oa_calc - perc_OAmin)
848
454
 
849
- combined_check = os1_htg_mode_check | os4_clg_mode_check
455
+ # Combined checks for OS 1 and OS 4 modes
456
+ os1_htg_mode_check = (
457
+ (rat_minus_oat >= self.oat_rat_delta_min)
458
+ & (percent_oa_calc_minus_perc_OAmin > self.airflow_err_thres)
459
+ & (df[self.heating_sig_col] > 0.0)
460
+ & (df[self.supply_vfd_speed_col] > 0.0)
461
+ )
850
462
 
851
- # Rolling sum to count consecutive trues
852
- rolling_sum = combined_check.rolling(window=self.rolling_window_size).sum()
463
+ os4_clg_mode_check = (
464
+ (rat_minus_oat >= self.oat_rat_delta_min)
465
+ & (percent_oa_calc_minus_perc_OAmin > self.airflow_err_thres)
466
+ & (df[self.heating_sig_col] == 0.0)
467
+ & (df[self.cooling_sig_col] > 0.0)
468
+ & (df[self.supply_vfd_speed_col] > 0.0)
469
+ & (df[self.economizer_sig_col] == self.ahu_min_oa_dpr)
470
+ )
853
471
 
854
- # Set flag to 1 if rolling sum equals the window size
855
- df["fc6_flag"] = (rolling_sum == self.rolling_window_size).astype(int)
472
+ combined_check = os1_htg_mode_check | os4_clg_mode_check
856
473
 
857
- return df
474
+ # Set fault flag
475
+ self._set_fault_flag(df, combined_check, "fc6_flag")
858
476
 
859
- except MissingColumnError as e:
860
- print(f"Error: {e.message}")
861
- sys.stdout.flush()
862
- raise e
863
- except InvalidParameterError as e:
864
- print(f"Error: {e.message}")
865
- sys.stdout.flush()
866
- raise e
477
+ return df
867
478
 
868
479
 
869
- class FaultConditionSeven(FaultCondition):
480
+ class FaultConditionSeven(BaseFaultCondition, FaultConditionMixin):
870
481
  """Class provides the definitions for Fault Condition 7.
871
482
  Very similar to FC 13 but uses heating valve.
872
483
  Supply air temperature too low in full heating.
@@ -874,9 +485,7 @@ class FaultConditionSeven(FaultCondition):
874
485
  py -3.12 -m pytest open_fdd/tests/ahu/test_ahu_fc7.py -rP -s
875
486
  """
876
487
 
877
- def __init__(self, dict_):
878
- super().__init__()
879
-
488
+ def _init_specific_attributes(self, dict_):
880
489
  # Threshold parameters
881
490
  self.supply_degf_err_thres = dict_.get("SUPPLY_DEGF_ERR_THRES", None)
882
491
 
@@ -891,9 +500,8 @@ class FaultConditionSeven(FaultCondition):
891
500
  self.sat_setpoint_col = dict_.get("SAT_SETPOINT_COL", None)
892
501
  self.heating_sig_col = dict_.get("HEATING_SIG_COL", None)
893
502
  self.supply_vfd_speed_col = dict_.get("SUPPLY_VFD_SPEED_COL", None)
894
- self.troubleshoot_mode = dict_.get("TROUBLESHOOT_MODE", False)
895
- self.rolling_window_size = dict_.get("ROLLING_WINDOW_SIZE", None)
896
503
 
504
+ # Set documentation strings
897
505
  self.equation_string = (
898
506
  "fc7_flag = 1 if SAT < (SATSP - εSAT) in full heating mode "
899
507
  "and VFD speed > 0 for N consecutive values else 0 \n"
@@ -908,8 +516,6 @@ class FaultConditionSeven(FaultCondition):
908
516
  )
909
517
  self.error_string = "One or more required columns are missing or None \n"
910
518
 
911
- self.set_attributes(dict_)
912
-
913
519
  # Set required columns specific to this fault condition
914
520
  self.required_columns = [
915
521
  self.sat_col,
@@ -918,69 +524,32 @@ class FaultConditionSeven(FaultCondition):
918
524
  self.supply_vfd_speed_col,
919
525
  ]
920
526
 
921
- # Check if any of the required columns are None
922
- if any(col is None for col in self.required_columns):
923
- raise MissingColumnError(
924
- f"{self.error_string}"
925
- f"{self.equation_string}"
926
- f"{self.description_string}"
927
- f"{self.required_column_description}"
928
- f"{self.required_columns}"
929
- )
930
-
931
- # Ensure all required columns are strings
932
- self.required_columns = [str(col) for col in self.required_columns]
933
-
934
- self.mapped_columns = (
935
- f"Your config dictionary is mapped as: {', '.join(self.required_columns)}"
936
- )
937
-
938
- def get_required_columns(self) -> str:
939
- """Returns a string representation of the required columns."""
940
- return (
941
- f"{self.equation_string}"
942
- f"{self.description_string}"
943
- f"{self.required_column_description}"
944
- f"{self.mapped_columns}"
945
- )
946
-
527
+ @FaultConditionMixin._handle_errors
947
528
  def apply(self, df: pd.DataFrame) -> pd.DataFrame:
948
- try:
949
- # Ensure all required columns are present
950
- self.check_required_columns(df)
951
-
952
- # Check analog outputs [data with units of %] are floats only
953
- columns_to_check = [self.supply_vfd_speed_col, self.heating_sig_col]
954
- self.check_analog_pct(df, columns_to_check)
529
+ """Apply the fault condition to the DataFrame."""
530
+ # Apply common checks
531
+ self._apply_common_checks(df)
955
532
 
956
- # Perform checks
957
- sat_check = df[self.sat_setpoint_col] - self.supply_degf_err_thres
533
+ # Check analog outputs [data with units of %] are floats only
534
+ columns_to_check = [self.supply_vfd_speed_col, self.heating_sig_col]
535
+ self._apply_analog_checks(df, columns_to_check)
958
536
 
959
- combined_check = (
960
- (df[self.sat_col] < sat_check)
961
- & (df[self.heating_sig_col] > 0.9)
962
- & (df[self.supply_vfd_speed_col] > 0)
963
- )
964
-
965
- # Rolling sum to count consecutive trues
966
- rolling_sum = combined_check.rolling(window=self.rolling_window_size).sum()
537
+ # Perform checks
538
+ sat_check = df[self.sat_setpoint_col] - self.supply_degf_err_thres
967
539
 
968
- # Set flag to 1 if rolling sum equals the window size
969
- df["fc7_flag"] = (rolling_sum >= self.rolling_window_size).astype(int)
540
+ combined_check = (
541
+ (df[self.sat_col] < sat_check)
542
+ & (df[self.heating_sig_col] > 0.9)
543
+ & (df[self.supply_vfd_speed_col] > 0)
544
+ )
970
545
 
971
- return df
546
+ # Set fault flag
547
+ self._set_fault_flag(df, combined_check, "fc7_flag")
972
548
 
973
- except MissingColumnError as e:
974
- print(f"Error: {e.message}")
975
- sys.stdout.flush()
976
- raise e
977
- except InvalidParameterError as e:
978
- print(f"Error: {e.message}")
979
- sys.stdout.flush()
980
- raise e
549
+ return df
981
550
 
982
551
 
983
- class FaultConditionEight(FaultCondition):
552
+ class FaultConditionEight(BaseFaultCondition, FaultConditionMixin):
984
553
  """Class provides the definitions for Fault Condition 8.
985
554
  Supply air temperature and mix air temperature should
986
555
  be approx equal in economizer mode.
@@ -988,9 +557,7 @@ class FaultConditionEight(FaultCondition):
988
557
  py -3.12 -m pytest open_fdd/tests/ahu/test_ahu_fc8.py -rP -s
989
558
  """
990
559
 
991
- def __init__(self, dict_):
992
- super().__init__()
993
-
560
+ def _init_specific_attributes(self, dict_):
994
561
  # Threshold parameters
995
562
  self.delta_t_supply_fan = dict_.get("DELTA_T_SUPPLY_FAN", None)
996
563
  self.mix_degf_err_thres = dict_.get("MIX_DEGF_ERR_THRES", None)
@@ -1014,9 +581,8 @@ class FaultConditionEight(FaultCondition):
1014
581
  self.sat_col = dict_.get("SAT_COL", None)
1015
582
  self.economizer_sig_col = dict_.get("ECONOMIZER_SIG_COL", None)
1016
583
  self.cooling_sig_col = dict_.get("COOLING_SIG_COL", None)
1017
- self.troubleshoot_mode = dict_.get("TROUBLESHOOT_MODE", False)
1018
- self.rolling_window_size = dict_.get("ROLLING_WINDOW_SIZE", None)
1019
584
 
585
+ # Set documentation strings
1020
586
  self.equation_string = (
1021
587
  "fc8_flag = 1 if |SAT - MAT - ΔT_fan| > √(εSAT² + εMAT²) "
1022
588
  "in economizer mode for N consecutive values else 0 \n"
@@ -1067,51 +633,42 @@ class FaultConditionEight(FaultCondition):
1067
633
  f"{self.mapped_columns}"
1068
634
  )
1069
635
 
636
+ @FaultConditionMixin._handle_errors
1070
637
  def apply(self, df: pd.DataFrame) -> pd.DataFrame:
1071
- try:
1072
- # Ensure all required columns are present
1073
- self.check_required_columns(df)
1074
-
1075
- # Check analog outputs [data with units of %] are floats only
1076
- columns_to_check = [
1077
- self.economizer_sig_col,
1078
- self.cooling_sig_col,
1079
- ]
1080
- self.check_analog_pct(df, columns_to_check)
1081
-
1082
- # Perform checks
1083
- sat_fan_mat = abs(
1084
- df[self.sat_col] - self.delta_t_supply_fan - df[self.mat_col]
1085
- )
1086
- sat_mat_sqrted = np.sqrt(
1087
- self.supply_degf_err_thres**2 + self.mix_degf_err_thres**2
1088
- )
638
+ """Apply the fault condition to the DataFrame."""
639
+ # Apply common checks
640
+ self._apply_common_checks(df)
1089
641
 
1090
- combined_check = (
1091
- (sat_fan_mat > sat_mat_sqrted)
1092
- & (df[self.economizer_sig_col] > self.ahu_min_oa_dpr)
1093
- & (df[self.cooling_sig_col] < 0.1)
1094
- )
642
+ # Check analog outputs [data with units of %] are floats only
643
+ columns_to_check = [
644
+ self.economizer_sig_col,
645
+ self.cooling_sig_col,
646
+ ]
647
+ self._apply_analog_checks(df, columns_to_check)
1095
648
 
1096
- # Rolling sum to count consecutive trues
1097
- rolling_sum = combined_check.rolling(window=self.rolling_window_size).sum()
649
+ # Perform checks
650
+ sat_fan_mat = abs(df[self.sat_col] - self.delta_t_supply_fan - df[self.mat_col])
651
+ sat_mat_sqrted = np.sqrt(
652
+ self.supply_degf_err_thres**2 + self.mix_degf_err_thres**2
653
+ )
1098
654
 
1099
- # Set flag to 1 if rolling sum equals the window size
1100
- df["fc8_flag"] = (rolling_sum >= self.rolling_window_size).astype(int)
655
+ combined_check = (
656
+ (sat_fan_mat > sat_mat_sqrted)
657
+ # Verify AHU is running in OS 3 cooling mode with minimum OA
658
+ & (df[self.economizer_sig_col] > self.ahu_min_oa_dpr)
659
+ & (df[self.cooling_sig_col] < 0.1)
660
+ )
1101
661
 
1102
- return df
662
+ # Rolling sum to count consecutive trues
663
+ rolling_sum = combined_check.rolling(window=self.rolling_window_size).sum()
1103
664
 
1104
- except MissingColumnError as e:
1105
- print(f"Error: {e.message}")
1106
- sys.stdout.flush()
1107
- raise e
1108
- except InvalidParameterError as e:
1109
- print(f"Error: {e.message}")
1110
- sys.stdout.flush()
1111
- raise e
665
+ # Set flag to 1 if rolling sum equals the window size
666
+ df["fc8_flag"] = (rolling_sum >= self.rolling_window_size).astype(int)
667
+
668
+ return df
1112
669
 
1113
670
 
1114
- class FaultConditionNine(FaultCondition):
671
+ class FaultConditionNine(BaseFaultCondition, FaultConditionMixin):
1115
672
  """Class provides the definitions for Fault Condition 9.
1116
673
  Outside air temperature too high in free cooling without
1117
674
  additional mechanical cooling in economizer mode.
@@ -1119,9 +676,7 @@ class FaultConditionNine(FaultCondition):
1119
676
  py -3.12 -m pytest open_fdd/tests/ahu/test_ahu_fc9.py -rP -s
1120
677
  """
1121
678
 
1122
- def __init__(self, dict_):
1123
- super().__init__()
1124
-
679
+ def _init_specific_attributes(self, dict_):
1125
680
  # Threshold parameters
1126
681
  self.delta_t_supply_fan = dict_.get("DELTA_T_SUPPLY_FAN", None)
1127
682
  self.outdoor_degf_err_thres = dict_.get("OUTDOOR_DEGF_ERR_THRES", None)
@@ -1145,9 +700,8 @@ class FaultConditionNine(FaultCondition):
1145
700
  self.oat_col = dict_.get("OAT_COL", None)
1146
701
  self.cooling_sig_col = dict_.get("COOLING_SIG_COL", None)
1147
702
  self.economizer_sig_col = dict_.get("ECONOMIZER_SIG_COL", None)
1148
- self.troubleshoot_mode = dict_.get("TROUBLESHOOT_MODE", False)
1149
- self.rolling_window_size = dict_.get("ROLLING_WINDOW_SIZE", None)
1150
703
 
704
+ # Set documentation strings
1151
705
  self.equation_string = (
1152
706
  "fc9_flag = 1 if OAT > (SATSP - ΔT_fan + εSAT) "
1153
707
  "in free cooling mode for N consecutive values else 0 \n"
@@ -1162,8 +716,6 @@ class FaultConditionNine(FaultCondition):
1162
716
  )
1163
717
  self.error_string = "One or more required columns are missing or None \n"
1164
718
 
1165
- self.set_attributes(dict_)
1166
-
1167
719
  # Set required columns specific to this fault condition
1168
720
  self.required_columns = [
1169
721
  self.sat_setpoint_col,
@@ -1172,78 +724,41 @@ class FaultConditionNine(FaultCondition):
1172
724
  self.economizer_sig_col,
1173
725
  ]
1174
726
 
1175
- # Check if any of the required columns are None
1176
- if any(col is None for col in self.required_columns):
1177
- raise MissingColumnError(
1178
- f"{self.error_string}"
1179
- f"{self.equation_string}"
1180
- f"{self.description_string}"
1181
- f"{self.required_column_description}"
1182
- f"{self.required_columns}"
1183
- )
727
+ @FaultConditionMixin._handle_errors
728
+ def apply(self, df: pd.DataFrame) -> pd.DataFrame:
729
+ """Apply the fault condition to the DataFrame."""
730
+ # Apply common checks
731
+ self._apply_common_checks(df)
1184
732
 
1185
- # Ensure all required columns are strings
1186
- self.required_columns = [str(col) for col in self.required_columns]
733
+ # Check analog outputs [data with units of %] are floats only
734
+ columns_to_check = [
735
+ self.economizer_sig_col,
736
+ self.cooling_sig_col,
737
+ ]
738
+ self._apply_analog_checks(df, columns_to_check)
1187
739
 
1188
- self.mapped_columns = (
1189
- f"Your config dictionary is mapped as: {', '.join(self.required_columns)}"
740
+ # Perform calculations
741
+ oat_minus_oaterror = df[self.oat_col] - self.outdoor_degf_err_thres
742
+ satsp_delta_saterr = (
743
+ df[self.sat_setpoint_col]
744
+ - self.delta_t_supply_fan
745
+ + self.supply_degf_err_thres
1190
746
  )
1191
747
 
1192
- def get_required_columns(self) -> str:
1193
- """Returns a string representation of the required columns."""
1194
- return (
1195
- f"{self.equation_string}"
1196
- f"{self.description_string}"
1197
- f"{self.required_column_description}"
1198
- f"{self.mapped_columns}"
748
+ combined_check = (
749
+ (oat_minus_oaterror > satsp_delta_saterr)
750
+ # verify AHU is in OS2 only free cooling mode
751
+ & (df[self.economizer_sig_col] > self.ahu_min_oa_dpr)
752
+ & (df[self.cooling_sig_col] < 0.1)
1199
753
  )
1200
754
 
1201
- def apply(self, df: pd.DataFrame) -> pd.DataFrame:
1202
- try:
1203
- # Ensure all required columns are present
1204
- self.check_required_columns(df)
1205
-
1206
- # Check analog outputs [data with units of %] are floats only
1207
- columns_to_check = [
1208
- self.economizer_sig_col,
1209
- self.cooling_sig_col,
1210
- ]
1211
- self.check_analog_pct(df, columns_to_check)
1212
-
1213
- # Perform calculations
1214
- oat_minus_oaterror = df[self.oat_col] - self.outdoor_degf_err_thres
1215
- satsp_delta_saterr = (
1216
- df[self.sat_setpoint_col]
1217
- - self.delta_t_supply_fan
1218
- + self.supply_degf_err_thres
1219
- )
1220
-
1221
- combined_check = (
1222
- (oat_minus_oaterror > satsp_delta_saterr)
1223
- # verify AHU is in OS2 only free cooling mode
1224
- & (df[self.economizer_sig_col] > self.ahu_min_oa_dpr)
1225
- & (df[self.cooling_sig_col] < 0.1)
1226
- )
1227
-
1228
- # Rolling sum to count consecutive trues
1229
- rolling_sum = combined_check.rolling(window=self.rolling_window_size).sum()
755
+ # Set fault flag
756
+ self._set_fault_flag(df, combined_check, "fc9_flag")
1230
757
 
1231
- # Set flag to 1 if rolling sum equals the window size
1232
- df["fc9_flag"] = (rolling_sum >= self.rolling_window_size).astype(int)
1233
-
1234
- return df
1235
-
1236
- except MissingColumnError as e:
1237
- print(f"Error: {e.message}")
1238
- sys.stdout.flush()
1239
- raise e
1240
- except InvalidParameterError as e:
1241
- print(f"Error: {e.message}")
1242
- sys.stdout.flush()
1243
- raise e
758
+ return df
1244
759
 
1245
760
 
1246
- class FaultConditionTen(FaultCondition):
761
+ class FaultConditionTen(BaseFaultCondition, FaultConditionMixin):
1247
762
  """Class provides the definitions for Fault Condition 10.
1248
763
  Outdoor air temperature and mix air temperature should
1249
764
  be approx equal in economizer plus mech cooling mode.
@@ -1251,9 +766,7 @@ class FaultConditionTen(FaultCondition):
1251
766
  py -3.12 -m pytest open_fdd/tests/ahu/test_ahu_fc10.py -rP -s
1252
767
  """
1253
768
 
1254
- def __init__(self, dict_):
1255
- super().__init__()
1256
-
769
+ def _init_specific_attributes(self, dict_):
1257
770
  # Threshold parameters
1258
771
  self.outdoor_degf_err_thres = dict_.get("OUTDOOR_DEGF_ERR_THRES", None)
1259
772
  self.mix_degf_err_thres = dict_.get("MIX_DEGF_ERR_THRES", None)
@@ -1273,9 +786,8 @@ class FaultConditionTen(FaultCondition):
1273
786
  self.mat_col = dict_.get("MAT_COL", None)
1274
787
  self.cooling_sig_col = dict_.get("COOLING_SIG_COL", None)
1275
788
  self.economizer_sig_col = dict_.get("ECONOMIZER_SIG_COL", None)
1276
- self.troubleshoot_mode = dict_.get("TROUBLESHOOT_MODE", False)
1277
- self.rolling_window_size = dict_.get("ROLLING_WINDOW_SIZE", None)
1278
789
 
790
+ # Set documentation strings
1279
791
  self.equation_string = (
1280
792
  "fc10_flag = 1 if |OAT - MAT| > √(εOAT² + εMAT²) in "
1281
793
  "economizer + mech cooling mode for N consecutive values else 0 \n"
@@ -1290,8 +802,6 @@ class FaultConditionTen(FaultCondition):
1290
802
  )
1291
803
  self.error_string = "One or more required columns are missing or None \n"
1292
804
 
1293
- self.set_attributes(dict_)
1294
-
1295
805
  # Set required columns specific to this fault condition
1296
806
  self.required_columns = [
1297
807
  self.oat_col,
@@ -1300,97 +810,55 @@ class FaultConditionTen(FaultCondition):
1300
810
  self.economizer_sig_col,
1301
811
  ]
1302
812
 
1303
- # Check if any of the required columns are None
1304
- if any(col is None for col in self.required_columns):
1305
- raise MissingColumnError(
1306
- f"{self.error_string}"
1307
- f"{self.equation_string}"
1308
- f"{self.description_string}"
1309
- f"{self.required_column_description}"
1310
- f"{self.required_columns}"
1311
- )
813
+ @FaultConditionMixin._handle_errors
814
+ def apply(self, df: pd.DataFrame) -> pd.DataFrame:
815
+ """Apply the fault condition to the DataFrame."""
816
+ # Apply common checks
817
+ self._apply_common_checks(df)
1312
818
 
1313
- # Ensure all required columns are strings
1314
- self.required_columns = [str(col) for col in self.required_columns]
819
+ # Check analog outputs [data with units of %] are floats only
820
+ columns_to_check = [
821
+ self.economizer_sig_col,
822
+ self.cooling_sig_col,
823
+ ]
824
+ self._apply_analog_checks(df, columns_to_check)
1315
825
 
1316
- self.mapped_columns = (
1317
- f"Your config dictionary is mapped as: {', '.join(self.required_columns)}"
826
+ # Perform calculations
827
+ abs_mat_minus_oat = abs(df[self.mat_col] - df[self.oat_col])
828
+ mat_oat_sqrted = np.sqrt(
829
+ self.mix_degf_err_thres**2 + self.outdoor_degf_err_thres**2
1318
830
  )
1319
831
 
1320
- def get_required_columns(self) -> str:
1321
- """Returns a string representation of the required columns."""
1322
- return (
1323
- f"{self.equation_string}"
1324
- f"{self.description_string}"
1325
- f"{self.required_column_description}"
1326
- f"{self.mapped_columns}"
832
+ combined_check = (
833
+ (abs_mat_minus_oat > mat_oat_sqrted)
834
+ # Verify AHU is running in OS 3 cooling mode with minimum OA
835
+ & (df[self.cooling_sig_col] > 0.01)
836
+ & (df[self.economizer_sig_col] > 0.9)
1327
837
  )
1328
838
 
1329
- def apply(self, df: pd.DataFrame) -> pd.DataFrame:
1330
- try:
1331
- # Ensure all required columns are present
1332
- self.check_required_columns(df)
1333
-
1334
- # Check analog outputs [data with units of %] are floats only
1335
- columns_to_check = [
1336
- self.economizer_sig_col,
1337
- self.cooling_sig_col,
1338
- ]
1339
- self.check_analog_pct(df, columns_to_check)
1340
-
1341
- # Perform calculations
1342
- abs_mat_minus_oat = abs(df[self.mat_col] - df[self.oat_col])
1343
- mat_oat_sqrted = np.sqrt(
1344
- self.mix_degf_err_thres**2 + self.outdoor_degf_err_thres**2
1345
- )
1346
-
1347
- combined_check = (
1348
- (abs_mat_minus_oat > mat_oat_sqrted)
1349
- # Verify AHU is running in OS 3 cooling mode with minimum OA
1350
- & (df[self.cooling_sig_col] > 0.01)
1351
- & (df[self.economizer_sig_col] > 0.9)
1352
- )
1353
-
1354
- # Rolling sum to count consecutive trues
1355
- rolling_sum = combined_check.rolling(window=self.rolling_window_size).sum()
1356
-
1357
- # Set flag to 1 if rolling sum equals the window size
1358
- df["fc10_flag"] = (rolling_sum >= self.rolling_window_size).astype(int)
1359
-
1360
- return df
839
+ # Set fault flag
840
+ self._set_fault_flag(df, combined_check, "fc10_flag")
1361
841
 
1362
- except MissingColumnError as e:
1363
- print(f"Error: {e.message}")
1364
- sys.stdout.flush()
1365
- raise e
1366
- except InvalidParameterError as e:
1367
- print(f"Error: {e.message}")
1368
- sys.stdout.flush()
1369
- raise e
842
+ return df
1370
843
 
1371
844
 
1372
- class FaultConditionEleven(FaultCondition):
845
+ class FaultConditionEleven(BaseFaultCondition, FaultConditionMixin):
1373
846
  """Class provides the definitions for Fault Condition 11.
1374
- Outside air temperature too low for 100% outdoor
1375
- air cooling in economizer cooling mode.
1376
- Economizer performance fault
847
+ Outdoor air temperature and mix air temperature should
848
+ be approx equal in economizer mode.
1377
849
 
1378
850
  py -3.12 -m pytest open_fdd/tests/ahu/test_ahu_fc11.py -rP -s
1379
851
  """
1380
852
 
1381
- def __init__(self, dict_):
1382
- super().__init__()
1383
-
853
+ def _init_specific_attributes(self, dict_):
1384
854
  # Threshold parameters
1385
- self.delta_t_supply_fan = dict_.get("DELTA_T_SUPPLY_FAN", None)
1386
855
  self.outdoor_degf_err_thres = dict_.get("OUTDOOR_DEGF_ERR_THRES", None)
1387
- self.supply_degf_err_thres = dict_.get("SUPPLY_DEGF_ERR_THRES", None)
856
+ self.mix_degf_err_thres = dict_.get("MIX_DEGF_ERR_THRES", None)
1388
857
 
1389
858
  # Validate that threshold parameters are floats
1390
859
  for param, value in [
1391
- ("delta_t_supply_fan", self.delta_t_supply_fan),
1392
860
  ("outdoor_degf_err_thres", self.outdoor_degf_err_thres),
1393
- ("supply_degf_err_thres", self.supply_degf_err_thres),
861
+ ("mix_degf_err_thres", self.mix_degf_err_thres),
1394
862
  ]:
1395
863
  if not isinstance(value, float):
1396
864
  raise InvalidParameterError(
@@ -1398,123 +866,74 @@ class FaultConditionEleven(FaultCondition):
1398
866
  )
1399
867
 
1400
868
  # Other attributes
1401
- self.sat_setpoint_col = dict_.get("SAT_SETPOINT_COL", None)
1402
869
  self.oat_col = dict_.get("OAT_COL", None)
1403
- self.cooling_sig_col = dict_.get("COOLING_SIG_COL", None)
870
+ self.mat_col = dict_.get("MAT_COL", None)
1404
871
  self.economizer_sig_col = dict_.get("ECONOMIZER_SIG_COL", None)
1405
- self.troubleshoot_mode = dict_.get("TROUBLESHOOT_MODE", False)
1406
- self.rolling_window_size = dict_.get("ROLLING_WINDOW_SIZE", None)
1407
872
 
873
+ # Set documentation strings
1408
874
  self.equation_string = (
1409
- "fc11_flag = 1 if OAT < (SATSP - ΔT_fan - εSAT) in "
1410
- "economizer cooling mode for N consecutive values else 0 \n"
875
+ "fc11_flag = 1 if |OAT - MAT| > √(εOAT² + εMAT²) in "
876
+ "economizer mode for N consecutive values else 0 \n"
1411
877
  )
1412
878
  self.description_string = (
1413
- "Fault Condition 11: Outside air temperature too low for 100% outdoor air cooling "
1414
- "in economizer cooling mode (Economizer performance fault) \n"
879
+ "Fault Condition 11: Outdoor air temperature and mixed air temperature "
880
+ "should be approximately equal in economizer mode \n"
1415
881
  )
1416
882
  self.required_column_description = (
1417
- "Required inputs are the supply air temperature setpoint, outside air temperature, "
1418
- "cooling signal, and economizer signal \n"
1419
- )
1420
- self.error_string = "One or more required columns are missing or None \n"
1421
-
1422
- self.set_attributes(dict_)
1423
-
1424
- # Set required columns specific to this fault condition
1425
- self.required_columns = [
1426
- self.sat_setpoint_col,
1427
- self.oat_col,
1428
- self.cooling_sig_col,
1429
- self.economizer_sig_col,
1430
- ]
1431
-
1432
- # Check if any of the required columns are None
1433
- if any(col is None for col in self.required_columns):
1434
- raise MissingColumnError(
1435
- f"{self.error_string}"
1436
- f"{self.equation_string}"
1437
- f"{self.description_string}"
1438
- f"{self.required_column_description}"
1439
- f"{self.required_columns}"
1440
- )
1441
-
1442
- # Ensure all required columns are strings
1443
- self.required_columns = [str(col) for col in self.required_columns]
1444
-
1445
- self.mapped_columns = (
1446
- f"Your config dictionary is mapped as: {', '.join(self.required_columns)}"
1447
- )
1448
-
1449
- def get_required_columns(self) -> str:
1450
- """Returns a string representation of the required columns."""
1451
- return (
1452
- f"{self.equation_string}"
1453
- f"{self.description_string}"
1454
- f"{self.required_column_description}"
1455
- f"{self.mapped_columns}"
883
+ "Required inputs are the outside air temperature, mixed air temperature, "
884
+ "and economizer signal \n"
1456
885
  )
886
+ self.error_string = "One or more required columns are missing or None \n"
887
+
888
+ # Set required columns specific to this fault condition
889
+ self.required_columns = [
890
+ self.oat_col,
891
+ self.mat_col,
892
+ self.economizer_sig_col,
893
+ ]
1457
894
 
895
+ @FaultConditionMixin._handle_errors
1458
896
  def apply(self, df: pd.DataFrame) -> pd.DataFrame:
1459
- try:
1460
- # Ensure all required columns are present
1461
- self.check_required_columns(df)
1462
-
1463
- # Check analog outputs [data with units of %] are floats only
1464
- columns_to_check = [
1465
- self.economizer_sig_col,
1466
- self.cooling_sig_col,
1467
- ]
1468
- self.check_analog_pct(df, columns_to_check)
1469
-
1470
- # Perform calculations without creating DataFrame columns
1471
- oat_plus_oaterror = df[self.oat_col] + self.outdoor_degf_err_thres
1472
- satsp_delta_saterr = (
1473
- df[self.sat_setpoint_col]
1474
- - self.delta_t_supply_fan
1475
- - self.supply_degf_err_thres
1476
- )
897
+ """Apply the fault condition to the DataFrame."""
898
+ # Apply common checks
899
+ self._apply_common_checks(df)
1477
900
 
1478
- combined_check = (
1479
- (oat_plus_oaterror < satsp_delta_saterr)
1480
- # Verify AHU is running in OS 3 cooling mode with 100% OA
1481
- & (df[self.cooling_sig_col] > 0.01)
1482
- & (df[self.economizer_sig_col] > 0.9)
1483
- )
901
+ # Check analog outputs [data with units of %] are floats only
902
+ columns_to_check = [self.economizer_sig_col]
903
+ self._apply_analog_checks(df, columns_to_check)
1484
904
 
1485
- # Rolling sum to count consecutive trues
1486
- rolling_sum = combined_check.rolling(window=self.rolling_window_size).sum()
905
+ # Perform calculations
906
+ abs_mat_minus_oat = abs(df[self.mat_col] - df[self.oat_col])
907
+ mat_oat_sqrted = np.sqrt(
908
+ self.mix_degf_err_thres**2 + self.outdoor_degf_err_thres**2
909
+ )
1487
910
 
1488
- # Set flag to 1 if rolling sum equals the window size
1489
- df["fc11_flag"] = (rolling_sum >= self.rolling_window_size).astype(int)
911
+ combined_check = (
912
+ (abs_mat_minus_oat > mat_oat_sqrted)
913
+ # Verify AHU is running in economizer mode
914
+ & (df[self.economizer_sig_col] > 0.9)
915
+ )
1490
916
 
1491
- return df
917
+ # Set fault flag
918
+ self._set_fault_flag(df, combined_check, "fc11_flag")
1492
919
 
1493
- except MissingColumnError as e:
1494
- print(f"Error: {e.message}")
1495
- sys.stdout.flush()
1496
- raise e
1497
- except InvalidParameterError as e:
1498
- print(f"Error: {e.message}")
1499
- sys.stdout.flush()
1500
- raise e
920
+ return df
1501
921
 
1502
922
 
1503
- class FaultConditionTwelve(FaultCondition):
923
+ class FaultConditionTwelve(BaseFaultCondition, FaultConditionMixin):
1504
924
  """Class provides the definitions for Fault Condition 12.
1505
- Supply air temperature too high; should be less than
1506
- mix air temperature in economizer plus mech cooling mode.
925
+ Supply air temperature too high; should be less than mixed air temperature
926
+ in OS3 (economizer + mechanical cooling) and OS4 (mechanical cooling only) modes.
1507
927
 
1508
928
  py -3.12 -m pytest open_fdd/tests/ahu/test_ahu_fc12.py -rP -s
1509
929
  """
1510
930
 
1511
- def __init__(self, dict_):
1512
- super().__init__()
1513
-
931
+ def _init_specific_attributes(self, dict_):
1514
932
  # Threshold parameters
1515
933
  self.delta_t_supply_fan = dict_.get("DELTA_T_SUPPLY_FAN", None)
1516
934
  self.mix_degf_err_thres = dict_.get("MIX_DEGF_ERR_THRES", None)
1517
935
  self.supply_degf_err_thres = dict_.get("SUPPLY_DEGF_ERR_THRES", None)
936
+ self.outdoor_degf_err_thres = dict_.get("OUTDOOR_DEGF_ERR_THRES", None)
1518
937
  self.ahu_min_oa_dpr = dict_.get("AHU_MIN_OA_DPR", None)
1519
938
 
1520
939
  # Validate that threshold parameters are floats
@@ -1522,6 +941,7 @@ class FaultConditionTwelve(FaultCondition):
1522
941
  ("delta_t_supply_fan", self.delta_t_supply_fan),
1523
942
  ("mix_degf_err_thres", self.mix_degf_err_thres),
1524
943
  ("supply_degf_err_thres", self.supply_degf_err_thres),
944
+ ("outdoor_degf_err_thres", self.outdoor_degf_err_thres),
1525
945
  ("ahu_min_oa_dpr", self.ahu_min_oa_dpr),
1526
946
  ]:
1527
947
  if not isinstance(value, float):
@@ -1532,129 +952,99 @@ class FaultConditionTwelve(FaultCondition):
1532
952
  # Other attributes
1533
953
  self.sat_col = dict_.get("SAT_COL", None)
1534
954
  self.mat_col = dict_.get("MAT_COL", None)
955
+ self.oat_col = dict_.get("OAT_COL", None)
1535
956
  self.cooling_sig_col = dict_.get("COOLING_SIG_COL", None)
1536
957
  self.economizer_sig_col = dict_.get("ECONOMIZER_SIG_COL", None)
1537
- self.troubleshoot_mode = dict_.get("TROUBLESHOOT_MODE", False)
1538
- self.rolling_window_size = dict_.get("ROLLING_WINDOW_SIZE", None)
1539
958
 
959
+ # Set documentation strings
1540
960
  self.equation_string = (
1541
- "fc12_flag = 1 if SAT >= MAT + εMAT in "
1542
- "economizer + mech cooling mode for N consecutive values else 0 \n"
961
+ "fc12_flag = 1 if (SAT > MAT + εSAT) and "
962
+ "((CLG > 0 and ECO > 0.9) or (CLG > 0.9 and ECO = MIN_OA)) "
963
+ "for N consecutive values else 0 \n"
1543
964
  )
1544
965
  self.description_string = (
1545
966
  "Fault Condition 12: Supply air temperature too high; should be less than "
1546
- "mixed air temperature in economizer plus mechanical cooling mode \n"
967
+ "mixed air temperature in OS3 (economizer + mechanical cooling) and "
968
+ "OS4 (mechanical cooling only) modes \n"
1547
969
  )
1548
970
  self.required_column_description = (
1549
971
  "Required inputs are the supply air temperature, mixed air temperature, "
1550
- "cooling signal, and economizer signal \n"
972
+ "outside air temperature, cooling signal, and economizer signal \n"
1551
973
  )
1552
974
  self.error_string = "One or more required columns are missing or None \n"
1553
975
 
1554
- self.set_attributes(dict_)
1555
-
1556
976
  # Set required columns specific to this fault condition
1557
977
  self.required_columns = [
1558
978
  self.sat_col,
1559
979
  self.mat_col,
980
+ self.oat_col,
1560
981
  self.cooling_sig_col,
1561
982
  self.economizer_sig_col,
1562
983
  ]
1563
984
 
1564
- # Check if any of the required columns are None
1565
- if any(col is None for col in self.required_columns):
1566
- raise MissingColumnError(
1567
- f"{self.error_string}"
1568
- f"{self.equation_string}"
1569
- f"{self.description_string}"
1570
- f"{self.required_column_description}"
1571
- f"{self.required_columns}"
1572
- )
985
+ @FaultConditionMixin._handle_errors
986
+ def apply(self, df: pd.DataFrame) -> pd.DataFrame:
987
+ """Apply the fault condition to the DataFrame."""
988
+ # Apply common checks
989
+ self._apply_common_checks(df)
1573
990
 
1574
- # Ensure all required columns are strings
1575
- self.required_columns = [str(col) for col in self.required_columns]
991
+ # Check analog outputs [data with units of %] are floats only
992
+ columns_to_check = [
993
+ self.economizer_sig_col,
994
+ self.cooling_sig_col,
995
+ ]
996
+ self._apply_analog_checks(df, columns_to_check)
1576
997
 
1577
- self.mapped_columns = (
1578
- f"Your config dictionary is mapped as: {', '.join(self.required_columns)}"
998
+ # Calculate the threshold for SAT vs MAT comparison
999
+ sat_mat_threshold = np.sqrt(
1000
+ self.supply_degf_err_thres**2 + self.mix_degf_err_thres**2
1579
1001
  )
1580
1002
 
1581
- def get_required_columns(self) -> str:
1582
- """Returns a string representation of the required columns."""
1583
- return (
1584
- f"{self.equation_string}"
1585
- f"{self.description_string}"
1586
- f"{self.required_column_description}"
1587
- f"{self.mapped_columns}"
1003
+ # Check if SAT is too high compared to MAT (accounting for supply fan heat)
1004
+ sat_too_high = df[self.sat_col] > (
1005
+ df[self.mat_col] + sat_mat_threshold + self.delta_t_supply_fan
1588
1006
  )
1589
1007
 
1590
- def apply(self, df: pd.DataFrame) -> pd.DataFrame:
1591
- try:
1592
- # Ensure all required columns are present
1593
- self.check_required_columns(df)
1594
-
1595
- # Check analog outputs [data with units of %] are floats only
1596
- columns_to_check = [
1597
- self.economizer_sig_col,
1598
- self.cooling_sig_col,
1599
- ]
1600
- self.check_analog_pct(df, columns_to_check)
1601
-
1602
- # Perform calculations without creating DataFrame columns
1603
- sat_minus_saterr_delta_supply_fan = (
1604
- df[self.sat_col] - self.supply_degf_err_thres - self.delta_t_supply_fan
1605
- )
1606
- mat_plus_materr = df[self.mat_col] + self.mix_degf_err_thres
1607
-
1608
- # Combined check without adding to DataFrame columns
1609
- combined_check = operator.or_(
1610
- # OS4 AHU state cooling @ min OA
1611
- (sat_minus_saterr_delta_supply_fan > mat_plus_materr)
1612
- # Verify AHU in OS4 mode
1613
- & (df[self.cooling_sig_col] > 0.01)
1614
- & (df[self.economizer_sig_col] == self.ahu_min_oa_dpr),
1615
- # OR
1616
- (sat_minus_saterr_delta_supply_fan > mat_plus_materr)
1617
- # Verify AHU is running in OS3 cooling mode in 100% OA
1618
- & (df[self.cooling_sig_col] > 0.01)
1619
- & (df[self.economizer_sig_col] > 0.9),
1620
- )
1008
+ # Check operating modes:
1009
+ # OS3: Economizer + mechanical cooling (ECO > 0.9 and CLG > 0)
1010
+ os3_mode = (df[self.economizer_sig_col] > 0.9) & (df[self.cooling_sig_col] > 0)
1621
1011
 
1622
- # Rolling sum to count consecutive trues
1623
- rolling_sum = combined_check.rolling(window=self.rolling_window_size).sum()
1012
+ # OS4: Mechanical cooling only (ECO = MIN_OA and CLG > 0.9)
1013
+ os4_mode = (df[self.economizer_sig_col] <= self.ahu_min_oa_dpr) & (
1014
+ df[self.cooling_sig_col] > 0.9
1015
+ )
1624
1016
 
1625
- # Set flag to 1 if rolling sum equals the window size
1626
- df["fc12_flag"] = (rolling_sum >= self.rolling_window_size).astype(int)
1017
+ # Combine conditions:
1018
+ # Fault occurs when SAT is too high in either OS3 or OS4 mode
1019
+ combined_check = sat_too_high & (os3_mode | os4_mode)
1627
1020
 
1628
- return df
1021
+ # Set fault flag
1022
+ self._set_fault_flag(df, combined_check, "fc12_flag")
1629
1023
 
1630
- except MissingColumnError as e:
1631
- print(f"Error: {e.message}")
1632
- sys.stdout.flush()
1633
- raise e
1634
- except InvalidParameterError as e:
1635
- print(f"Error: {e.message}")
1636
- sys.stdout.flush()
1637
- raise e
1024
+ return df
1638
1025
 
1639
1026
 
1640
- class FaultConditionThirteen(FaultCondition):
1027
+ class FaultConditionThirteen(BaseFaultCondition, FaultConditionMixin):
1641
1028
  """Class provides the definitions for Fault Condition 13.
1642
- Supply air temperature too high in full cooling
1643
- in economizer plus mech cooling mode
1029
+ Supply air temperature too high in full cooling mode.
1030
+ This fault checks if SAT is too high compared to SAT setpoint
1031
+ in OS3 (economizer + mechanical cooling) and OS4 (mechanical cooling only) modes.
1644
1032
 
1645
1033
  py -3.12 -m pytest open_fdd/tests/ahu/test_ahu_fc13.py -rP -s
1646
1034
  """
1647
1035
 
1648
- def __init__(self, dict_):
1649
- super().__init__()
1650
-
1036
+ def _init_specific_attributes(self, dict_):
1651
1037
  # Threshold parameters
1652
1038
  self.supply_degf_err_thres = dict_.get("SUPPLY_DEGF_ERR_THRES", None)
1039
+ self.mix_degf_err_thres = dict_.get("MIX_DEGF_ERR_THRES", None)
1040
+ self.outdoor_degf_err_thres = dict_.get("OUTDOOR_DEGF_ERR_THRES", None)
1653
1041
  self.ahu_min_oa_dpr = dict_.get("AHU_MIN_OA_DPR", None)
1654
1042
 
1655
1043
  # Validate that threshold parameters are floats
1656
1044
  for param, value in [
1657
1045
  ("supply_degf_err_thres", self.supply_degf_err_thres),
1046
+ ("mix_degf_err_thres", self.mix_degf_err_thres),
1047
+ ("outdoor_degf_err_thres", self.outdoor_degf_err_thres),
1658
1048
  ("ahu_min_oa_dpr", self.ahu_min_oa_dpr),
1659
1049
  ]:
1660
1050
  if not isinstance(value, float):
@@ -1664,19 +1054,19 @@ class FaultConditionThirteen(FaultCondition):
1664
1054
 
1665
1055
  # Other attributes
1666
1056
  self.sat_col = dict_.get("SAT_COL", None)
1667
- self.sat_setpoint_col = dict_.get("SAT_SETPOINT_COL", None)
1057
+ self.sat_sp_col = dict_.get("SAT_SP_COL", None)
1668
1058
  self.cooling_sig_col = dict_.get("COOLING_SIG_COL", None)
1669
1059
  self.economizer_sig_col = dict_.get("ECONOMIZER_SIG_COL", None)
1670
- self.troubleshoot_mode = dict_.get("TROUBLESHOOT_MODE", False)
1671
- self.rolling_window_size = dict_.get("ROLLING_WINDOW_SIZE", None)
1672
1060
 
1061
+ # Set documentation strings
1673
1062
  self.equation_string = (
1674
- "fc13_flag = 1 if SAT > (SATSP + εSAT) in "
1675
- "economizer + mech cooling mode for N consecutive values else 0 \n"
1063
+ "fc13_flag = 1 if (SAT > SATSP + εSAT) and "
1064
+ "((CLG > 0.9 and ECO > 0.9) or (CLG > 0.9 and ECO = MIN_OA)) "
1065
+ "for N consecutive values else 0 \n"
1676
1066
  )
1677
1067
  self.description_string = (
1678
- "Fault Condition 13: Supply air temperature too high in full cooling "
1679
- "in economizer plus mechanical cooling mode \n"
1068
+ "Fault Condition 13: Supply air temperature too high in full cooling mode "
1069
+ "in OS3 (economizer + mechanical cooling) and OS4 (mechanical cooling only) modes \n"
1680
1070
  )
1681
1071
  self.required_column_description = (
1682
1072
  "Required inputs are the supply air temperature, supply air temperature setpoint, "
@@ -1684,111 +1074,75 @@ class FaultConditionThirteen(FaultCondition):
1684
1074
  )
1685
1075
  self.error_string = "One or more required columns are missing or None \n"
1686
1076
 
1687
- self.set_attributes(dict_)
1688
-
1689
1077
  # Set required columns specific to this fault condition
1690
1078
  self.required_columns = [
1691
1079
  self.sat_col,
1692
- self.sat_setpoint_col,
1080
+ self.sat_sp_col,
1693
1081
  self.cooling_sig_col,
1694
1082
  self.economizer_sig_col,
1695
1083
  ]
1696
1084
 
1697
- # Check if any of the required columns are None
1698
- if any(col is None for col in self.required_columns):
1699
- raise MissingColumnError(
1700
- f"{self.error_string}"
1701
- f"{self.equation_string}"
1702
- f"{self.description_string}"
1703
- f"{self.required_column_description}"
1704
- f"{self.required_columns}"
1705
- )
1085
+ @FaultConditionMixin._handle_errors
1086
+ def apply(self, df: pd.DataFrame) -> pd.DataFrame:
1087
+ """Apply the fault condition to the DataFrame."""
1088
+ # Apply common checks
1089
+ self._apply_common_checks(df)
1706
1090
 
1707
- # Ensure all required columns are strings
1708
- self.required_columns = [str(col) for col in self.required_columns]
1091
+ # Check analog outputs [data with units of %] are floats only
1092
+ columns_to_check = [
1093
+ self.economizer_sig_col,
1094
+ self.cooling_sig_col,
1095
+ ]
1096
+ self._apply_analog_checks(df, columns_to_check)
1709
1097
 
1710
- self.mapped_columns = (
1711
- f"Your config dictionary is mapped as: {', '.join(self.required_columns)}"
1098
+ # Check if SAT is too high compared to setpoint
1099
+ sat_too_high = df[self.sat_col] > (
1100
+ df[self.sat_sp_col] + self.supply_degf_err_thres
1712
1101
  )
1713
1102
 
1714
- def get_required_columns(self) -> str:
1715
- """Returns a string representation of the required columns."""
1716
- return (
1717
- f"{self.equation_string}"
1718
- f"{self.description_string}"
1719
- f"{self.required_column_description}"
1720
- f"{self.mapped_columns}"
1103
+ # Check operating modes:
1104
+ # OS3: Economizer + full mechanical cooling (ECO > 0.9 and CLG > 0.9)
1105
+ os3_mode = (df[self.economizer_sig_col] > 0.9) & (
1106
+ df[self.cooling_sig_col] > 0.9
1721
1107
  )
1722
1108
 
1723
- def apply(self, df: pd.DataFrame) -> pd.DataFrame:
1724
- try:
1725
- # Ensure all required columns are present
1726
- self.check_required_columns(df)
1727
-
1728
- # Check analog outputs [data with units of %] are floats only
1729
- columns_to_check = [
1730
- self.economizer_sig_col,
1731
- self.cooling_sig_col,
1732
- ]
1733
- self.check_analog_pct(df, columns_to_check)
1734
-
1735
- # Perform calculation without creating DataFrame columns
1736
- sat_greater_than_sp_calc = (
1737
- df[self.sat_col]
1738
- > df[self.sat_setpoint_col] + self.supply_degf_err_thres
1739
- )
1740
-
1741
- # Combined check without adding to DataFrame columns
1742
- combined_check = operator.or_(
1743
- # OS4 AHU state cooling @ min OA
1744
- (sat_greater_than_sp_calc)
1745
- & (df[self.cooling_sig_col] > 0.01)
1746
- & (df[self.economizer_sig_col] == self.ahu_min_oa_dpr),
1747
- # OR verify AHU is running in OS 3 cooling mode in 100% OA
1748
- (sat_greater_than_sp_calc)
1749
- & (df[self.cooling_sig_col] > 0.01)
1750
- & (df[self.economizer_sig_col] > 0.9),
1751
- )
1752
-
1753
- # Rolling sum to count consecutive trues
1754
- rolling_sum = combined_check.rolling(window=self.rolling_window_size).sum()
1109
+ # OS4: Full mechanical cooling only (ECO = MIN_OA and CLG > 0.9)
1110
+ os4_mode = (df[self.economizer_sig_col] <= self.ahu_min_oa_dpr) & (
1111
+ df[self.cooling_sig_col] > 0.9
1112
+ )
1755
1113
 
1756
- # Set flag to 1 if rolling sum equals the window size
1757
- df["fc13_flag"] = (rolling_sum >= self.rolling_window_size).astype(int)
1114
+ # Combine conditions:
1115
+ # Fault occurs when SAT is too high in either OS3 or OS4 mode with full cooling
1116
+ combined_check = sat_too_high & (os3_mode | os4_mode)
1758
1117
 
1759
- return df
1118
+ # Set fault flag
1119
+ self._set_fault_flag(df, combined_check, "fc13_flag")
1760
1120
 
1761
- except MissingColumnError as e:
1762
- print(f"Error: {e.message}")
1763
- sys.stdout.flush()
1764
- raise e
1765
- except InvalidParameterError as e:
1766
- print(f"Error: {e.message}")
1767
- sys.stdout.flush()
1768
- raise e
1121
+ return df
1769
1122
 
1770
1123
 
1771
- class FaultConditionFourteen(FaultCondition):
1124
+ class FaultConditionFourteen(BaseFaultCondition, FaultConditionMixin):
1772
1125
  """Class provides the definitions for Fault Condition 14.
1773
- Temperature drop across inactive cooling coil.
1774
- Requires coil leaving temp sensor.
1126
+ Temperature drop across inactive cooling coil in OS1 (heating) and OS2 (economizer) modes.
1127
+ This fault checks if there is an unexpected temperature drop across the cooling coil
1128
+ when it should be inactive.
1775
1129
 
1776
1130
  py -3.12 -m pytest open_fdd/tests/ahu/test_ahu_fc14.py -rP -s
1777
1131
  """
1778
1132
 
1779
- def __init__(self, dict_):
1780
- super().__init__()
1781
-
1133
+ def _init_specific_attributes(self, dict_):
1782
1134
  # Threshold parameters
1783
1135
  self.delta_t_supply_fan = dict_.get("DELTA_T_SUPPLY_FAN", None)
1784
1136
  self.coil_temp_enter_err_thres = dict_.get("COIL_TEMP_ENTER_ERR_THRES", None)
1785
- self.coil_temp_leav_err_thres = dict_.get("COIL_TEMP_LEAV_ERR_THRES", None)
1137
+ self.coil_temp_leave_err_thres = dict_.get("COIL_TEMP_LEAV_ERR_THRES", None)
1138
+ self.ahu_min_oa_dpr = dict_.get("AHU_MIN_OA_DPR", None)
1786
1139
 
1787
1140
  # Validate that threshold parameters are floats
1788
1141
  for param, value in [
1789
1142
  ("delta_t_supply_fan", self.delta_t_supply_fan),
1790
1143
  ("coil_temp_enter_err_thres", self.coil_temp_enter_err_thres),
1791
- ("coil_temp_leav_err_thres", self.coil_temp_leav_err_thres),
1144
+ ("coil_temp_leave_err_thres", self.coil_temp_leave_err_thres),
1145
+ ("ahu_min_oa_dpr", self.ahu_min_oa_dpr),
1792
1146
  ]:
1793
1147
  if not isinstance(value, float):
1794
1148
  raise InvalidParameterError(
@@ -1798,30 +1152,26 @@ class FaultConditionFourteen(FaultCondition):
1798
1152
  # Other attributes
1799
1153
  self.clg_coil_enter_temp_col = dict_.get("CLG_COIL_ENTER_TEMP_COL", None)
1800
1154
  self.clg_coil_leave_temp_col = dict_.get("CLG_COIL_LEAVE_TEMP_COL", None)
1801
- self.ahu_min_oa_dpr = dict_.get("AHU_MIN_OA_DPR", None)
1802
1155
  self.cooling_sig_col = dict_.get("COOLING_SIG_COL", None)
1803
1156
  self.heating_sig_col = dict_.get("HEATING_SIG_COL", None)
1804
1157
  self.economizer_sig_col = dict_.get("ECONOMIZER_SIG_COL", None)
1805
1158
  self.supply_vfd_speed_col = dict_.get("SUPPLY_VFD_SPEED_COL", None)
1806
- self.troubleshoot_mode = dict_.get("TROUBLESHOOT_MODE", False)
1807
- self.rolling_window_size = dict_.get("ROLLING_WINDOW_SIZE", None)
1808
1159
 
1160
+ # Set documentation strings
1809
1161
  self.equation_string = (
1810
- "fc14_flag = 1 if ΔT_coil >= √(εcoil_enter² + εcoil_leave²) + ΔT_fan "
1811
- "in inactive cooling coil mode for N consecutive values else 0 \n"
1162
+ "fc14_flag = 1 if (CLG_LEAVE < CLG_ENTER - √(εENTER² + εLEAVE²)) "
1163
+ "in OS1 (heating) or OS2 (economizer) modes for N consecutive values else 0 \n"
1812
1164
  )
1813
1165
  self.description_string = (
1814
1166
  "Fault Condition 14: Temperature drop across inactive cooling coil "
1815
- "detected, requiring coil leaving temperature sensor \n"
1167
+ "in OS1 (heating) and OS2 (economizer) modes \n"
1816
1168
  )
1817
1169
  self.required_column_description = (
1818
- "Required inputs are the cooling coil entering temperature, cooling coil leaving temperature, "
1170
+ "Required inputs are the cooling coil entering and leaving air temperatures, "
1819
1171
  "cooling signal, heating signal, economizer signal, and supply fan VFD speed \n"
1820
1172
  )
1821
1173
  self.error_string = "One or more required columns are missing or None \n"
1822
1174
 
1823
- self.set_attributes(dict_)
1824
-
1825
1175
  # Set required columns specific to this fault condition
1826
1176
  self.required_columns = [
1827
1177
  self.clg_coil_enter_temp_col,
@@ -1832,106 +1182,79 @@ class FaultConditionFourteen(FaultCondition):
1832
1182
  self.supply_vfd_speed_col,
1833
1183
  ]
1834
1184
 
1835
- # Check if any of the required columns are None
1836
- if any(col is None for col in self.required_columns):
1837
- raise MissingColumnError(
1838
- f"{self.error_string}"
1839
- f"{self.equation_string}"
1840
- f"{self.description_string}"
1841
- f"{self.required_column_description}"
1842
- f"{self.required_columns}"
1843
- )
1844
-
1845
- # Ensure all required columns are strings
1846
- self.required_columns = [str(col) for col in self.required_columns]
1185
+ @FaultConditionMixin._handle_errors
1186
+ def apply(self, df: pd.DataFrame) -> pd.DataFrame:
1187
+ """Apply the fault condition to the DataFrame."""
1188
+ # Apply common checks
1189
+ self._apply_common_checks(df)
1847
1190
 
1848
- self.mapped_columns = (
1849
- f"Your config dictionary is mapped as: {', '.join(self.required_columns)}"
1850
- )
1191
+ # Check analog outputs [data with units of %] are floats only
1192
+ columns_to_check = [
1193
+ self.cooling_sig_col,
1194
+ self.heating_sig_col,
1195
+ self.economizer_sig_col,
1196
+ self.supply_vfd_speed_col,
1197
+ ]
1198
+ self._apply_analog_checks(df, columns_to_check)
1851
1199
 
1852
- def get_required_columns(self) -> str:
1853
- """Returns a string representation of the required columns."""
1854
- return (
1855
- f"{self.equation_string}"
1856
- f"{self.description_string}"
1857
- f"{self.required_column_description}"
1858
- f"{self.mapped_columns}"
1200
+ # Calculate the threshold for temperature drop
1201
+ temp_drop_threshold = np.sqrt(
1202
+ self.coil_temp_enter_err_thres**2 + self.coil_temp_leave_err_thres**2
1859
1203
  )
1860
1204
 
1861
- def apply(self, df: pd.DataFrame) -> pd.DataFrame:
1862
- try:
1863
- # Ensure all required columns are present
1864
- self.check_required_columns(df)
1865
-
1866
- # Check analog outputs [data with units of %] are floats only
1867
- columns_to_check = [
1868
- self.economizer_sig_col,
1869
- self.cooling_sig_col,
1870
- self.heating_sig_col,
1871
- self.supply_vfd_speed_col,
1872
- ]
1873
- self.check_analog_pct(df, columns_to_check)
1874
-
1875
- # Calculate necessary checks
1876
- clg_delta_temp = (
1877
- df[self.clg_coil_enter_temp_col] - df[self.clg_coil_leave_temp_col]
1878
- )
1879
- clg_delta_sqrted = (
1880
- np.sqrt(
1881
- self.coil_temp_enter_err_thres**2 + self.coil_temp_leav_err_thres**2
1882
- )
1883
- + self.delta_t_supply_fan
1884
- )
1205
+ # Check if there's a significant temperature drop across the cooling coil
1206
+ temp_drop = df[self.clg_coil_enter_temp_col] - df[self.clg_coil_leave_temp_col]
1207
+ significant_temp_drop = temp_drop > temp_drop_threshold
1885
1208
 
1886
- # Perform combined checks without adding intermediate columns to DataFrame
1887
- combined_check = operator.or_(
1888
- (clg_delta_temp >= clg_delta_sqrted)
1889
- & (df[self.economizer_sig_col] > self.ahu_min_oa_dpr)
1890
- & (df[self.cooling_sig_col] < 0.1), # OR
1891
- (clg_delta_temp >= clg_delta_sqrted)
1892
- & (df[self.heating_sig_col] > 0.0)
1893
- & (df[self.supply_vfd_speed_col] > 0.0),
1894
- )
1209
+ # Check operating modes:
1210
+ # OS1: Heating mode (HTG > 0, CLG = 0, ECO = MIN_OA)
1211
+ os1_mode = (
1212
+ (df[self.heating_sig_col] > 0.0)
1213
+ & (df[self.cooling_sig_col] == 0.0)
1214
+ & (df[self.economizer_sig_col] <= self.ahu_min_oa_dpr)
1215
+ )
1895
1216
 
1896
- # Rolling sum to count consecutive trues
1897
- rolling_sum = combined_check.rolling(window=self.rolling_window_size).sum()
1217
+ # OS2: Economizer mode (HTG = 0, CLG = 0, ECO > MIN_OA)
1218
+ os2_mode = (
1219
+ (df[self.heating_sig_col] == 0.0)
1220
+ & (df[self.cooling_sig_col] == 0.0)
1221
+ & (df[self.economizer_sig_col] > self.ahu_min_oa_dpr)
1222
+ )
1898
1223
 
1899
- # Set flag to 1 if rolling sum equals the window size
1900
- df["fc14_flag"] = (rolling_sum >= self.rolling_window_size).astype(int)
1224
+ # Combine conditions:
1225
+ # Fault occurs when there's a significant temperature drop across an inactive cooling coil
1226
+ # in either OS1 (heating) or OS2 (economizer) mode
1227
+ combined_check = significant_temp_drop & (os1_mode | os2_mode)
1901
1228
 
1902
- return df
1229
+ # Set fault flag
1230
+ self._set_fault_flag(df, combined_check, "fc14_flag")
1903
1231
 
1904
- except MissingColumnError as e:
1905
- print(f"Error: {e.message}")
1906
- sys.stdout.flush()
1907
- raise e
1908
- except InvalidParameterError as e:
1909
- print(f"Error: {e.message}")
1910
- sys.stdout.flush()
1911
- raise e
1232
+ return df
1912
1233
 
1913
1234
 
1914
- class FaultConditionFifteen(FaultCondition):
1235
+ class FaultConditionFifteen(BaseFaultCondition, FaultConditionMixin):
1915
1236
  """Class provides the definitions for Fault Condition 15.
1916
- Temperature rise across inactive heating coil.
1917
- Requires coil leaving temp sensor.
1237
+ Temperature rise across inactive heating coil in OS2 (economizer),
1238
+ OS3 (economizer + mechanical cooling), and OS4 (mechanical cooling only) modes.
1239
+ This fault checks if there is an unexpected temperature rise across the heating coil
1240
+ when it should be inactive.
1918
1241
 
1919
- > py -3.12 -m pytest open_fdd/tests/ahu/test_ahu_fc15.py -rP -s
1242
+ py -3.12 -m pytest open_fdd/tests/ahu/test_ahu_fc15.py -rP -s
1920
1243
  """
1921
1244
 
1922
- def __init__(self, dict_):
1923
- super().__init__()
1924
-
1245
+ def _init_specific_attributes(self, dict_):
1925
1246
  # Threshold parameters
1926
- self.delta_supply_fan = dict_.get("DELTA_SUPPLY_FAN", None)
1247
+ self.delta_t_supply_fan = dict_.get("DELTA_SUPPLY_FAN", None)
1927
1248
  self.coil_temp_enter_err_thres = dict_.get("COIL_TEMP_ENTER_ERR_THRES", None)
1928
- self.coil_temp_leav_err_thres = dict_.get("COIL_TEMP_LEAV_ERR_THRES", None)
1249
+ self.coil_temp_leave_err_thres = dict_.get("COIL_TEMP_LEAV_ERR_THRES", None)
1250
+ self.ahu_min_oa_dpr = dict_.get("AHU_MIN_OA_DPR", None)
1929
1251
 
1930
1252
  # Validate that threshold parameters are floats
1931
1253
  for param, value in [
1932
- ("delta_supply_fan", self.delta_supply_fan),
1254
+ ("delta_t_supply_fan", self.delta_t_supply_fan),
1933
1255
  ("coil_temp_enter_err_thres", self.coil_temp_enter_err_thres),
1934
- ("coil_temp_leav_err_thres", self.coil_temp_leav_err_thres),
1256
+ ("coil_temp_leave_err_thres", self.coil_temp_leave_err_thres),
1257
+ ("ahu_min_oa_dpr", self.ahu_min_oa_dpr),
1935
1258
  ]:
1936
1259
  if not isinstance(value, float):
1937
1260
  raise InvalidParameterError(
@@ -1941,30 +1264,28 @@ class FaultConditionFifteen(FaultCondition):
1941
1264
  # Other attributes
1942
1265
  self.htg_coil_enter_temp_col = dict_.get("HTG_COIL_ENTER_TEMP_COL", None)
1943
1266
  self.htg_coil_leave_temp_col = dict_.get("HTG_COIL_LEAVE_TEMP_COL", None)
1944
- self.ahu_min_oa_dpr = dict_.get("AHU_MIN_OA_DPR", None)
1945
1267
  self.cooling_sig_col = dict_.get("COOLING_SIG_COL", None)
1946
1268
  self.heating_sig_col = dict_.get("HEATING_SIG_COL", None)
1947
1269
  self.economizer_sig_col = dict_.get("ECONOMIZER_SIG_COL", None)
1948
1270
  self.supply_vfd_speed_col = dict_.get("SUPPLY_VFD_SPEED_COL", None)
1949
- self.troubleshoot_mode = dict_.get("TROUBLESHOOT_MODE", False)
1950
- self.rolling_window_size = dict_.get("ROLLING_WINDOW_SIZE", None)
1951
1271
 
1272
+ # Set documentation strings
1952
1273
  self.equation_string = (
1953
- "fc15_flag = 1 if ΔT_coil >= √(εcoil_enter² + εcoil_leave²) + ΔT_fan "
1954
- "in inactive heating coil mode for N consecutive values else 0 \n"
1274
+ "fc15_flag = 1 if (HTG_LEAVE > HTG_ENTER + √(εENTER² + εLEAVE²) + ΔTfan) "
1275
+ "in OS2 (economizer), OS3 (economizer + mechanical cooling), or "
1276
+ "OS4 (mechanical cooling only) modes for N consecutive values else 0 \n"
1955
1277
  )
1956
1278
  self.description_string = (
1957
1279
  "Fault Condition 15: Temperature rise across inactive heating coil "
1958
- "detected, requiring coil leaving temperature sensor \n"
1280
+ "in OS2 (economizer), OS3 (economizer + mechanical cooling), and "
1281
+ "OS4 (mechanical cooling only) modes \n"
1959
1282
  )
1960
1283
  self.required_column_description = (
1961
- "Required inputs are the heating coil entering temperature, heating coil leaving temperature, "
1284
+ "Required inputs are the heating coil entering and leaving air temperatures, "
1962
1285
  "cooling signal, heating signal, economizer signal, and supply fan VFD speed \n"
1963
1286
  )
1964
1287
  self.error_string = "One or more required columns are missing or None \n"
1965
1288
 
1966
- self.set_attributes(dict_)
1967
-
1968
1289
  # Set required columns specific to this fault condition
1969
1290
  self.required_columns = [
1970
1291
  self.htg_coil_enter_temp_col,
@@ -1975,130 +1296,86 @@ class FaultConditionFifteen(FaultCondition):
1975
1296
  self.supply_vfd_speed_col,
1976
1297
  ]
1977
1298
 
1978
- # Check if any of the required columns are None
1979
- if any(col is None for col in self.required_columns):
1980
- raise MissingColumnError(
1981
- f"{self.error_string}"
1982
- f"{self.equation_string}"
1983
- f"{self.description_string}"
1984
- f"{self.required_column_description}"
1985
- f"{self.required_columns}"
1986
- )
1987
-
1988
- # Ensure all required columns are strings
1989
- self.required_columns = [str(col) for col in self.required_columns]
1990
-
1991
- self.mapped_columns = (
1992
- f"Your config dictionary is mapped as: {', '.join(self.required_columns)}"
1993
- )
1299
+ @FaultConditionMixin._handle_errors
1300
+ def apply(self, df: pd.DataFrame) -> pd.DataFrame:
1301
+ """Apply the fault condition to the DataFrame."""
1302
+ # Apply common checks
1303
+ self._apply_common_checks(df)
1994
1304
 
1995
- def get_required_columns(self) -> str:
1996
- """Returns a string representation of the required columns."""
1997
- return (
1998
- f"{self.equation_string}"
1999
- f"{self.description_string}"
2000
- f"{self.required_column_description}"
2001
- f"{self.mapped_columns}"
2002
- )
1305
+ # Check analog outputs [data with units of %] are floats only
1306
+ columns_to_check = [
1307
+ self.cooling_sig_col,
1308
+ self.heating_sig_col,
1309
+ self.economizer_sig_col,
1310
+ self.supply_vfd_speed_col,
1311
+ ]
1312
+ self._apply_analog_checks(df, columns_to_check)
2003
1313
 
2004
- def apply(self, df: pd.DataFrame) -> pd.DataFrame:
2005
- try:
2006
- # Ensure all required columns are present
2007
- self.check_required_columns(df)
2008
-
2009
- if self.troubleshoot_mode:
2010
- self.troubleshoot_cols(df)
2011
-
2012
- # Check analog outputs [data with units of %] are floats only
2013
- columns_to_check = [
2014
- self.economizer_sig_col,
2015
- self.cooling_sig_col,
2016
- self.heating_sig_col,
2017
- self.supply_vfd_speed_col,
2018
- ]
2019
- self.check_analog_pct(df, columns_to_check)
2020
-
2021
- # Create helper columns
2022
- df["htg_delta_temp"] = (
2023
- df[self.htg_coil_leave_temp_col] - df[self.htg_coil_enter_temp_col]
1314
+ # Calculate the threshold for temperature rise, including supply fan heat
1315
+ temp_rise_threshold = (
1316
+ np.sqrt(
1317
+ self.coil_temp_enter_err_thres**2 + self.coil_temp_leave_err_thres**2
2024
1318
  )
1319
+ + self.delta_t_supply_fan
1320
+ )
2025
1321
 
2026
- df["htg_delta_sqrted"] = (
2027
- np.sqrt(
2028
- self.coil_temp_enter_err_thres**2 + self.coil_temp_leav_err_thres**2
2029
- )
2030
- + self.delta_supply_fan
2031
- )
1322
+ # Check if there's a significant temperature rise across the heating coil
1323
+ temp_rise = df[self.htg_coil_leave_temp_col] - df[self.htg_coil_enter_temp_col]
1324
+ significant_temp_rise = temp_rise > temp_rise_threshold
2032
1325
 
2033
- df["combined_check"] = (
2034
- (
2035
- (df["htg_delta_temp"] >= df["htg_delta_sqrted"])
2036
- # verify AHU is in OS2 only free cooling mode
2037
- & (df[self.economizer_sig_col] > self.ahu_min_oa_dpr)
2038
- & (df[self.cooling_sig_col] < 0.1)
2039
- )
2040
- | (
2041
- (df["htg_delta_temp"] >= df["htg_delta_sqrted"])
2042
- # OS4 AHU state clg @ min OA
2043
- & (df[self.cooling_sig_col] > 0.01)
2044
- & (df[self.economizer_sig_col] == self.ahu_min_oa_dpr)
2045
- )
2046
- | (
2047
- (df["htg_delta_temp"] >= df["htg_delta_sqrted"])
2048
- # verify AHU is running in OS 3 clg mode in 100 OA
2049
- & (df[self.cooling_sig_col] > 0.01)
2050
- & (df[self.economizer_sig_col] > 0.9)
2051
- )
2052
- )
1326
+ # Check operating modes:
1327
+ # OS2: Economizer mode (HTG = 0, CLG = 0, ECO > MIN_OA)
1328
+ os2_mode = (
1329
+ (df[self.heating_sig_col] == 0.0)
1330
+ & (df[self.cooling_sig_col] == 0.0)
1331
+ & (df[self.economizer_sig_col] > self.ahu_min_oa_dpr)
1332
+ )
2053
1333
 
2054
- # Rolling sum to count consecutive trues
2055
- rolling_sum = (
2056
- df["combined_check"].rolling(window=self.rolling_window_size).sum()
2057
- )
2058
- # Set flag to 1 if rolling sum equals the window size
2059
- df["fc15_flag"] = (rolling_sum >= self.rolling_window_size).astype(int)
1334
+ # OS3: Economizer + mechanical cooling (HTG = 0, CLG > 0, ECO > 0.9)
1335
+ os3_mode = (
1336
+ (df[self.heating_sig_col] == 0.0)
1337
+ & (df[self.cooling_sig_col] > 0.0)
1338
+ & (df[self.economizer_sig_col] > 0.9)
1339
+ )
2060
1340
 
2061
- if self.troubleshoot_mode:
2062
- print("Troubleshoot mode enabled - not removing helper columns")
2063
- sys.stdout.flush()
1341
+ # OS4: Mechanical cooling only (HTG = 0, CLG > 0, ECO = MIN_OA)
1342
+ os4_mode = (
1343
+ (df[self.heating_sig_col] == 0.0)
1344
+ & (df[self.cooling_sig_col] > 0.0)
1345
+ & (df[self.economizer_sig_col] <= self.ahu_min_oa_dpr)
1346
+ )
2064
1347
 
2065
- # Optionally remove temporary columns
2066
- df.drop(
2067
- columns=["htg_delta_temp", "htg_delta_sqrted", "combined_check"],
2068
- inplace=True,
2069
- )
1348
+ # Combine conditions:
1349
+ # Fault occurs when there's a significant temperature rise across an inactive heating coil
1350
+ # in OS2 (economizer), OS3 (economizer + mechanical cooling), or OS4 (mechanical cooling only) mode
1351
+ combined_check = significant_temp_rise & (os2_mode | os3_mode | os4_mode)
2070
1352
 
2071
- return df
1353
+ # Set fault flag
1354
+ self._set_fault_flag(df, combined_check, "fc15_flag")
2072
1355
 
2073
- except MissingColumnError as e:
2074
- print(f"Error: {e.message}")
2075
- sys.stdout.flush()
2076
- raise e
2077
- except InvalidParameterError as e:
2078
- print(f"Error: {e.message}")
2079
- sys.stdout.flush()
2080
- raise e
1356
+ return df
2081
1357
 
2082
1358
 
2083
- class FaultConditionSixteen(FaultCondition):
1359
+ class FaultConditionSixteen(BaseFaultCondition, FaultConditionMixin):
2084
1360
  """Class provides the definitions for Fault Condition 16.
2085
- ERV Ineffective Process based on outdoor air temperature ranges.
2086
- """
1361
+ ERV effectiveness should be within specified thresholds based on OAT.
1362
+ This fault checks if the ERV (Energy Recovery Ventilator) is operating
1363
+ within expected efficiency ranges in both heating and cooling modes.
2087
1364
 
2088
- def __init__(self, dict_):
2089
- super().__init__()
2090
-
2091
- # Threshold parameters for efficiency ranges based on heating and cooling
2092
- self.erv_efficiency_min_heating = dict_.get("ERV_EFFICIENCY_MIN_HEATING", 0.7)
2093
- self.erv_efficiency_max_heating = dict_.get("ERV_EFFICIENCY_MAX_HEATING", 0.8)
2094
- self.erv_efficiency_min_cooling = dict_.get("ERV_EFFICIENCY_MIN_COOLING", 0.5)
2095
- self.erv_efficiency_max_cooling = dict_.get("ERV_EFFICIENCY_MAX_COOLING", 0.6)
1365
+ py -3.12 -m pytest open_fdd/tests/ahu/test_ahu_fc16.py -rP -s
1366
+ """
2096
1367
 
2097
- self.oat_low_threshold = dict_.get("OAT_LOW_THRES", 32.0)
2098
- self.oat_high_threshold = dict_.get("OAT_HIGH_THRES", 80.0)
1368
+ def _init_specific_attributes(self, dict_):
1369
+ # Threshold parameters
1370
+ self.erv_efficiency_min_heating = dict_.get("ERV_EFFICIENCY_MIN_HEATING", None)
1371
+ self.erv_efficiency_max_heating = dict_.get("ERV_EFFICIENCY_MAX_HEATING", None)
1372
+ self.erv_efficiency_min_cooling = dict_.get("ERV_EFFICIENCY_MIN_COOLING", None)
1373
+ self.erv_efficiency_max_cooling = dict_.get("ERV_EFFICIENCY_MAX_COOLING", None)
1374
+ self.oat_low_threshold = dict_.get("OAT_LOW_THRESHOLD", None)
1375
+ self.oat_high_threshold = dict_.get("OAT_HIGH_THRESHOLD", None)
2099
1376
  self.oat_rat_delta_min = dict_.get("OAT_RAT_DELTA_MIN", None)
2100
1377
 
2101
- # Validate that threshold parameters are floats and within 0.0 and 1.0 for efficiency values
1378
+ # Validate that threshold parameters are floats
2102
1379
  for param, value in [
2103
1380
  ("erv_efficiency_min_heating", self.erv_efficiency_min_heating),
2104
1381
  ("erv_efficiency_max_heating", self.erv_efficiency_max_heating),
@@ -2112,44 +1389,41 @@ class FaultConditionSixteen(FaultCondition):
2112
1389
  raise InvalidParameterError(
2113
1390
  f"The parameter '{param}' should be a float, but got {type(value).__name__}."
2114
1391
  )
2115
- if "erv_efficiency" in param and not (0.0 <= value <= 1.0):
1392
+
1393
+ # Validate that efficiency values are between 0.0 and 1.0
1394
+ for param, value in [
1395
+ ("ERV_EFFICIENCY_MIN_HEATING", self.erv_efficiency_min_heating),
1396
+ ("ERV_EFFICIENCY_MAX_HEATING", self.erv_efficiency_max_heating),
1397
+ ("ERV_EFFICIENCY_MIN_COOLING", self.erv_efficiency_min_cooling),
1398
+ ("ERV_EFFICIENCY_MAX_COOLING", self.erv_efficiency_max_cooling),
1399
+ ]:
1400
+ if not 0.0 <= value <= 1.0:
2116
1401
  raise InvalidParameterError(
2117
- f"The parameter '{param}' should be a float between 0.0 and 1.0 to represent a percentage, but got {value}."
1402
+ f"The parameter '{param}' should be a float between 0.0 and 1.0, but got {value}."
2118
1403
  )
2119
1404
 
2120
1405
  # Other attributes
2121
- self.erv_oat_enter_col = dict_.get("ERV_OAT_ENTER_COL", "erv_oat_enter")
2122
- self.erv_oat_leaving_col = dict_.get("ERV_OAT_LEAVING_COL", "erv_oat_leaving")
2123
- self.erv_eat_enter_col = dict_.get("ERV_EAT_ENTER_COL", "erv_eat_enter")
2124
- self.erv_eat_leaving_col = dict_.get("ERV_EAT_LEAVING_COL", "erv_eat_leaving")
2125
- self.supply_vfd_speed_col = dict_.get(
2126
- "SUPPLY_VFD_SPEED_COL", "supply_vfd_speed"
2127
- )
2128
- self.rolling_window_size = dict_.get("ROLLING_WINDOW_SIZE", 1)
2129
- self.troubleshoot_mode = dict_.get("TROUBLESHOOT_MODE", False)
1406
+ self.erv_oat_enter_col = dict_.get("ERV_OAT_ENTER_COL", None)
1407
+ self.erv_oat_leaving_col = dict_.get("ERV_OAT_LEAVING_COL", None)
1408
+ self.erv_eat_enter_col = dict_.get("ERV_EAT_ENTER_COL", None)
1409
+ self.erv_eat_leaving_col = dict_.get("ERV_EAT_LEAVING_COL", None)
1410
+ self.supply_vfd_speed_col = dict_.get("SUPPLY_VFD_SPEED_COL", None)
2130
1411
 
1412
+ # Set documentation strings
2131
1413
  self.equation_string = (
2132
- "fc16_flag = 1 if temperature deltas and expected efficiency is ineffective "
1414
+ "fc16_flag = 1 if ERV effectiveness is outside expected range "
1415
+ "(heating: εmin_htg ≤ ε ≤ εmax_htg, cooling: εmin_clg ≤ ε ≤ εmax_clg) "
2133
1416
  "for N consecutive values else 0 \n"
2134
1417
  )
2135
1418
  self.description_string = (
2136
- "Fault Condition 16: ERV is an ineffective heat transfer fault. "
2137
- "This fault occurs when the ERV's efficiency "
2138
- "is outside the acceptable range based on the delta temperature across the "
2139
- "ERV outside air enter temperature and ERV outside air leaving temperature, "
2140
- "indicating poor heat transfer. "
2141
- "It considers both heating and cooling conditions where each have acceptable "
2142
- "ranges in percentage for expected heat transfer efficiency. The percentage needs "
2143
- "to be a float between 0.0 and 1.0."
1419
+ "Fault Condition 16: ERV effectiveness should be within specified "
1420
+ "thresholds based on OAT \n"
2144
1421
  )
2145
1422
  self.required_column_description = (
2146
- "Required inputs are the ERV outside air entering temperature, ERV outside air leaving temperature, "
2147
- "ERV exhaust entering temperature, ERV exhaust leaving temperature, "
2148
- "and AHU supply fan VFD speed."
1423
+ "Required inputs are the ERV outdoor air entering and leaving temperatures, "
1424
+ "ERV exhaust air entering and leaving temperatures, and supply fan VFD speed \n"
2149
1425
  )
2150
- self.error_string = "One or more required columns are missing or None."
2151
-
2152
- self.set_attributes(dict_)
1426
+ self.error_string = "One or more required columns are missing or None \n"
2153
1427
 
2154
1428
  # Set required columns specific to this fault condition
2155
1429
  self.required_columns = [
@@ -2160,125 +1434,54 @@ class FaultConditionSixteen(FaultCondition):
2160
1434
  self.supply_vfd_speed_col,
2161
1435
  ]
2162
1436
 
2163
- # Check if any of the required columns are None
2164
- if any(col is None for col in self.required_columns):
2165
- raise MissingColumnError(
2166
- f"{self.error_string}\n"
2167
- f"{self.equation_string}\n"
2168
- f"{self.description_string}\n"
2169
- f"{self.required_column_description}\n"
2170
- f"Missing columns: {self.required_columns}"
2171
- )
2172
-
2173
- # Ensure all required columns are strings
2174
- self.required_columns = [str(col) for col in self.required_columns]
2175
-
2176
- self.mapped_columns = (
2177
- f"Your config dictionary is mapped as: {', '.join(self.required_columns)}"
2178
- )
2179
-
2180
- def get_required_columns(self) -> str:
2181
- """Returns a string representation of the required columns."""
2182
- return (
2183
- f"{self.equation_string}"
2184
- f"{self.description_string}\n"
2185
- f"{self.required_column_description}\n"
2186
- f"{self.mapped_columns}"
2187
- )
2188
-
2189
- def calculate_erv_efficiency(self, df: pd.DataFrame) -> pd.DataFrame:
2190
-
2191
- df = SharedUtils.clean_nan_values(df)
2192
-
2193
- cols_to_check = [self.erv_eat_enter_col, self.erv_oat_enter_col]
2194
- if df[cols_to_check].eq(0).any().any():
2195
- print(f"Warning: Zero values found in columns: {cols_to_check}")
2196
- print(f"This may cause division by zero errors.")
2197
- sys.stdout.flush()
2198
-
2199
- # Calculate the temperature differences
2200
- delta_temp_oa = df[self.erv_oat_leaving_col] - df[self.erv_oat_enter_col]
2201
- delta_temp_ea = df[self.erv_eat_enter_col] - df[self.erv_oat_enter_col]
2202
-
2203
- # Use the absolute value to handle both heating and cooling applications
2204
- df["erv_efficiency_oa"] = np.abs(delta_temp_oa) / np.abs(delta_temp_ea)
2205
-
2206
- return df
2207
-
1437
+ @FaultConditionMixin._handle_errors
2208
1438
  def apply(self, df: pd.DataFrame) -> pd.DataFrame:
2209
- try:
2210
- # Calculate ERV efficiency
2211
- df = self.calculate_erv_efficiency(df)
2212
-
2213
- # Fan must be on for a fault to be considered
2214
- fan_on = df[self.supply_vfd_speed_col] > 0.1
1439
+ """Apply the fault condition to the DataFrame."""
1440
+ # Apply common checks
1441
+ self._apply_common_checks(df)
2215
1442
 
2216
- # Determine if the conditions are for heating or cooling based on OAT
2217
- cold_outside = df[self.erv_oat_enter_col] <= self.oat_low_threshold
2218
- hot_outside = df[self.erv_oat_enter_col] >= self.oat_high_threshold
1443
+ # Check analog outputs [data with units of %] are floats only
1444
+ columns_to_check = [self.supply_vfd_speed_col]
1445
+ self._apply_analog_checks(df, columns_to_check)
2219
1446
 
2220
- # Calculate the temperature difference between the exhaust air entering and outside air entering
2221
- rat_minus_oat = abs(df[self.erv_eat_enter_col] - df[self.erv_oat_enter_col])
2222
- good_delta_check = rat_minus_oat >= self.oat_rat_delta_min
1447
+ # Calculate temperature differences
1448
+ oat_rat_delta = abs(df[self.erv_oat_enter_col] - df[self.erv_eat_enter_col])
2223
1449
 
2224
- # Initialize the fault condition to False (no fault)
2225
- df["fc16_flag"] = 0
1450
+ # Calculate ERV effectiveness
1451
+ # ε = (T_leaving - T_entering) / (T_exhaust - T_entering)
1452
+ erv_effectiveness = (
1453
+ df[self.erv_oat_leaving_col] - df[self.erv_oat_enter_col]
1454
+ ) / (df[self.erv_eat_enter_col] - df[self.erv_oat_enter_col])
2226
1455
 
2227
- # Apply heating fault logic
2228
- heating_fault = (
2229
- (
2230
- (df["erv_efficiency_oa"] < self.erv_efficiency_min_heating)
2231
- | (df["erv_efficiency_oa"] > self.erv_efficiency_max_heating)
2232
- )
2233
- & cold_outside
2234
- & good_delta_check
2235
- & fan_on
2236
- )
2237
-
2238
- # Apply cooling fault logic
2239
- cooling_fault = (
2240
- (
2241
- (df["erv_efficiency_oa"] < self.erv_efficiency_min_cooling)
2242
- | (df["erv_efficiency_oa"] > self.erv_efficiency_max_cooling)
2243
- )
2244
- & hot_outside
2245
- & good_delta_check
2246
- & fan_on
2247
- )
2248
-
2249
- # Combine the faults
2250
- df["combined_checks"] = heating_fault | cooling_fault
1456
+ # Determine operating mode based on OAT
1457
+ heating_mode = df[self.erv_oat_enter_col] < self.oat_low_threshold
1458
+ cooling_mode = df[self.erv_oat_enter_col] > self.oat_high_threshold
2251
1459
 
2252
- # Apply rolling sum to combined checks to account for rolling window
2253
- df["fc16_flag"] = (
2254
- df["combined_checks"]
2255
- .rolling(window=self.rolling_window_size)
2256
- .sum()
2257
- .ge(self.rolling_window_size)
2258
- .astype(int)
2259
- )
1460
+ # Check effectiveness against thresholds
1461
+ low_effectiveness_htg = heating_mode & (
1462
+ erv_effectiveness < self.erv_efficiency_min_heating
1463
+ )
1464
+ high_effectiveness_htg = heating_mode & (
1465
+ erv_effectiveness > self.erv_efficiency_max_heating
1466
+ )
1467
+ low_effectiveness_clg = cooling_mode & (
1468
+ erv_effectiveness < self.erv_efficiency_min_cooling
1469
+ )
1470
+ high_effectiveness_clg = cooling_mode & (
1471
+ erv_effectiveness > self.erv_efficiency_max_cooling
1472
+ )
2260
1473
 
2261
- if self.troubleshoot_mode:
2262
- print("Troubleshoot mode enabled - not removing helper columns")
2263
- sys.stdout.flush()
2264
-
2265
- # Drop helper cols if not in troubleshoot mode
2266
- if not self.troubleshoot_mode:
2267
- df.drop(
2268
- columns=[
2269
- "combined_checks",
2270
- "erv_efficiency_oa",
2271
- ],
2272
- inplace=True,
2273
- )
1474
+ # Combine conditions:
1475
+ # Fault occurs when ERV effectiveness is outside expected range
1476
+ # and there's sufficient temperature difference between OAT and RAT
1477
+ combined_check = (oat_rat_delta >= self.oat_rat_delta_min) & (
1478
+ low_effectiveness_htg
1479
+ | high_effectiveness_htg
1480
+ | low_effectiveness_clg
1481
+ | high_effectiveness_clg
1482
+ )
2274
1483
 
2275
- return df
1484
+ # Set fault flag
1485
+ self._set_fault_flag(df, combined_check, "fc16_flag")
2276
1486
 
2277
- except MissingColumnError as e:
2278
- print(f"Error: {e.message}")
2279
- sys.stdout.flush()
2280
- raise e
2281
- except InvalidParameterError as e:
2282
- print(f"Error: {e.message}")
2283
- sys.stdout.flush()
2284
- raise e
1487
+ return df