ecopipeline 1.0.4__py3-none-any.whl → 1.0.5__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.
- ecopipeline/event_tracking/__init__.py +3 -2
- ecopipeline/event_tracking/event_tracking.py +910 -39
- ecopipeline/extract/__init__.py +2 -2
- ecopipeline/extract/extract.py +84 -0
- ecopipeline/transform/transform.py +1 -1
- ecopipeline/utils/ConfigManager.py +15 -2
- {ecopipeline-1.0.4.dist-info → ecopipeline-1.0.5.dist-info}/METADATA +1 -1
- {ecopipeline-1.0.4.dist-info → ecopipeline-1.0.5.dist-info}/RECORD +11 -11
- {ecopipeline-1.0.4.dist-info → ecopipeline-1.0.5.dist-info}/WHEEL +1 -1
- {ecopipeline-1.0.4.dist-info → ecopipeline-1.0.5.dist-info}/licenses/LICENSE +0 -0
- {ecopipeline-1.0.4.dist-info → ecopipeline-1.0.5.dist-info}/top_level.txt +0 -0
|
@@ -17,9 +17,18 @@ def central_alarm_df_creator(df: pd.DataFrame, daily_data : pd.DataFrame, config
|
|
|
17
17
|
dict_of_alarms['boundary'] = flag_boundary_alarms(df, config, full_days=day_list, system=system, default_fault_time= default_boundary_fault_time)
|
|
18
18
|
dict_of_alarms['power ratio'] = power_ratio_alarm(daily_data, config, day_table_name = config.get_table_name(day_table_name_header), system=system, ratio_period_days=power_ratio_period_days)
|
|
19
19
|
dict_of_alarms['abnormal COP'] = flag_abnormal_COP(daily_data, config, system = system, default_high_bound=default_cop_high_bound, default_low_bound=default_cop_low_bound)
|
|
20
|
-
dict_of_alarms['
|
|
20
|
+
dict_of_alarms['temperature maintenance setpoint'] = flag_high_tm_setpoint(df, daily_data, config, system=system)
|
|
21
21
|
dict_of_alarms['recirculation loop balancing valve'] = flag_recirc_balance_valve(daily_data, config, system=system)
|
|
22
22
|
dict_of_alarms['HPWH inlet temperature'] = flag_hp_inlet_temp(df, daily_data, config, system)
|
|
23
|
+
dict_of_alarms['HPWH outlet temperature'] = flag_hp_outlet_temp(df, daily_data, config, system)
|
|
24
|
+
dict_of_alarms['improper backup heating use'] = flag_backup_use(df, daily_data, config, system)
|
|
25
|
+
dict_of_alarms['blown equipment fuse'] = flag_blown_fuse(df, daily_data, config, system)
|
|
26
|
+
dict_of_alarms['unexpected SOO change'] = flag_unexpected_soo_change(df, daily_data, config, system)
|
|
27
|
+
dict_of_alarms['short cycle'] = flag_shortcycle(df, daily_data, config, system)
|
|
28
|
+
dict_of_alarms['HPWH outage'] = flag_HP_outage(df, daily_data, config, day_table_name = config.get_table_name(day_table_name_header), system=system)
|
|
29
|
+
dict_of_alarms['unexpected temperature'] = flag_unexpected_temp(df, daily_data, config, system)
|
|
30
|
+
dict_of_alarms['demand response inconsistency'] = flag_ls_mode_inconsistancy(df, daily_data, config, system)
|
|
31
|
+
|
|
23
32
|
|
|
24
33
|
ongoing_COP_exception = ['abnormal COP']
|
|
25
34
|
|
|
@@ -183,7 +192,7 @@ def flag_boundary_alarms(df: pd.DataFrame, config : ConfigManager, default_fault
|
|
|
183
192
|
|
|
184
193
|
return _convert_silent_alarm_dict_to_df(alarms)
|
|
185
194
|
|
|
186
|
-
def
|
|
195
|
+
def flag_high_tm_setpoint(df: pd.DataFrame, daily_df: pd.DataFrame, config : ConfigManager, default_fault_time : int = 3,
|
|
187
196
|
system: str = "", default_setpoint : float = 130.0, default_power_indication : float = 1.0,
|
|
188
197
|
default_power_ratio : float = 0.4) -> pd.DataFrame:
|
|
189
198
|
"""
|
|
@@ -191,10 +200,10 @@ def flag_high_swing_setpoint(df: pd.DataFrame, daily_df: pd.DataFrame, config :
|
|
|
191
200
|
and create an dataframe with applicable alarm events
|
|
192
201
|
|
|
193
202
|
VarNames syntax:
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
203
|
+
TMSTPT_T_ID:### - Swing Tank Outlet Temperature. Alarm triggered if over number ### (or 130) for 3 minutes with power on
|
|
204
|
+
TMSTPT_SP_ID:### - Swing Tank Power. ### is lowest recorded power for Swing Tank to be considered 'on'. Defaults to 1.0
|
|
205
|
+
TMSTPT_TP_ID:### - Total System Power for ratio alarming for alarming if swing tank power is more than ### (40% default) of usage
|
|
206
|
+
TMSTPT_ST_ID:### - Swing Tank Setpoint that should not change at all from ### (default 130)
|
|
198
207
|
|
|
199
208
|
Parameters
|
|
200
209
|
----------
|
|
@@ -207,7 +216,7 @@ def flag_high_swing_setpoint(df: pd.DataFrame, daily_df: pd.DataFrame, config :
|
|
|
207
216
|
The ConfigManager object that holds configuration data for the pipeline. Among other things, this object will point to a file
|
|
208
217
|
called Varriable_Names.csv in the input folder of the pipeline (e.g. "full/path/to/pipeline/input/Variable_Names.csv").
|
|
209
218
|
The file must have at least two columns which must be titled "variable_name" and "alarm_codes" which should contain the
|
|
210
|
-
name of each variable in the dataframe that requires alarming and the
|
|
219
|
+
name of each variable in the dataframe that requires alarming and the TMSTPT alarm codes (e.g., TMSTPT_T_1:140, TMSTPT_SP_1:2.0)
|
|
211
220
|
default_fault_time : int
|
|
212
221
|
Number of consecutive minutes for T+SP alarms (default 3). T+SP alarms trigger when tank is powered and temperature exceeds
|
|
213
222
|
setpoint for this many consecutive minutes.
|
|
@@ -235,7 +244,7 @@ def flag_high_swing_setpoint(df: pd.DataFrame, daily_df: pd.DataFrame, config :
|
|
|
235
244
|
print("File Not Found: ", variable_names_path)
|
|
236
245
|
return pd.DataFrame()
|
|
237
246
|
|
|
238
|
-
bounds_df = _process_bounds_df_alarm_codes(bounds_df, '
|
|
247
|
+
bounds_df = _process_bounds_df_alarm_codes(bounds_df, 'TMSTPT',
|
|
239
248
|
{'T' : default_setpoint,
|
|
240
249
|
'SP': default_power_indication,
|
|
241
250
|
'TP': default_power_ratio,
|
|
@@ -266,7 +275,9 @@ def flag_high_swing_setpoint(df: pd.DataFrame, daily_df: pd.DataFrame, config :
|
|
|
266
275
|
# Check if we have both T and SP
|
|
267
276
|
if len(t_codes) == 1 and len(sp_codes) == 1:
|
|
268
277
|
t_var_name = t_codes.iloc[0]['variable_name']
|
|
278
|
+
t_pretty_name = t_codes.iloc[0]['pretty_name']
|
|
269
279
|
sp_var_name = sp_codes.iloc[0]['variable_name']
|
|
280
|
+
sp_pretty_name = sp_codes.iloc[0]['pretty_name']
|
|
270
281
|
sp_power_indication = sp_codes.iloc[0]['bound']
|
|
271
282
|
t_setpoint = t_codes.iloc[0]['bound']
|
|
272
283
|
# Check if both variables exist in df
|
|
@@ -284,11 +295,12 @@ def flag_high_swing_setpoint(df: pd.DataFrame, daily_df: pd.DataFrame, config :
|
|
|
284
295
|
first_true_index = consecutive_condition.idxmax()
|
|
285
296
|
# Adjust for the rolling window (first fault_time-1 minutes don't count)
|
|
286
297
|
adjusted_time = first_true_index - pd.Timedelta(minutes=default_fault_time-1)
|
|
287
|
-
_add_an_alarm(alarms, adjusted_time, sp_var_name, f"High
|
|
298
|
+
_add_an_alarm(alarms, adjusted_time, sp_var_name, f"High TM Setpoint: {sp_pretty_name} showed draw at {adjusted_time} although {t_pretty_name} was above {t_setpoint} F.")
|
|
288
299
|
alarmed_for_day = True
|
|
289
300
|
if not alarmed_for_day and len(st_codes) == 1:
|
|
290
301
|
st_var_name = st_codes.iloc[0]['variable_name']
|
|
291
302
|
st_setpoint = st_codes.iloc[0]['bound']
|
|
303
|
+
st_pretty_name = st_codes.iloc[0]['pretty_name']
|
|
292
304
|
# Check if st_var_name exists in filtered_df
|
|
293
305
|
if st_var_name in filtered_df.columns:
|
|
294
306
|
# Check if setpoint was altered for over 10 minutes
|
|
@@ -299,11 +311,12 @@ def flag_high_swing_setpoint(df: pd.DataFrame, daily_df: pd.DataFrame, config :
|
|
|
299
311
|
first_true_index = consecutive_condition.idxmax()
|
|
300
312
|
# Adjust for the rolling window
|
|
301
313
|
adjusted_time = first_true_index - pd.Timedelta(minutes=9)
|
|
302
|
-
_add_an_alarm(alarms, day, st_var_name, f"
|
|
314
|
+
_add_an_alarm(alarms, day, st_var_name, f"{st_pretty_name} was altered at {adjusted_time}")
|
|
303
315
|
alarmed_for_day = True
|
|
304
316
|
if not alarmed_for_day and len(tp_codes) == 1 and len(sp_codes) == 1:
|
|
305
317
|
tp_var_name = tp_codes.iloc[0]['variable_name']
|
|
306
318
|
sp_var_name = sp_codes.iloc[0]['variable_name']
|
|
319
|
+
sp_pretty_name = sp_codes.iloc[0]['pretty_name']
|
|
307
320
|
tp_ratio = tp_codes.iloc[0]['bound']
|
|
308
321
|
# Check if both variables exist in df
|
|
309
322
|
if tp_var_name in daily_df.columns and sp_var_name in daily_df.columns:
|
|
@@ -311,40 +324,255 @@ def flag_high_swing_setpoint(df: pd.DataFrame, daily_df: pd.DataFrame, config :
|
|
|
311
324
|
if day in daily_df.index and daily_df.loc[day, tp_var_name] != 0:
|
|
312
325
|
power_ratio = daily_df.loc[day, sp_var_name] / daily_df.loc[day, tp_var_name]
|
|
313
326
|
if power_ratio > tp_ratio:
|
|
314
|
-
_add_an_alarm(alarms, day, sp_var_name, f"High
|
|
327
|
+
_add_an_alarm(alarms, day, sp_var_name, f"High temperature maintenace power ratio: {sp_pretty_name} accounted for more than {tp_ratio * 100}% of daily power.")
|
|
315
328
|
return _convert_silent_alarm_dict_to_df(alarms)
|
|
316
329
|
|
|
317
|
-
def
|
|
330
|
+
def flag_backup_use(df: pd.DataFrame, daily_df: pd.DataFrame, config : ConfigManager,
|
|
331
|
+
system: str = "", default_setpoint : float = 130.0, default_power_ratio : float = 0.1) -> pd.DataFrame:
|
|
318
332
|
"""
|
|
319
333
|
Function will take a pandas dataframe and location of alarm information in a csv,
|
|
320
334
|
and create an dataframe with applicable alarm events
|
|
321
335
|
|
|
322
336
|
VarNames syntax:
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
337
|
+
BU_P_ID - Back Up Tank Power Varriable. Must be in same power units as total system power
|
|
338
|
+
BU_TP_ID:### - Total System Power for ratio alarming for alarming if back up power is more than ### (40% default) of usage
|
|
339
|
+
BU_ST_ID:### - Back Up Setpoint that should not change at all from ### (default 130)
|
|
326
340
|
|
|
327
341
|
Parameters
|
|
328
342
|
----------
|
|
343
|
+
df: pd.DataFrame
|
|
344
|
+
post-transformed dataframe for minute data. It should be noted that this function expects consecutive, in order minutes. If minutes
|
|
345
|
+
are out of order or have gaps, the function may return erroneous alarms.
|
|
329
346
|
daily_df: pd.DataFrame
|
|
330
|
-
post-transformed dataframe for daily data. Used for checking
|
|
331
|
-
power to heating output power.
|
|
347
|
+
post-transformed dataframe for daily data. Used for checking power ratios and determining which days to process.
|
|
332
348
|
config : ecopipeline.ConfigManager
|
|
333
349
|
The ConfigManager object that holds configuration data for the pipeline. Among other things, this object will point to a file
|
|
334
350
|
called Varriable_Names.csv in the input folder of the pipeline (e.g. "full/path/to/pipeline/input/Variable_Names.csv").
|
|
335
351
|
The file must have at least two columns which must be titled "variable_name" and "alarm_codes" which should contain the
|
|
336
|
-
name of each variable in the dataframe that requires alarming and the
|
|
352
|
+
name of each variable in the dataframe that requires alarming and the STS alarm codes (e.g., STS_T_1:140, STS_SP_1:2.0)
|
|
337
353
|
system: str
|
|
338
354
|
string of system name if processing a particular system in a Variable_Names.csv file with multiple systems. Leave as an empty string if not aplicable.
|
|
355
|
+
default_setpoint : float
|
|
356
|
+
Default temperature setpoint in degrees for T and ST alarm codes when no custom bound is specified (default 130.0)
|
|
357
|
+
default_power_indication : float
|
|
358
|
+
Default power threshold in kW for SP alarm codes when no custom bound is specified (default 1.0)
|
|
339
359
|
default_power_ratio : float
|
|
340
|
-
Default power ratio threshold (as decimal, e.g., 0.4 for 40%) for
|
|
341
|
-
Alarm triggers when sum of ER equipment >= (OUT value / default_power_ratio)
|
|
360
|
+
Default power ratio threshold (as decimal, e.g., 0.4 for 40%) for TP alarm codes when no custom bound is specified (default 0.4)
|
|
342
361
|
|
|
343
362
|
Returns
|
|
344
363
|
-------
|
|
345
364
|
pd.DataFrame:
|
|
346
365
|
Pandas dataframe with alarm events
|
|
347
366
|
"""
|
|
367
|
+
if df.empty:
|
|
368
|
+
print("cannot flag swing tank setpoint alarms. Dataframe is empty")
|
|
369
|
+
return pd.DataFrame()
|
|
370
|
+
variable_names_path = config.get_var_names_path()
|
|
371
|
+
try:
|
|
372
|
+
bounds_df = pd.read_csv(variable_names_path)
|
|
373
|
+
except FileNotFoundError:
|
|
374
|
+
print("File Not Found: ", variable_names_path)
|
|
375
|
+
return pd.DataFrame()
|
|
376
|
+
|
|
377
|
+
bounds_df = _process_bounds_df_alarm_codes(bounds_df, 'BU',
|
|
378
|
+
{'POW': None,
|
|
379
|
+
'TP': default_power_ratio,
|
|
380
|
+
'ST': default_setpoint},
|
|
381
|
+
system)
|
|
382
|
+
if bounds_df.empty:
|
|
383
|
+
return _convert_silent_alarm_dict_to_df({}) # no alarms to look into
|
|
384
|
+
|
|
385
|
+
# Process each unique alarm_code_id
|
|
386
|
+
alarms = {}
|
|
387
|
+
for day in daily_df.index:
|
|
388
|
+
next_day = day + pd.Timedelta(days=1)
|
|
389
|
+
filtered_df = df.loc[(df.index >= day) & (df.index < next_day)]
|
|
390
|
+
alarmed_for_day = False
|
|
391
|
+
for alarm_id in bounds_df['alarm_code_id'].unique():
|
|
392
|
+
id_group = bounds_df[bounds_df['alarm_code_id'] == alarm_id]
|
|
393
|
+
|
|
394
|
+
# Get T and SP alarm codes for this ID
|
|
395
|
+
pow_codes = id_group[id_group['alarm_code_type'] == 'POW']
|
|
396
|
+
tp_codes = id_group[id_group['alarm_code_type'] == 'TP']
|
|
397
|
+
st_codes = id_group[id_group['alarm_code_type'] == 'ST']
|
|
398
|
+
|
|
399
|
+
# Check for multiple T or SP codes with same ID
|
|
400
|
+
if len(tp_codes) > 1:
|
|
401
|
+
raise Exception(f"Improper alarm codes for swing tank setpoint with id {alarm_id}")
|
|
402
|
+
|
|
403
|
+
if not alarmed_for_day and len(st_codes) >= 1:
|
|
404
|
+
# Check each ST code against its individual bound
|
|
405
|
+
for idx, st_row in st_codes.iterrows():
|
|
406
|
+
st_var_name = st_row['variable_name']
|
|
407
|
+
st_setpoint = st_row['bound']
|
|
408
|
+
# Check if st_var_name exists in filtered_df
|
|
409
|
+
if st_var_name in filtered_df.columns:
|
|
410
|
+
# Check if setpoint was altered for over 10 minutes
|
|
411
|
+
altered_mask = filtered_df[st_var_name] != st_setpoint
|
|
412
|
+
consecutive_condition = altered_mask.rolling(window=10).min() == 1
|
|
413
|
+
if consecutive_condition.any():
|
|
414
|
+
# Get the first index where condition was met
|
|
415
|
+
first_true_index = consecutive_condition.idxmax()
|
|
416
|
+
# Adjust for the rolling window
|
|
417
|
+
adjusted_time = first_true_index - pd.Timedelta(minutes=9)
|
|
418
|
+
_add_an_alarm(alarms, day, st_var_name, f"Swing tank setpoint was altered at {adjusted_time}")
|
|
419
|
+
alarmed_for_day = True
|
|
420
|
+
break # Exit loop once we've found an alarm for this day
|
|
421
|
+
if not alarmed_for_day and len(tp_codes) == 1 and len(pow_codes) >= 1:
|
|
422
|
+
tp_var_name = tp_codes.iloc[0]['variable_name']
|
|
423
|
+
tp_bound = tp_codes.iloc[0]['bound']
|
|
424
|
+
if tp_var_name in daily_df.columns:
|
|
425
|
+
# Get list of ER variable names
|
|
426
|
+
bu_pow_names = pow_codes['variable_name'].tolist()
|
|
427
|
+
|
|
428
|
+
# Check if all ER variables exist in daily_df
|
|
429
|
+
if all(var in daily_df.columns for var in bu_pow_names):
|
|
430
|
+
# Sum all ER variables for this day
|
|
431
|
+
bu_pow_sum = daily_df.loc[day, bu_pow_names].sum()
|
|
432
|
+
tp_value = daily_df.loc[day, tp_var_name]
|
|
433
|
+
|
|
434
|
+
# Check if sum of ER >= OUT value
|
|
435
|
+
if bu_pow_sum >= tp_value*tp_bound:
|
|
436
|
+
_add_an_alarm(alarms, day, tp_var_name, f"Improper Back Up Use: Sum of back up equipment ({bu_pow_sum:.2f}) exceeds {(tp_bound * 100):.2f}% of total power.")
|
|
437
|
+
|
|
438
|
+
return _convert_silent_alarm_dict_to_df(alarms)
|
|
439
|
+
|
|
440
|
+
def flag_HP_outage(df: pd.DataFrame, daily_df: pd.DataFrame, config : ConfigManager, day_table_name : str, system: str = "", default_power_ratio : float = 0.3,
|
|
441
|
+
ratio_period_days : int = 7) -> pd.DataFrame:
|
|
442
|
+
"""
|
|
443
|
+
Detects possible heat pump failures or outages by checking if heat pump power consumption falls below
|
|
444
|
+
an expected ratio of total system power over a rolling period, or by checking for non-zero values in
|
|
445
|
+
a direct alarm variable from the heat pump controller.
|
|
446
|
+
|
|
447
|
+
VarNames syntax:
|
|
448
|
+
HPOUT_POW_[OPTIONAL ID]:### - Heat pump power variable. ### is the minimum expected ratio of HP power to total power
|
|
449
|
+
(default 0.3 for 30%). Must be in same power units as total system power.
|
|
450
|
+
HPOUT_TP_[OPTIONAL ID] - Total system power variable for ratio comparison. Required when using POW codes.
|
|
451
|
+
HPOUT_ALRM_[OPTIONAL ID] - Direct alarm variable from HP controller. Alarm triggers if any non-zero value is detected.
|
|
452
|
+
|
|
453
|
+
Parameters
|
|
454
|
+
----------
|
|
455
|
+
df: pd.DataFrame
|
|
456
|
+
Post-transformed dataframe for minute data. Used for checking ALRM codes for non-zero values.
|
|
457
|
+
daily_df: pd.DataFrame
|
|
458
|
+
Post-transformed dataframe for daily data. Used for checking power ratios over the rolling period.
|
|
459
|
+
config : ecopipeline.ConfigManager
|
|
460
|
+
The ConfigManager object that holds configuration data for the pipeline. Among other things, this object will point to a file
|
|
461
|
+
called Variable_Names.csv in the input folder of the pipeline (e.g. "full/path/to/pipeline/input/Variable_Names.csv").
|
|
462
|
+
The file must have at least two columns which must be titled "variable_name" and "alarm_codes" which should contain the
|
|
463
|
+
name of each variable in the dataframe that requires alarming and the HPOUT alarm codes (e.g., HPOUT_POW_1:0.3, HPOUT_TP_1, HPOUT_ALRM_1).
|
|
464
|
+
day_table_name : str
|
|
465
|
+
Name of the daily database table to fetch previous days' data for the rolling period calculation.
|
|
466
|
+
system: str
|
|
467
|
+
String of system name if processing a particular system in a Variable_Names.csv file with multiple systems. Leave as an empty string if not applicable.
|
|
468
|
+
default_power_ratio : float
|
|
469
|
+
Default minimum power ratio threshold (as decimal, e.g., 0.3 for 30%) for POW alarm codes when no custom bound is specified (default 0.3).
|
|
470
|
+
An alarm triggers if HP power falls below this ratio of total power over the rolling period.
|
|
471
|
+
ratio_period_days : int
|
|
472
|
+
Number of days to use for the rolling power ratio calculation (default 7). Must be greater than 1.
|
|
473
|
+
|
|
474
|
+
Returns
|
|
475
|
+
-------
|
|
476
|
+
pd.DataFrame:
|
|
477
|
+
Pandas dataframe with alarm events
|
|
478
|
+
"""
|
|
479
|
+
if df.empty:
|
|
480
|
+
print("cannot flag swing tank setpoint alarms. Dataframe is empty")
|
|
481
|
+
return pd.DataFrame()
|
|
482
|
+
variable_names_path = config.get_var_names_path()
|
|
483
|
+
try:
|
|
484
|
+
bounds_df = pd.read_csv(variable_names_path)
|
|
485
|
+
except FileNotFoundError:
|
|
486
|
+
print("File Not Found: ", variable_names_path)
|
|
487
|
+
return pd.DataFrame()
|
|
488
|
+
|
|
489
|
+
bounds_df = _process_bounds_df_alarm_codes(bounds_df, 'HPOUT',
|
|
490
|
+
{'POW': default_power_ratio,
|
|
491
|
+
'TP': None,
|
|
492
|
+
'ALRM': None},
|
|
493
|
+
system)
|
|
494
|
+
if bounds_df.empty:
|
|
495
|
+
return _convert_silent_alarm_dict_to_df({}) # no alarms to look into
|
|
496
|
+
|
|
497
|
+
# Process each unique alarm_code_id
|
|
498
|
+
alarms = {}
|
|
499
|
+
for alarm_id in bounds_df['alarm_code_id'].unique():
|
|
500
|
+
id_group = bounds_df[bounds_df['alarm_code_id'] == alarm_id]
|
|
501
|
+
|
|
502
|
+
# Get T and SP alarm codes for this ID
|
|
503
|
+
pow_codes = id_group[id_group['alarm_code_type'] == 'POW']
|
|
504
|
+
tp_codes = id_group[id_group['alarm_code_type'] == 'TP']
|
|
505
|
+
alrm_codes = id_group[id_group['alarm_code_type'] == 'ALRM']
|
|
506
|
+
if len(pow_codes) > 0 and len(tp_codes) != 1:
|
|
507
|
+
raise Exception(f"Improper alarm codes for heat pump outage with id {alarm_id}. Requires 1 total power (TP) variable.")
|
|
508
|
+
elif len(pow_codes) > 0 and len(tp_codes) == 1:
|
|
509
|
+
if ratio_period_days <= 1:
|
|
510
|
+
print("HP Outage alarm period, ratio_period_days, must be more than 1")
|
|
511
|
+
else:
|
|
512
|
+
tp_var_name = tp_codes.iloc[0]['variable_name']
|
|
513
|
+
daily_df_copy = daily_df.copy()
|
|
514
|
+
daily_df_copy = _append_previous_days_to_df(daily_df_copy, config, ratio_period_days, day_table_name)
|
|
515
|
+
for i in range(ratio_period_days - 1, len(daily_df_copy)):
|
|
516
|
+
start_idx = i - ratio_period_days + 1
|
|
517
|
+
end_idx = i + 1
|
|
518
|
+
day = daily_df_copy.index[i]
|
|
519
|
+
block_data = daily_df_copy.iloc[start_idx:end_idx].sum()
|
|
520
|
+
for j in range(len(pow_codes)):
|
|
521
|
+
pow_var_name = pow_codes.iloc[j]['variable_name']
|
|
522
|
+
pow_var_bound = pow_codes.iloc[j]['bound']
|
|
523
|
+
if block_data[pow_var_name] < block_data[tp_var_name] * pow_var_bound:
|
|
524
|
+
_add_an_alarm(alarms, day, pow_var_name, f"Possible Heat Pump failure or outage.")
|
|
525
|
+
elif len(alrm_codes) > 0:
|
|
526
|
+
for i in range(len(alrm_codes)):
|
|
527
|
+
alrm_var_name = alrm_codes.iloc[i]['variable_name']
|
|
528
|
+
if alrm_var_name in df.columns:
|
|
529
|
+
for day in daily_df.index:
|
|
530
|
+
next_day = day + pd.Timedelta(days=1)
|
|
531
|
+
filtered_df = df.loc[(df.index >= day) & (df.index < next_day)]
|
|
532
|
+
if not filtered_df.empty and (filtered_df[alrm_var_name] != 0).any():
|
|
533
|
+
_add_an_alarm(alarms, day, alrm_var_name, f"Heat pump alarm triggered.")
|
|
534
|
+
break
|
|
535
|
+
|
|
536
|
+
return _convert_silent_alarm_dict_to_df(alarms)
|
|
537
|
+
|
|
538
|
+
def flag_recirc_balance_valve(daily_df: pd.DataFrame, config : ConfigManager, system: str = "", default_power_ratio : float = 0.4) -> pd.DataFrame:
|
|
539
|
+
"""
|
|
540
|
+
Detects recirculation balance issues by comparing sum of ER (equipment recirculation) heater
|
|
541
|
+
power to either total power or heating output.
|
|
542
|
+
|
|
543
|
+
VarNames syntax:
|
|
544
|
+
BV_ER_[OPTIONAL ID] - Indicates a power variable for an ER heater (equipment recirculation).
|
|
545
|
+
Multiple ER variables with the same ID will be summed together.
|
|
546
|
+
BV_TP_[OPTIONAL ID]:### - Indicates the Total Power of the system. Optional ### for the percentage
|
|
547
|
+
threshold that should not be crossed by the ER elements (default 0.4 for 40%).
|
|
548
|
+
Alarm triggers when sum of ER >= total_power * threshold.
|
|
549
|
+
BV_OUT_[OPTIONAL ID] - Indicates the heating output variable the ER heating contributes to.
|
|
550
|
+
Alarm triggers when sum of ER > sum of OUT * 0.95 (i.e., ER exceeds 95% of heating output).
|
|
551
|
+
Multiple OUT variables with the same ID will be summed together.
|
|
552
|
+
|
|
553
|
+
Note: Each alarm ID requires at least one ER code AND either one TP code OR at least one OUT code.
|
|
554
|
+
If a TP code exists for an ID, it takes precedence over OUT codes.
|
|
555
|
+
|
|
556
|
+
Parameters
|
|
557
|
+
----------
|
|
558
|
+
daily_df: pd.DataFrame
|
|
559
|
+
Post-transformed dataframe for daily data. Used for checking recirculation balance by comparing sum of ER equipment
|
|
560
|
+
power to total power or heating output power.
|
|
561
|
+
config : ecopipeline.ConfigManager
|
|
562
|
+
The ConfigManager object that holds configuration data for the pipeline. Among other things, this object will point to a file
|
|
563
|
+
called Variable_Names.csv in the input folder of the pipeline (e.g. "full/path/to/pipeline/input/Variable_Names.csv").
|
|
564
|
+
The file must have at least two columns which must be titled "variable_name" and "alarm_codes" which should contain the
|
|
565
|
+
name of each variable in the dataframe that requires alarming and the BV alarm codes (e.g., BV_ER_1, BV_TP_1:0.3)
|
|
566
|
+
system: str
|
|
567
|
+
String of system name if processing a particular system in a Variable_Names.csv file with multiple systems. Leave as an empty string if not applicable.
|
|
568
|
+
default_power_ratio : float
|
|
569
|
+
Default power ratio threshold (as decimal, e.g., 0.4 for 40%) for TP alarm codes when no custom bound is specified (default 0.4).
|
|
570
|
+
|
|
571
|
+
Returns
|
|
572
|
+
-------
|
|
573
|
+
pd.DataFrame:
|
|
574
|
+
Pandas dataframe with alarm events
|
|
575
|
+
"""
|
|
348
576
|
if daily_df.empty:
|
|
349
577
|
print("cannot flag missing balancing valve alarms. Dataframe is empty")
|
|
350
578
|
return pd.DataFrame()
|
|
@@ -355,7 +583,7 @@ def flag_recirc_balance_valve(daily_df: pd.DataFrame, config : ConfigManager, sy
|
|
|
355
583
|
print("File Not Found: ", variable_names_path)
|
|
356
584
|
return pd.DataFrame()
|
|
357
585
|
bounds_df = _process_bounds_df_alarm_codes(bounds_df, 'BV',
|
|
358
|
-
{'
|
|
586
|
+
{'TP' : default_power_ratio},
|
|
359
587
|
system)
|
|
360
588
|
if bounds_df.empty:
|
|
361
589
|
return _convert_silent_alarm_dict_to_df({}) # no BV alarms to look into
|
|
@@ -364,25 +592,38 @@ def flag_recirc_balance_valve(daily_df: pd.DataFrame, config : ConfigManager, sy
|
|
|
364
592
|
for alarm_id in bounds_df['alarm_code_id'].unique():
|
|
365
593
|
id_group = bounds_df[bounds_df['alarm_code_id'] == alarm_id]
|
|
366
594
|
out_codes = id_group[id_group['alarm_code_type'] == 'OUT']
|
|
367
|
-
|
|
368
|
-
out_bound = out_codes.iloc[0]['bound']
|
|
595
|
+
tp_codes = id_group[id_group['alarm_code_type'] == 'TP']
|
|
369
596
|
er_codes = id_group[id_group['alarm_code_type'] == 'ER']
|
|
370
|
-
if len(
|
|
597
|
+
if len(er_codes) < 1 or (len(out_codes) < 1 and len(tp_codes) != 1):
|
|
371
598
|
raise Exception(f"Improper alarm codes for balancing valve with id {alarm_id}")
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
599
|
+
er_var_names = er_codes['variable_name'].tolist()
|
|
600
|
+
if len(tp_codes) == 1 and tp_codes.iloc[0]['variable_name']in daily_df.columns:
|
|
601
|
+
tp_var_name = tp_codes.iloc[0]['variable_name']
|
|
602
|
+
tp_bound = tp_codes.iloc[0]['bound']
|
|
603
|
+
for day in daily_df.index:
|
|
376
604
|
|
|
377
605
|
# Check if all ER variables exist in daily_df
|
|
378
606
|
if all(var in daily_df.columns for var in er_var_names):
|
|
379
607
|
# Sum all ER variables for this day
|
|
380
608
|
er_sum = daily_df.loc[day, er_var_names].sum()
|
|
381
|
-
|
|
609
|
+
tp_value = daily_df.loc[day, tp_var_name]
|
|
382
610
|
|
|
383
611
|
# Check if sum of ER >= OUT value
|
|
384
|
-
if er_sum >=
|
|
385
|
-
_add_an_alarm(alarms, day,
|
|
612
|
+
if er_sum >= tp_value*tp_bound:
|
|
613
|
+
_add_an_alarm(alarms, day, tp_var_name, f"Recirculation imbalance: Sum of recirculation equipment ({er_sum:.2f}) exceeds or equals {(tp_bound * 100):.2f}% of total power.")
|
|
614
|
+
elif len(out_codes) >= 1:
|
|
615
|
+
out_var_names = out_codes['variable_name'].tolist()
|
|
616
|
+
for day in daily_df.index:
|
|
617
|
+
|
|
618
|
+
# Check if all ER variables exist in daily_df
|
|
619
|
+
if all(var in daily_df.columns for var in er_var_names) and all(var in daily_df.columns for var in out_var_names):
|
|
620
|
+
# Sum all ER variables for this day
|
|
621
|
+
er_sum = daily_df.loc[day, er_var_names].sum()
|
|
622
|
+
out_sum = daily_df.loc[day, out_var_names].sum()
|
|
623
|
+
|
|
624
|
+
# Check if sum of ER >= OUT value
|
|
625
|
+
if er_sum > out_sum:
|
|
626
|
+
_add_an_alarm(alarms, day, out_codes.iloc[0]['variable_name'], f"Recirculation imbalance: Sum of recirculation equipment power ({er_sum:.2f} kW) exceeds TM heating output ({out_sum:.2f} kW).")
|
|
386
627
|
return _convert_silent_alarm_dict_to_df(alarms)
|
|
387
628
|
|
|
388
629
|
def flag_hp_inlet_temp(df: pd.DataFrame, daily_df: pd.DataFrame, config : ConfigManager, system: str = "", default_power_threshold : float = 1.0,
|
|
@@ -473,7 +714,612 @@ def flag_hp_inlet_temp(df: pd.DataFrame, daily_df: pd.DataFrame, config : Config
|
|
|
473
714
|
|
|
474
715
|
return _convert_silent_alarm_dict_to_df(alarms)
|
|
475
716
|
|
|
476
|
-
def
|
|
717
|
+
def flag_hp_outlet_temp(df: pd.DataFrame, daily_df: pd.DataFrame, config : ConfigManager, system: str = "", default_power_threshold : float = 1.0,
|
|
718
|
+
default_temp_threshold : float = 140.0, fault_time : int = 5) -> pd.DataFrame:
|
|
719
|
+
"""
|
|
720
|
+
Detects low heat pump outlet temperature by checking if the outlet temperature falls below a threshold
|
|
721
|
+
while the heat pump is running. The first 10 minutes after each HP turn-on are excluded as a warmup
|
|
722
|
+
period. An alarm triggers if the temperature stays below the threshold for `fault_time` consecutive
|
|
723
|
+
minutes after the warmup period.
|
|
724
|
+
|
|
725
|
+
VarNames syntax:
|
|
726
|
+
HPO_POW_[OPTIONAL ID]:### - Indicates a power variable for the heat pump. ### is the power threshold (default 1.0) above which
|
|
727
|
+
the heat pump is considered 'on'.
|
|
728
|
+
HPO_T_[OPTIONAL ID]:### - Indicates heat pump outlet temperature variable. ### is the temperature threshold (default 140.0)
|
|
729
|
+
that should always be exceeded while the heat pump is on after the 10-minute warmup period.
|
|
730
|
+
|
|
731
|
+
Parameters
|
|
732
|
+
----------
|
|
733
|
+
df: pd.DataFrame
|
|
734
|
+
Post-transformed dataframe for minute data. It should be noted that this function expects consecutive, in order minutes. If minutes
|
|
735
|
+
are out of order or have gaps, the function may return erroneous alarms.
|
|
736
|
+
daily_df: pd.DataFrame
|
|
737
|
+
Post-transformed dataframe for daily data.
|
|
738
|
+
config : ecopipeline.ConfigManager
|
|
739
|
+
The ConfigManager object that holds configuration data for the pipeline. Among other things, this object will point to a file
|
|
740
|
+
called Variable_Names.csv in the input folder of the pipeline (e.g. "full/path/to/pipeline/input/Variable_Names.csv").
|
|
741
|
+
The file must have at least two columns which must be titled "variable_name" and "alarm_codes" which should contain the
|
|
742
|
+
name of each variable in the dataframe that requires alarming and the HPO alarm codes (e.g., HPO_POW_1:1.0, HPO_T_1:140.0).
|
|
743
|
+
system: str
|
|
744
|
+
String of system name if processing a particular system in a Variable_Names.csv file with multiple systems. Leave as an empty string if not applicable.
|
|
745
|
+
default_power_threshold : float
|
|
746
|
+
Default power threshold for POW alarm codes when no custom bound is specified (default 1.0). Heat pump is considered 'on'
|
|
747
|
+
when power exceeds this value.
|
|
748
|
+
default_temp_threshold : float
|
|
749
|
+
Default temperature threshold for T alarm codes when no custom bound is specified (default 140.0). Alarm triggers when
|
|
750
|
+
temperature falls BELOW this value while heat pump is on (after warmup period).
|
|
751
|
+
fault_time : int
|
|
752
|
+
Number of consecutive minutes that temperature must be below threshold (after warmup) before triggering an alarm (default 5).
|
|
753
|
+
|
|
754
|
+
Returns
|
|
755
|
+
-------
|
|
756
|
+
pd.DataFrame:
|
|
757
|
+
Pandas dataframe with alarm events
|
|
758
|
+
"""
|
|
759
|
+
if df.empty:
|
|
760
|
+
print("cannot flag missing balancing valve alarms. Dataframe is empty")
|
|
761
|
+
return pd.DataFrame()
|
|
762
|
+
variable_names_path = config.get_var_names_path()
|
|
763
|
+
try:
|
|
764
|
+
bounds_df = pd.read_csv(variable_names_path)
|
|
765
|
+
except FileNotFoundError:
|
|
766
|
+
print("File Not Found: ", variable_names_path)
|
|
767
|
+
return pd.DataFrame()
|
|
768
|
+
|
|
769
|
+
bounds_df = _process_bounds_df_alarm_codes(bounds_df, 'HPO',
|
|
770
|
+
{'POW' : default_power_threshold,
|
|
771
|
+
'T' : default_temp_threshold},
|
|
772
|
+
system)
|
|
773
|
+
if bounds_df.empty:
|
|
774
|
+
return _convert_silent_alarm_dict_to_df({}) # no alarms to look into
|
|
775
|
+
|
|
776
|
+
# Process each unique alarm_code_id
|
|
777
|
+
alarms = {}
|
|
778
|
+
for alarm_id in bounds_df['alarm_code_id'].unique():
|
|
779
|
+
for day in daily_df.index:
|
|
780
|
+
next_day = day + pd.Timedelta(days=1)
|
|
781
|
+
filtered_df = df.loc[(df.index >= day) & (df.index < next_day)]
|
|
782
|
+
id_group = bounds_df[bounds_df['alarm_code_id'] == alarm_id]
|
|
783
|
+
pow_codes = id_group[id_group['alarm_code_type'] == 'POW']
|
|
784
|
+
pow_var_name = pow_codes.iloc[0]['variable_name']
|
|
785
|
+
pow_thresh = pow_codes.iloc[0]['bound']
|
|
786
|
+
t_codes = id_group[id_group['alarm_code_type'] == 'T']
|
|
787
|
+
t_var_name = t_codes.iloc[0]['variable_name']
|
|
788
|
+
t_pretty_name = t_codes.iloc[0]['pretty_name']
|
|
789
|
+
t_thresh = t_codes.iloc[0]['bound']
|
|
790
|
+
if len(t_codes) != 1 or len(pow_codes) != 1:
|
|
791
|
+
raise Exception(f"Improper alarm codes for balancing valve with id {alarm_id}")
|
|
792
|
+
if pow_var_name in filtered_df.columns and t_var_name in filtered_df.columns:
|
|
793
|
+
# Check for consecutive minutes where both power and temp exceed thresholds
|
|
794
|
+
power_mask = filtered_df[pow_var_name] > pow_thresh
|
|
795
|
+
temp_mask = filtered_df[t_var_name] < t_thresh
|
|
796
|
+
|
|
797
|
+
# Exclude first 10 minutes after each HP turn-on (warmup period)
|
|
798
|
+
warmup_minutes = 10
|
|
799
|
+
mask_changes = power_mask != power_mask.shift(1)
|
|
800
|
+
run_groups = mask_changes.cumsum()
|
|
801
|
+
cumcount_in_run = power_mask.groupby(run_groups).cumcount() + 1
|
|
802
|
+
past_warmup_mask = power_mask & (cumcount_in_run > warmup_minutes)
|
|
803
|
+
|
|
804
|
+
combined_mask = past_warmup_mask & temp_mask
|
|
805
|
+
|
|
806
|
+
# Check for fault_time consecutive minutes
|
|
807
|
+
consecutive_condition = combined_mask.rolling(window=fault_time).min() == 1
|
|
808
|
+
if consecutive_condition.any():
|
|
809
|
+
first_true_index = consecutive_condition.idxmax()
|
|
810
|
+
adjusted_time = first_true_index - pd.Timedelta(minutes=fault_time-1)
|
|
811
|
+
_add_an_alarm(alarms, day, t_var_name, f"Low heat pump outlet temperature: {t_pretty_name} was below {t_thresh:.1f} while HP was ON starting at {adjusted_time}.")
|
|
812
|
+
|
|
813
|
+
return _convert_silent_alarm_dict_to_df(alarms)
|
|
814
|
+
|
|
815
|
+
def flag_blown_fuse(df: pd.DataFrame, daily_df: pd.DataFrame, config : ConfigManager, system: str = "", default_power_threshold : float = 1.0,
|
|
816
|
+
default_power_range : float = 2.0, default_power_draw : float = 30, fault_time : int = 3) -> pd.DataFrame:
|
|
817
|
+
"""
|
|
818
|
+
Detects blown fuse alarms for heating elements by identifying when an element is drawing power
|
|
819
|
+
but significantly less than expected, which may indicate a blown fuse.
|
|
820
|
+
|
|
821
|
+
VarNames syntax:
|
|
822
|
+
BF_[OPTIONAL ID]:### - Indicates a blown fuse alarm for an element. ### is the expected kW input when the element is on.
|
|
823
|
+
|
|
824
|
+
Parameters
|
|
825
|
+
----------
|
|
826
|
+
df: pd.DataFrame
|
|
827
|
+
Post-transformed dataframe for minute data. It should be noted that this function expects consecutive, in order minutes. If minutes
|
|
828
|
+
are out of order or have gaps, the function may return erroneous alarms.
|
|
829
|
+
daily_df: pd.DataFrame
|
|
830
|
+
Post-transformed dataframe for daily data.
|
|
831
|
+
config : ecopipeline.ConfigManager
|
|
832
|
+
The ConfigManager object that holds configuration data for the pipeline. Among other things, this object will point to a file
|
|
833
|
+
called Variable_Names.csv in the input folder of the pipeline (e.g. "full/path/to/pipeline/input/Variable_Names.csv").
|
|
834
|
+
The file must have at least two columns which must be titled "variable_name" and "alarm_codes" which should contain the
|
|
835
|
+
name of each variable in the dataframe that requires alarming and the BF alarm codes (e.g., BF:30, BF_1:25).
|
|
836
|
+
system: str
|
|
837
|
+
String of system name if processing a particular system in a Variable_Names.csv file with multiple systems. Leave as an empty string if not applicable.
|
|
838
|
+
default_power_threshold : float
|
|
839
|
+
Power threshold to determine if the element is "on" (default 1.0). Element is considered on when power exceeds this value.
|
|
840
|
+
default_power_range : float
|
|
841
|
+
Allowable variance below the expected power draw (default 2.0). An alarm triggers when the actual power draw is less than
|
|
842
|
+
(expected_power_draw - default_power_range) while the element is on.
|
|
843
|
+
default_power_draw : float
|
|
844
|
+
Default expected power draw in kW when no custom bound is specified in the alarm code (default 30).
|
|
845
|
+
fault_time : int
|
|
846
|
+
Number of consecutive minutes that the fault condition must persist before triggering an alarm (default 3).
|
|
847
|
+
|
|
848
|
+
Returns
|
|
849
|
+
-------
|
|
850
|
+
pd.DataFrame:
|
|
851
|
+
Pandas dataframe with alarm events
|
|
852
|
+
"""
|
|
853
|
+
if df.empty:
|
|
854
|
+
print("cannot flag missing balancing valve alarms. Dataframe is empty")
|
|
855
|
+
return pd.DataFrame()
|
|
856
|
+
variable_names_path = config.get_var_names_path()
|
|
857
|
+
try:
|
|
858
|
+
bounds_df = pd.read_csv(variable_names_path)
|
|
859
|
+
except FileNotFoundError:
|
|
860
|
+
print("File Not Found: ", variable_names_path)
|
|
861
|
+
return pd.DataFrame()
|
|
862
|
+
|
|
863
|
+
bounds_df = _process_bounds_df_alarm_codes(bounds_df, 'BF',
|
|
864
|
+
{'default' : default_power_draw},
|
|
865
|
+
system, two_part_tag=False)
|
|
866
|
+
if bounds_df.empty:
|
|
867
|
+
return _convert_silent_alarm_dict_to_df({}) # no alarms to look into
|
|
868
|
+
|
|
869
|
+
# Process each unique alarm_code_id
|
|
870
|
+
alarms = {}
|
|
871
|
+
for var_name in bounds_df['variable_name'].unique():
|
|
872
|
+
for day in daily_df.index:
|
|
873
|
+
next_day = day + pd.Timedelta(days=1)
|
|
874
|
+
filtered_df = df.loc[(df.index >= day) & (df.index < next_day)]
|
|
875
|
+
rows = bounds_df[bounds_df['variable_name'] == var_name]
|
|
876
|
+
expected_power_draw = rows.iloc[0]['bound']
|
|
877
|
+
if len(rows) != 1:
|
|
878
|
+
raise Exception(f"Multiple blown fuse alarm codes for {var_name}")
|
|
879
|
+
if var_name in filtered_df.columns:
|
|
880
|
+
# Check for consecutive minutes where both power and temp exceed thresholds
|
|
881
|
+
power_on_mask = filtered_df[var_name] > default_power_threshold
|
|
882
|
+
unexpected_power_mask = filtered_df[var_name] < expected_power_draw - default_power_range
|
|
883
|
+
combined_mask = power_on_mask & unexpected_power_mask
|
|
884
|
+
|
|
885
|
+
# Check for fault_time consecutive minutes
|
|
886
|
+
consecutive_condition = combined_mask.rolling(window=fault_time).min() == 1
|
|
887
|
+
if consecutive_condition.any():
|
|
888
|
+
first_true_index = consecutive_condition.idxmax()
|
|
889
|
+
adjusted_time = first_true_index - pd.Timedelta(minutes=fault_time-1)
|
|
890
|
+
_add_an_alarm(alarms, day, var_name, f"Blown Fuse: {var_name} had a power draw less than {expected_power_draw - default_power_range:.1f} while element was ON starting at {adjusted_time}.")
|
|
891
|
+
|
|
892
|
+
return _convert_silent_alarm_dict_to_df(alarms)
|
|
893
|
+
|
|
894
|
+
def flag_unexpected_soo_change(df: pd.DataFrame, daily_df: pd.DataFrame, config : ConfigManager, system: str = "", default_power_threshold : float = 1.0,
|
|
895
|
+
default_on_temp : float = 115.0, default_off_temp : float = 140.0) -> pd.DataFrame:
|
|
896
|
+
"""
|
|
897
|
+
Detects unexpected state of operation (SOO) changes by checking if the heat pump turns on or off
|
|
898
|
+
when the temperature is not near the expected aquastat setpoint thresholds. An alarm is triggered
|
|
899
|
+
if the HP turns on/off and the corresponding temperature is more than 5.0 degrees away from the
|
|
900
|
+
expected threshold.
|
|
901
|
+
|
|
902
|
+
VarNames syntax:
|
|
903
|
+
SOOCHNG_POW:### - Indicates a power variable for the heat pump system (should be total power across all primary heat pumps). ### is the power threshold (default 1.0) above which
|
|
904
|
+
the heat pump system is considered 'on'.
|
|
905
|
+
SOOCHNG_ON_[Mode ID]:### - Indicates the temperature variable at the ON aquastat fraction. ### is the temperature (default 115.0)
|
|
906
|
+
that should trigger the heat pump to turn ON. Mode ID should be the load up mode from ['loadUp','shed','criticalPeak','gridEmergency','advLoadUp','normal'] or left blank for normal mode
|
|
907
|
+
SOOCHNG_OFF_[Mode ID]:### - Indicates the temperature variable at the OFF aquastat fraction (can be same as ON aquastat). ### is the temperature (default 140.0)
|
|
908
|
+
that should trigger the heat pump to turn OFF. Mode ID should be the load up mode from ['loadUp','shed','criticalPeak','gridEmergency','advLoadUp','normal'] or left blank for normal mode
|
|
909
|
+
|
|
910
|
+
Parameters
|
|
911
|
+
----------
|
|
912
|
+
df: pd.DataFrame
|
|
913
|
+
Post-transformed dataframe for minute data. It should be noted that this function expects consecutive, in order minutes. If minutes
|
|
914
|
+
are out of order or have gaps, the function may return erroneous alarms.
|
|
915
|
+
daily_df: pd.DataFrame
|
|
916
|
+
Post-transformed dataframe for daily data.
|
|
917
|
+
config : ecopipeline.ConfigManager
|
|
918
|
+
The ConfigManager object that holds configuration data for the pipeline. Among other things, this object will point to a file
|
|
919
|
+
called Variable_Names.csv in the input folder of the pipeline (e.g. "full/path/to/pipeline/input/Variable_Names.csv").
|
|
920
|
+
The file must have at least two columns which must be titled "variable_name" and "alarm_codes" which should contain the
|
|
921
|
+
name of each variable in the dataframe that requires alarming and the SOOCHNG alarm codes (e.g., SOOCHNG_POW_normal:1.0, SOOCHNG_ON_normal:115.0, SOOCHNG_OFF_normal:140.0).
|
|
922
|
+
system: str
|
|
923
|
+
String of system name if processing a particular system in a Variable_Names.csv file with multiple systems. Leave as an empty string if not applicable.
|
|
924
|
+
default_power_threshold : float
|
|
925
|
+
Default power threshold for POW alarm codes when no custom bound is specified (default 1.0). Heat pump is considered 'on'
|
|
926
|
+
when power exceeds this value.
|
|
927
|
+
default_on_temp : float
|
|
928
|
+
Default ON temperature threshold (default 115.0). When the HP turns on, an alarm triggers if the temperature
|
|
929
|
+
is more than 5.0 degrees away from this value.
|
|
930
|
+
default_off_temp : float
|
|
931
|
+
Default OFF temperature threshold (default 140.0). When the HP turns off, an alarm triggers if the temperature
|
|
932
|
+
is more than 5.0 degrees away from this value.
|
|
933
|
+
|
|
934
|
+
Returns
|
|
935
|
+
-------
|
|
936
|
+
pd.DataFrame:
|
|
937
|
+
Pandas dataframe with alarm events
|
|
938
|
+
"""
|
|
939
|
+
soo_dict = {
|
|
940
|
+
'loadUp' : 'LOAD UP',
|
|
941
|
+
'shed' : 'SHED',
|
|
942
|
+
'criticalPeak': 'CRITICAL PEAK',
|
|
943
|
+
'gridEmergency' : 'GRID EMERGENCY',
|
|
944
|
+
'advLoadUp' : 'ADVANCED LOAD UP'
|
|
945
|
+
}
|
|
946
|
+
if df.empty:
|
|
947
|
+
print("cannot flag missing balancing valve alarms. Dataframe is empty")
|
|
948
|
+
return pd.DataFrame()
|
|
949
|
+
variable_names_path = config.get_var_names_path()
|
|
950
|
+
try:
|
|
951
|
+
bounds_df = pd.read_csv(variable_names_path)
|
|
952
|
+
except FileNotFoundError:
|
|
953
|
+
print("File Not Found: ", variable_names_path)
|
|
954
|
+
return pd.DataFrame()
|
|
955
|
+
|
|
956
|
+
bounds_df = _process_bounds_df_alarm_codes(bounds_df, 'SOOCHNG',
|
|
957
|
+
{'POW' : default_power_threshold,
|
|
958
|
+
'ON' : default_on_temp,
|
|
959
|
+
'OFF' : default_off_temp},
|
|
960
|
+
system)
|
|
961
|
+
if bounds_df.empty:
|
|
962
|
+
return _convert_silent_alarm_dict_to_df({}) # no alarms to look into
|
|
963
|
+
|
|
964
|
+
ls_df = config.get_ls_df()
|
|
965
|
+
|
|
966
|
+
# Process each unique alarm_code_id
|
|
967
|
+
alarms = {}
|
|
968
|
+
pow_codes = bounds_df[bounds_df['alarm_code_type'] == 'POW']
|
|
969
|
+
if len(pow_codes) != 1:
|
|
970
|
+
raise Exception(f"Improper alarm codes for SOO changes; must have 1 POW variable to indicate power to HPWH(s).")
|
|
971
|
+
pow_var_name = pow_codes.iloc[0]['variable_name']
|
|
972
|
+
pow_thresh = pow_codes.iloc[0]['bound']
|
|
973
|
+
bounds_df = bounds_df[bounds_df['alarm_code_type'] != 'POW']
|
|
974
|
+
|
|
975
|
+
for alarm_id in bounds_df['alarm_code_id'].unique():
|
|
976
|
+
ls_filtered_df = df.copy()
|
|
977
|
+
soo_mode_name = 'NORMAL'
|
|
978
|
+
if alarm_id in soo_dict.keys():
|
|
979
|
+
if not ls_df.empty:
|
|
980
|
+
# Filter ls_filtered_df for only date ranges in the right mode of ls_df
|
|
981
|
+
mode_rows = ls_df[ls_df['event'] == alarm_id]
|
|
982
|
+
mask = pd.Series(False, index=ls_filtered_df.index)
|
|
983
|
+
for _, row in mode_rows.iterrows():
|
|
984
|
+
mask |= (ls_filtered_df.index >= row['startDateTime']) & (ls_filtered_df.index < row['endDateTime'])
|
|
985
|
+
ls_filtered_df = ls_filtered_df[mask]
|
|
986
|
+
soo_mode_name = soo_dict[alarm_id]
|
|
987
|
+
else:
|
|
988
|
+
print(f"Cannot check for {alarm_id} because there are no {alarm_id} periods in time frame.")
|
|
989
|
+
continue
|
|
990
|
+
elif not ls_df.empty:
|
|
991
|
+
# Filter out all date range rows from ls_filtered_df's indexes
|
|
992
|
+
mask = pd.Series(True, index=ls_filtered_df.index)
|
|
993
|
+
for _, row in ls_df.iterrows():
|
|
994
|
+
mask &= ~((ls_filtered_df.index >= row['startDateTime']) & (ls_filtered_df.index < row['endDateTime']))
|
|
995
|
+
ls_filtered_df = ls_filtered_df[mask]
|
|
996
|
+
|
|
997
|
+
for day in daily_df.index:
|
|
998
|
+
next_day = day + pd.Timedelta(days=1)
|
|
999
|
+
filtered_df = ls_filtered_df.loc[(ls_filtered_df.index >= day) & (ls_filtered_df.index < next_day)]
|
|
1000
|
+
id_group = bounds_df[bounds_df['alarm_code_id'] == alarm_id]
|
|
1001
|
+
on_t_codes = id_group[id_group['alarm_code_type'] == 'ON']
|
|
1002
|
+
off_t_codes = id_group[id_group['alarm_code_type'] == 'ON']
|
|
1003
|
+
if len(on_t_codes) != 1 or len(off_t_codes) != 1:
|
|
1004
|
+
raise Exception(f"Improper alarm codes for SOO changes with id {alarm_id}. Must have 1 ON and 1 OFF variable")
|
|
1005
|
+
on_t_var_name = on_t_codes.iloc[0]['variable_name']
|
|
1006
|
+
on_t_pretty_name = on_t_codes.iloc[0]['pretty_name']
|
|
1007
|
+
on_t_thresh = on_t_codes.iloc[0]['bound']
|
|
1008
|
+
off_t_var_name = off_t_codes.iloc[0]['variable_name']
|
|
1009
|
+
off_t_pretty_name = off_t_codes.iloc[0]['pretty_name']
|
|
1010
|
+
off_t_thresh = off_t_codes.iloc[0]['bound']
|
|
1011
|
+
if pow_var_name in filtered_df.columns:
|
|
1012
|
+
found_alarm = False
|
|
1013
|
+
power_below = filtered_df[pow_var_name] <= pow_thresh
|
|
1014
|
+
power_above = filtered_df[pow_var_name] > pow_thresh
|
|
1015
|
+
if on_t_var_name in filtered_df.columns:
|
|
1016
|
+
power_turn_on = power_below.shift(1) & power_above
|
|
1017
|
+
power_on_times = filtered_df.index[power_turn_on.fillna(False)]
|
|
1018
|
+
# Check if temperature is within 5.0 of on_t_thresh at each turn-on moment
|
|
1019
|
+
for power_time in power_on_times:
|
|
1020
|
+
temp_at_turn_on = filtered_df.loc[power_time, on_t_var_name]
|
|
1021
|
+
if abs(temp_at_turn_on - on_t_thresh) > 5.0:
|
|
1022
|
+
_add_an_alarm(alarms, day, on_t_var_name,
|
|
1023
|
+
f"Unexpected SOO change: during {soo_mode_name}, HP turned on at {power_time} but {on_t_pretty_name} was {temp_at_turn_on:.1f} F (setpoint at {on_t_thresh} F).")
|
|
1024
|
+
found_alarm = True
|
|
1025
|
+
break # TODO soon don't do this
|
|
1026
|
+
if not found_alarm and off_t_var_name in filtered_df.columns:
|
|
1027
|
+
power_turn_off = power_above.shift(1) & power_below
|
|
1028
|
+
power_off_times = filtered_df.index[power_turn_off.fillna(False)]
|
|
1029
|
+
# Check if temperature is within 5.0 of off_t_thresh at each turn-on moment
|
|
1030
|
+
for power_time in power_off_times:
|
|
1031
|
+
temp_at_turn_off = filtered_df.loc[power_time, off_t_var_name]
|
|
1032
|
+
if abs(temp_at_turn_off - off_t_thresh) > 5.0:
|
|
1033
|
+
_add_an_alarm(alarms, day, off_t_var_name,
|
|
1034
|
+
f"Unexpected SOO change: during {soo_mode_name}, HP turned off at {power_time} but {off_t_pretty_name} was {temp_at_turn_off:.1f} F (setpoint at {off_t_thresh} F)).")
|
|
1035
|
+
found_alarm = True
|
|
1036
|
+
break # TODO soon don't do this
|
|
1037
|
+
|
|
1038
|
+
return _convert_silent_alarm_dict_to_df(alarms)
|
|
1039
|
+
|
|
1040
|
+
def flag_ls_mode_inconsistancy(df: pd.DataFrame, daily_df: pd.DataFrame, config : ConfigManager, system: str = "") -> pd.DataFrame:
|
|
1041
|
+
"""
|
|
1042
|
+
Detects when a variable does not match its expected value during a load shifting event.
|
|
1043
|
+
An alarm is triggered if the variable value does not equal the expected value during the
|
|
1044
|
+
time periods defined in the load shifting schedule for that mode.
|
|
1045
|
+
|
|
1046
|
+
VarNames syntax:
|
|
1047
|
+
SOO_[mode]:### - Indicates a variable that should equal ### during [mode] load shifting events.
|
|
1048
|
+
[mode] can be: loadUp, shed, criticalPeak, gridEmergency, advLoadUp
|
|
1049
|
+
### is the expected value (e.g., SOO_loadUp:1 means the variable should be 1 during loadUp events)
|
|
1050
|
+
|
|
1051
|
+
Parameters
|
|
1052
|
+
----------
|
|
1053
|
+
df: pd.DataFrame
|
|
1054
|
+
Post-transformed dataframe for minute data. It should be noted that this function expects consecutive,
|
|
1055
|
+
in order minutes. If minutes are out of order or have gaps, the function may return erroneous alarms.
|
|
1056
|
+
daily_df: pd.DataFrame
|
|
1057
|
+
Pandas dataframe with daily data. This dataframe should have a datetime index.
|
|
1058
|
+
config : ecopipeline.ConfigManager
|
|
1059
|
+
The ConfigManager object that holds configuration data for the pipeline.
|
|
1060
|
+
system: str
|
|
1061
|
+
String of system name if processing a particular system in a Variable_Names.csv file with multiple systems.
|
|
1062
|
+
|
|
1063
|
+
Returns
|
|
1064
|
+
-------
|
|
1065
|
+
pd.DataFrame:
|
|
1066
|
+
Pandas dataframe with alarm events
|
|
1067
|
+
"""
|
|
1068
|
+
if df.empty:
|
|
1069
|
+
print("cannot flag load shift mode inconsistency alarms. Dataframe is empty")
|
|
1070
|
+
return pd.DataFrame()
|
|
1071
|
+
variable_names_path = config.get_var_names_path()
|
|
1072
|
+
try:
|
|
1073
|
+
bounds_df = pd.read_csv(variable_names_path)
|
|
1074
|
+
except FileNotFoundError:
|
|
1075
|
+
print("File Not Found: ", variable_names_path)
|
|
1076
|
+
return pd.DataFrame()
|
|
1077
|
+
|
|
1078
|
+
bounds_df = _process_bounds_df_alarm_codes(bounds_df, 'SOO', {}, system)
|
|
1079
|
+
if bounds_df.empty:
|
|
1080
|
+
return _convert_silent_alarm_dict_to_df({}) # no alarms to look into
|
|
1081
|
+
|
|
1082
|
+
ls_df = config.get_ls_df()
|
|
1083
|
+
if ls_df.empty:
|
|
1084
|
+
return _convert_silent_alarm_dict_to_df({}) # no load shifting events to check
|
|
1085
|
+
|
|
1086
|
+
valid_modes = ['loadUp', 'shed', 'criticalPeak', 'gridEmergency', 'advLoadUp']
|
|
1087
|
+
|
|
1088
|
+
alarms = {}
|
|
1089
|
+
for _, row in bounds_df.iterrows():
|
|
1090
|
+
mode = row['alarm_code_type']
|
|
1091
|
+
if mode not in valid_modes and mode != 'normal':
|
|
1092
|
+
continue
|
|
1093
|
+
|
|
1094
|
+
var_name = row['variable_name']
|
|
1095
|
+
pretty_name = row['pretty_name']
|
|
1096
|
+
expected_value = row['bound']
|
|
1097
|
+
|
|
1098
|
+
if var_name not in df.columns:
|
|
1099
|
+
continue
|
|
1100
|
+
|
|
1101
|
+
for day in daily_df.index:
|
|
1102
|
+
next_day = day + pd.Timedelta(days=1)
|
|
1103
|
+
filtered_df = df.loc[(df.index >= day) & (df.index < next_day)]
|
|
1104
|
+
|
|
1105
|
+
if filtered_df.empty:
|
|
1106
|
+
continue
|
|
1107
|
+
|
|
1108
|
+
if mode == 'normal':
|
|
1109
|
+
# For 'normal' mode, check periods NOT covered by any load shifting events
|
|
1110
|
+
normal_df = filtered_df.copy()
|
|
1111
|
+
if not ls_df.empty:
|
|
1112
|
+
mask = pd.Series(True, index=normal_df.index)
|
|
1113
|
+
for _, event_row in ls_df.iterrows():
|
|
1114
|
+
event_start = event_row['startDateTime']
|
|
1115
|
+
event_end = event_row['endDateTime']
|
|
1116
|
+
mask &= ~((normal_df.index >= event_start) & (normal_df.index < event_end))
|
|
1117
|
+
normal_df = normal_df[mask]
|
|
1118
|
+
|
|
1119
|
+
if normal_df.empty:
|
|
1120
|
+
continue
|
|
1121
|
+
|
|
1122
|
+
# Check if any values don't match the expected value during normal periods
|
|
1123
|
+
mismatched = normal_df[normal_df[var_name] != expected_value]
|
|
1124
|
+
|
|
1125
|
+
if not mismatched.empty:
|
|
1126
|
+
first_mismatch_time = mismatched.index[0]
|
|
1127
|
+
actual_value = mismatched.iloc[0][var_name]
|
|
1128
|
+
_add_an_alarm(alarms, day, var_name,
|
|
1129
|
+
f"Load shift mode inconsistency: {pretty_name} was {actual_value} at {first_mismatch_time} during normal operation (expected {expected_value}).")
|
|
1130
|
+
else:
|
|
1131
|
+
# For load shifting modes, check periods covered by those specific events
|
|
1132
|
+
mode_events = ls_df[ls_df['event'] == mode]
|
|
1133
|
+
if mode_events.empty:
|
|
1134
|
+
continue
|
|
1135
|
+
|
|
1136
|
+
# Check each load shifting event for this mode on this day
|
|
1137
|
+
for _, event_row in mode_events.iterrows():
|
|
1138
|
+
event_start = event_row['startDateTime']
|
|
1139
|
+
event_end = event_row['endDateTime']
|
|
1140
|
+
|
|
1141
|
+
# Filter for data during this event
|
|
1142
|
+
event_df = filtered_df.loc[(filtered_df.index >= event_start) & (filtered_df.index < event_end)]
|
|
1143
|
+
|
|
1144
|
+
if event_df.empty:
|
|
1145
|
+
continue
|
|
1146
|
+
|
|
1147
|
+
# Check if any values don't match the expected value
|
|
1148
|
+
mismatched = event_df[event_df[var_name] != expected_value]
|
|
1149
|
+
|
|
1150
|
+
if not mismatched.empty:
|
|
1151
|
+
first_mismatch_time = mismatched.index[0]
|
|
1152
|
+
actual_value = mismatched.iloc[0][var_name]
|
|
1153
|
+
_add_an_alarm(alarms, day, var_name,
|
|
1154
|
+
f"Load shift mode inconsistency: {pretty_name} was {actual_value} at {first_mismatch_time} during {mode} event (expected {expected_value}).")
|
|
1155
|
+
break # Only one alarm per variable per day
|
|
1156
|
+
|
|
1157
|
+
return _convert_silent_alarm_dict_to_df(alarms)
|
|
1158
|
+
|
|
1159
|
+
def flag_unexpected_temp(df: pd.DataFrame, daily_df: pd.DataFrame, config : ConfigManager, system: str = "", default_high_temp : float = 130,
|
|
1160
|
+
default_low_temp : float = 115, fault_time : int = 10) -> pd.DataFrame:
|
|
1161
|
+
"""
|
|
1162
|
+
Detects when domestic hot water (DHW) supply temperature falls outside an acceptable range for
|
|
1163
|
+
too long. An alarm is triggered if the temperature is above the high bound or below the low bound
|
|
1164
|
+
for `fault_time` consecutive minutes.
|
|
1165
|
+
|
|
1166
|
+
VarNames syntax:
|
|
1167
|
+
TMPRNG_[OPTIONAL ID]:###-### - Indicates a temperature variable. ###-### is the acceptable temperature range
|
|
1168
|
+
(e.g., TMPRNG:110-130 means temperature should stay between 110 and 130 degrees).
|
|
1169
|
+
|
|
1170
|
+
Parameters
|
|
1171
|
+
----------
|
|
1172
|
+
df: pd.DataFrame
|
|
1173
|
+
Post-transformed dataframe for minute data. It should be noted that this function expects consecutive, in order minutes. If minutes
|
|
1174
|
+
are out of order or have gaps, the function may return erroneous alarms.
|
|
1175
|
+
daily_df: pd.DataFrame
|
|
1176
|
+
Post-transformed dataframe for daily data. Used for determining which days to process.
|
|
1177
|
+
config : ecopipeline.ConfigManager
|
|
1178
|
+
The ConfigManager object that holds configuration data for the pipeline. Among other things, this object will point to a file
|
|
1179
|
+
called Variable_Names.csv in the input folder of the pipeline (e.g. "full/path/to/pipeline/input/Variable_Names.csv").
|
|
1180
|
+
The file must have at least two columns which must be titled "variable_name" and "alarm_codes" which should contain the
|
|
1181
|
+
name of each variable in the dataframe that requires alarming and the DHW alarm codes (e.g., DHW:110-130, DHW_1:115-125).
|
|
1182
|
+
system: str
|
|
1183
|
+
String of system name if processing a particular system in a Variable_Names.csv file with multiple systems. Leave as an empty string if not applicable.
|
|
1184
|
+
default_high_temp : float
|
|
1185
|
+
Default high temperature bound when no custom range is specified in the alarm code (default 130). Temperature above this triggers alarm.
|
|
1186
|
+
default_low_temp : float
|
|
1187
|
+
Default low temperature bound when no custom range is specified in the alarm code (default 130). Temperature below this triggers alarm.
|
|
1188
|
+
fault_time : int
|
|
1189
|
+
Number of consecutive minutes that temperature must be outside the acceptable range before triggering an alarm (default 10).
|
|
1190
|
+
|
|
1191
|
+
Returns
|
|
1192
|
+
-------
|
|
1193
|
+
pd.DataFrame:
|
|
1194
|
+
Pandas dataframe with alarm events
|
|
1195
|
+
"""
|
|
1196
|
+
if df.empty:
|
|
1197
|
+
print("cannot flag missing balancing valve alarms. Dataframe is empty")
|
|
1198
|
+
return pd.DataFrame()
|
|
1199
|
+
variable_names_path = config.get_var_names_path()
|
|
1200
|
+
try:
|
|
1201
|
+
bounds_df = pd.read_csv(variable_names_path)
|
|
1202
|
+
except FileNotFoundError:
|
|
1203
|
+
print("File Not Found: ", variable_names_path)
|
|
1204
|
+
return pd.DataFrame()
|
|
1205
|
+
|
|
1206
|
+
bounds_df = _process_bounds_df_alarm_codes(bounds_df, 'TMPRNG',
|
|
1207
|
+
{'default': [default_low_temp,default_high_temp]},
|
|
1208
|
+
system, two_part_tag=False,
|
|
1209
|
+
range_bounds=True)
|
|
1210
|
+
if bounds_df.empty:
|
|
1211
|
+
return _convert_silent_alarm_dict_to_df({}) # no alarms to look into
|
|
1212
|
+
|
|
1213
|
+
# Process each unique alarm_code_id
|
|
1214
|
+
alarms = {}
|
|
1215
|
+
for dhw_var in bounds_df['variable_name'].unique():
|
|
1216
|
+
for day in daily_df.index:
|
|
1217
|
+
next_day = day + pd.Timedelta(days=1)
|
|
1218
|
+
filtered_df = df.loc[(df.index >= day) & (df.index < next_day)]
|
|
1219
|
+
rows = bounds_df[bounds_df['variable_name'] == dhw_var]
|
|
1220
|
+
low_bound = rows.iloc[0]['bound']
|
|
1221
|
+
high_bound = rows.iloc[0]['bound2']
|
|
1222
|
+
pretty_name = rows.iloc[0]['pretty_name']
|
|
1223
|
+
|
|
1224
|
+
if dhw_var in filtered_df.columns:
|
|
1225
|
+
# Check if temp is above high bound or below low bound
|
|
1226
|
+
out_of_range_mask = (filtered_df[dhw_var] > high_bound) | (filtered_df[dhw_var] < low_bound)
|
|
1227
|
+
|
|
1228
|
+
# Check for fault_time consecutive minutes
|
|
1229
|
+
consecutive_condition = out_of_range_mask.rolling(window=fault_time).min() == 1
|
|
1230
|
+
if consecutive_condition.any():
|
|
1231
|
+
first_true_index = consecutive_condition.idxmax()
|
|
1232
|
+
adjusted_time = first_true_index - pd.Timedelta(minutes=fault_time-1)
|
|
1233
|
+
_add_an_alarm(alarms, day, dhw_var,
|
|
1234
|
+
f"Temperature out of range: {pretty_name} was outside {low_bound}-{high_bound} F for {fault_time}+ consecutive minutes starting at {adjusted_time}.")
|
|
1235
|
+
|
|
1236
|
+
return _convert_silent_alarm_dict_to_df(alarms)
|
|
1237
|
+
|
|
1238
|
+
def flag_shortcycle(df: pd.DataFrame, daily_df: pd.DataFrame, config : ConfigManager, system: str = "", default_power_threshold : float = 1.0,
|
|
1239
|
+
short_cycle_time : int = 15) -> pd.DataFrame:
|
|
1240
|
+
"""
|
|
1241
|
+
Detects short cycling by identifying when the heat pump turns on for less than `short_cycle_time`
|
|
1242
|
+
consecutive minutes before turning off again. Short cycling can indicate equipment issues or
|
|
1243
|
+
improper system sizing.
|
|
1244
|
+
|
|
1245
|
+
VarNames syntax:
|
|
1246
|
+
SHRTCYC_[OPTIONAL ID]:### - Indicates a power variable for the heat pump. ### is the power threshold (default 1.0) above which
|
|
1247
|
+
the heat pump is considered 'on'.
|
|
1248
|
+
|
|
1249
|
+
Parameters
|
|
1250
|
+
----------
|
|
1251
|
+
df: pd.DataFrame
|
|
1252
|
+
Post-transformed dataframe for minute data. It should be noted that this function expects consecutive, in order minutes. If minutes
|
|
1253
|
+
are out of order or have gaps, the function may return erroneous alarms.
|
|
1254
|
+
daily_df: pd.DataFrame
|
|
1255
|
+
Post-transformed dataframe for daily data.
|
|
1256
|
+
config : ecopipeline.ConfigManager
|
|
1257
|
+
The ConfigManager object that holds configuration data for the pipeline. Among other things, this object will point to a file
|
|
1258
|
+
called Variable_Names.csv in the input folder of the pipeline (e.g. "full/path/to/pipeline/input/Variable_Names.csv").
|
|
1259
|
+
The file must have at least two columns which must be titled "variable_name" and "alarm_codes" which should contain the
|
|
1260
|
+
name of each variable in the dataframe that requires alarming and the SHRTCYC alarm codes (e.g., SHRTCYC:1.0, SHRTCYC_1:0.5).
|
|
1261
|
+
system: str
|
|
1262
|
+
String of system name if processing a particular system in a Variable_Names.csv file with multiple systems. Leave as an empty string if not applicable.
|
|
1263
|
+
default_power_threshold : float
|
|
1264
|
+
Default power threshold when no custom bound is specified in the alarm code (default 1.0). Heat pump is considered 'on'
|
|
1265
|
+
when power exceeds this value.
|
|
1266
|
+
short_cycle_time : int
|
|
1267
|
+
Minimum expected run time in minutes (default 15). An alarm triggers if the heat pump runs for fewer than this many
|
|
1268
|
+
consecutive minutes before turning off.
|
|
1269
|
+
|
|
1270
|
+
Returns
|
|
1271
|
+
-------
|
|
1272
|
+
pd.DataFrame:
|
|
1273
|
+
Pandas dataframe with alarm events
|
|
1274
|
+
"""
|
|
1275
|
+
if df.empty:
|
|
1276
|
+
print("cannot flag missing balancing valve alarms. Dataframe is empty")
|
|
1277
|
+
return pd.DataFrame()
|
|
1278
|
+
variable_names_path = config.get_var_names_path()
|
|
1279
|
+
try:
|
|
1280
|
+
bounds_df = pd.read_csv(variable_names_path)
|
|
1281
|
+
except FileNotFoundError:
|
|
1282
|
+
print("File Not Found: ", variable_names_path)
|
|
1283
|
+
return pd.DataFrame()
|
|
1284
|
+
|
|
1285
|
+
bounds_df = _process_bounds_df_alarm_codes(bounds_df, 'SHRTCYC',
|
|
1286
|
+
{'default' : default_power_threshold},
|
|
1287
|
+
system, two_part_tag=False)
|
|
1288
|
+
if bounds_df.empty:
|
|
1289
|
+
return _convert_silent_alarm_dict_to_df({}) # no alarms to look into
|
|
1290
|
+
|
|
1291
|
+
# Process each unique alarm_code_id
|
|
1292
|
+
alarms = {}
|
|
1293
|
+
for var_name in bounds_df['variable_name'].unique():
|
|
1294
|
+
for day in daily_df.index:
|
|
1295
|
+
next_day = day + pd.Timedelta(days=1)
|
|
1296
|
+
filtered_df = df.loc[(df.index >= day) & (df.index < next_day)]
|
|
1297
|
+
rows = bounds_df[bounds_df['variable_name'] == var_name]
|
|
1298
|
+
pwr_thresh = rows.iloc[0]['bound']
|
|
1299
|
+
var_pretty = rows.iloc[0]['pretty_name']
|
|
1300
|
+
if len(rows) != 1:
|
|
1301
|
+
raise Exception(f"Multiple blown fuse alarm codes for {var_name}")
|
|
1302
|
+
if var_name in filtered_df.columns:
|
|
1303
|
+
power_on_mask = filtered_df[var_name] > pwr_thresh
|
|
1304
|
+
|
|
1305
|
+
# Find runs of consecutive True values by detecting changes in the mask
|
|
1306
|
+
mask_changes = power_on_mask != power_on_mask.shift(1)
|
|
1307
|
+
run_groups = mask_changes.cumsum()
|
|
1308
|
+
|
|
1309
|
+
# For each run where power is on, check if it's shorter than short_cycle_time
|
|
1310
|
+
for group_id in run_groups[power_on_mask].unique():
|
|
1311
|
+
run_indices = filtered_df.index[(run_groups == group_id) & power_on_mask]
|
|
1312
|
+
run_length = len(run_indices)
|
|
1313
|
+
if run_length > 0 and run_length < short_cycle_time:
|
|
1314
|
+
start_time = run_indices[0]
|
|
1315
|
+
_add_an_alarm(alarms, day, var_name,
|
|
1316
|
+
f"Short cycle: {var_pretty} was on for only {run_length} minutes starting at {start_time}.")
|
|
1317
|
+
break
|
|
1318
|
+
|
|
1319
|
+
return _convert_silent_alarm_dict_to_df(alarms)
|
|
1320
|
+
|
|
1321
|
+
def _process_bounds_df_alarm_codes(bounds_df : pd.DataFrame, alarm_tag : str, type_default_dict : dict = {}, system : str = "",
|
|
1322
|
+
two_part_tag : bool = True, range_bounds : bool = False) -> pd.DataFrame:
|
|
477
1323
|
# Should only do for alarm codes of format: [TAG]_[TYPE]_[OPTIONAL_ID]:[BOUND]
|
|
478
1324
|
if (system != ""):
|
|
479
1325
|
if not 'system' in bounds_df.columns:
|
|
@@ -511,10 +1357,20 @@ def _process_bounds_df_alarm_codes(bounds_df : pd.DataFrame, alarm_tag : str, ty
|
|
|
511
1357
|
tag_parts = tag_code.split(':')
|
|
512
1358
|
if len(tag_parts) > 2:
|
|
513
1359
|
raise Exception(f"Improperly formated alarm code : {tag_code}")
|
|
514
|
-
|
|
1360
|
+
if range_bounds:
|
|
1361
|
+
bounds = tag_parts[1]
|
|
1362
|
+
bound_range = bounds.split('-')
|
|
1363
|
+
if len(bound_range) != 2:
|
|
1364
|
+
raise Exception(f"Improperly formated alarm code : {tag_code}. Expected bound range in form '[number]-[number]' but recieved '{bounds}'.")
|
|
1365
|
+
new_row['bound'] = bound_range[0]
|
|
1366
|
+
new_row['bound2'] = bound_range[1]
|
|
1367
|
+
else:
|
|
1368
|
+
new_row['bound'] = tag_parts[1]
|
|
515
1369
|
tag_code = tag_parts[0]
|
|
516
1370
|
else:
|
|
517
1371
|
new_row['bound'] = None
|
|
1372
|
+
if range_bounds:
|
|
1373
|
+
new_row['bound2'] = None
|
|
518
1374
|
new_row['alarm_codes'] = tag_code
|
|
519
1375
|
|
|
520
1376
|
expanded_rows.append(new_row)
|
|
@@ -527,12 +1383,20 @@ def _process_bounds_df_alarm_codes(bounds_df : pd.DataFrame, alarm_tag : str, ty
|
|
|
527
1383
|
alarm_code_parts = []
|
|
528
1384
|
for idx, row in bounds_df.iterrows():
|
|
529
1385
|
parts = row['alarm_codes'].split('_')
|
|
530
|
-
if
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
1386
|
+
if two_part_tag:
|
|
1387
|
+
if len(parts) == 2:
|
|
1388
|
+
alarm_code_parts.append([parts[1], "No ID"])
|
|
1389
|
+
elif len(parts) == 3:
|
|
1390
|
+
alarm_code_parts.append([parts[1], parts[2]])
|
|
1391
|
+
else:
|
|
1392
|
+
raise Exception(f"improper {alarm_tag} alarm code format for {row['variable_name']}")
|
|
534
1393
|
else:
|
|
535
|
-
|
|
1394
|
+
if len(parts) == 1:
|
|
1395
|
+
alarm_code_parts.append(["default", "No ID"])
|
|
1396
|
+
elif len(parts) == 2:
|
|
1397
|
+
alarm_code_parts.append(["default", parts[1]])
|
|
1398
|
+
else:
|
|
1399
|
+
raise Exception(f"improper {alarm_tag} alarm code format for {row['variable_name']}")
|
|
536
1400
|
if alarm_code_parts:
|
|
537
1401
|
bounds_df[['alarm_code_type', 'alarm_code_id']] = pd.DataFrame(alarm_code_parts, index=bounds_df.index)
|
|
538
1402
|
|
|
@@ -540,9 +1404,16 @@ def _process_bounds_df_alarm_codes(bounds_df : pd.DataFrame, alarm_tag : str, ty
|
|
|
540
1404
|
for idx, row in bounds_df.iterrows():
|
|
541
1405
|
if pd.isna(row['bound']) or row['bound'] is None:
|
|
542
1406
|
if row['alarm_code_type'] in type_default_dict.keys():
|
|
543
|
-
|
|
1407
|
+
if range_bounds:
|
|
1408
|
+
bounds_df.at[idx, 'bound'] = type_default_dict[row['alarm_code_type']][0]
|
|
1409
|
+
bounds_df.at[idx, 'bound2'] = type_default_dict[row['alarm_code_type']][1]
|
|
1410
|
+
else:
|
|
1411
|
+
bounds_df.at[idx, 'bound'] = type_default_dict[row['alarm_code_type']]
|
|
544
1412
|
# Coerce bound column to float
|
|
545
1413
|
bounds_df['bound'] = pd.to_numeric(bounds_df['bound'], errors='coerce').astype(float)
|
|
1414
|
+
if range_bounds:
|
|
1415
|
+
bounds_df['bound2'] = pd.to_numeric(bounds_df['bound2'], errors='coerce').astype(float)
|
|
1416
|
+
|
|
546
1417
|
return bounds_df
|
|
547
1418
|
|
|
548
1419
|
def _add_an_alarm(alarm_dict : dict, day : datetime, var_name : str, alarm_string : str):
|