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