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.
@@ -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['swing tank setpoint'] = flag_high_swing_setpoint(df, daily_data, config, system=system)
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 flag_high_swing_setpoint(df: pd.DataFrame, daily_df: pd.DataFrame, config : ConfigManager, default_fault_time : int = 3,
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
- STS_T_ID:### - Swing Tank Outlet Temperature. Alarm triggered if over number ### (or 130) for 3 minutes with power on
195
- STS_SP_ID:### - Swing Tank Power. ### is lowest recorded power for Swing Tank to be considered 'on'. Defaults to 1.0
196
- STS_TP_ID:### - Total System Power for ratio alarming for alarming if swing tank power is more than ### (40% default) of usage
197
- STS_ST_ID:### - Swing Tank Setpoint that should not change at all from ### (default 130)
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 STS alarm codes (e.g., STS_T_1:140, STS_SP_1:2.0)
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, 'STS',
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 swing tank setpoint: Swing tank was powered at {adjusted_time} although temperature was above {t_setpoint}.")
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"Swing tank setpoint was altered at {adjusted_time}")
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 swing tank power ratio: Swing tank accounted for more than {tp_ratio * 100}% of daily power.")
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 flag_recirc_balance_valve(daily_df: pd.DataFrame, config : ConfigManager, system: str = "", default_power_ratio : float = 0.4) -> pd.DataFrame:
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
- BV_ER_[OPTIONAL ID] : Indicates a power variable for an ER heater (equipment recirculation)
324
- BV_OUT_[OPTIONAL ID]:### - Indicates the heating output variable the ER heating contributes to. Optional ### for the percentage
325
- threshold that should not be crossed by the ER elements (default 0.4 for 40%)
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 recirculation balance by comparing sum of ER equipment
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 BV alarm codes (e.g., BV_ER_1, BV_OUT_1:0.5)
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 OUT alarm codes when no custom bound is specified (default 0.4).
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
- {'OUT' : default_power_ratio},
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
- out_var_name = out_codes.iloc[0]['variable_name']
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(out_codes) > 1 or len(er_codes) < 1:
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
- for day in daily_df.index:
373
- if out_var_name in daily_df.columns:
374
- # Get list of ER variable names
375
- er_var_names = er_codes['variable_name'].tolist()
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
- out_value = daily_df.loc[day, out_var_name]
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 >= out_value*out_bound:
385
- _add_an_alarm(alarms, day, out_var_name, f"Recirculation imbalance: Sum of recirculation equipment ({er_sum:.2f}) exceeds or equals {(out_bound * 100):.2f}% of heating output.")
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 _process_bounds_df_alarm_codes(bounds_df : pd.DataFrame, alarm_tag : str, type_default_dict : dict = {}, system : str = "") -> pd.DataFrame:
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
- new_row['bound'] = tag_parts[1]
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 len(parts) == 2:
531
- alarm_code_parts.append([parts[1], "No ID"])
532
- elif len(parts) == 3:
533
- alarm_code_parts.append([parts[1], parts[2]])
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
- raise Exception(f"improper STS alarm code format for {row['variable_name']}")
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
- bounds_df.at[idx, 'bound'] = type_default_dict[row['alarm_code_type']]
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):