diff --git a/bin/run_external_control.py b/bin/run_external_control.py index 1b478b9..afab2e3 100644 --- a/bin/run_external_control.py +++ b/bin/run_external_control.py @@ -7,11 +7,15 @@ # Test script to run single Dwelling with constant external control signal dwelling_args.update({ + 'time_res': dt.timedelta(minutes=10), 'ext_time_res': dt.timedelta(minutes=60), # for duty cycle control only }) example_control_signal = { - 'HVAC Heating': {'Setpoint': 19}, # in C + 'HVAC Heating': {'Setpoint': 19, + # 'Max Capacity Fraction': 0.8, + 'Max ER Capacity Fraction': 0.5, + }, # in C 'HVAC Cooling': {'Setpoint': 22}, # in C 'Water Heating': {'Setpoint': 50}, # in C 'PV': {'P Setpoint': -1.1, 'Q Setpoint': 0.5}, # in kW, kVAR @@ -36,30 +40,31 @@ def run_with_schedule_control(): dwelling.simulate() -def run_constant_control_signal(control_signal): +def run_constant_control_signal(control_signal=None): # Initialization - dwelling = Dwelling(name='Test House with Controller', **dwelling_args) + dwelling = Dwelling(name='OCHRE with Controller', **dwelling_args) # Simulation for t in dwelling.sim_times: - assert dwelling.current_time == t + # assert dwelling.current_time == t house_status = dwelling.update(control_signal=control_signal) - return dwelling.finalize() + df, _, _ = dwelling.finalize() def get_hvac_controls(hour_of_day, occupancy, heating_setpoint, **unused_inputs): # Use some of the controller_inputs to determine setpoints (or other control signals) if 14 <= hour_of_day < 20: # 2PM-8PM - if occupancy > 0: - heating_setpoint -= 1 # reduce setpoint by 1 degree C - else: - heating_setpoint -= 2 # reduce setpoint by 2 degrees C + heating_setpoint -= 1 # reduce setpoint by 1 degree C + # if occupancy > 0: + # heating_setpoint -= 1 # reduce setpoint by 1 degree C + # else: + # heating_setpoint -= 2 # reduce setpoint by 2 degrees C return { - # 'HVAC Heating': {'Duty Cycle': 1 if heating_on else 0}, 'HVAC Heating': { - 'Setpoint': heating_setpoint, + 'Capacity': 1000, + # 'Setpoint': heating_setpoint, # 'Deadband': 2, # 'Load Fraction': 0, # Set to 0 for force heater off # 'Duty Cycle': 0.5, # Sets fraction of on-time explicitly @@ -70,7 +75,7 @@ def get_hvac_controls(hour_of_day, occupancy, heating_setpoint, **unused_inputs) def run_with_hvac_controller(): # Initialization - dwelling = Dwelling(name='Test House with Controller', **dwelling_args) + dwelling = Dwelling(name="OCHRE with Controller", **dwelling_args) heater = dwelling.get_equipment_by_end_use('HVAC Heating') cooler = dwelling.get_equipment_by_end_use('HVAC Cooling') @@ -113,7 +118,7 @@ def run_controls_from_file(control_file): df_ext = pd.read_csv(control_file, index_col='Time', parse_dates=True) # Initialization - dwelling = Dwelling(name='Test House with Controller', **dwelling_args) + dwelling = Dwelling(name="OCHRE with Controller", **dwelling_args) # Simulation control_signal = None @@ -130,5 +135,5 @@ def run_controls_from_file(control_file): if __name__ == '__main__': # run_with_schedule_control() # run_constant_control_signal(example_control_signal) - # run_controls_from_file(external_control_file='path/to/control_file.csv') - run_with_hvac_controller() \ No newline at end of file + run_with_hvac_controller() + # run_controls_from_file(external_control_file='path/to/control_file.csv') \ No newline at end of file diff --git a/bin/run_multiple.py b/bin/run_multiple.py index bcf6495..ba39756 100644 --- a/bin/run_multiple.py +++ b/bin/run_multiple.py @@ -138,7 +138,6 @@ def run_single_building(input_path, simulation_name='ochre', output_path=None): def compile_results(main_folder): # Sample script to compile results from multiple OCHRE runs # assumes each run is in a different folder, and all simulation names are 'ochre' - dirs_to_include = int(dirs_to_include) # set up main_folder = os.path.abspath(main_folder) diff --git a/changelog.md b/changelog.md index c970191..cb5de50 100644 --- a/changelog.md +++ b/changelog.md @@ -2,8 +2,12 @@ ### Changes from PRs +- Added HVAC capacity and max capacity controls, ideal mode only +- Require HVAC duty cycle control for thermostatic mode only - Fixed bug with accounting for HVAC delivered heat for standalone HVAC runs - Fixed bug with ASHP backup heater units +- Fixed bug with named HVAC/Water Heating equipment arguments +- Fixed bug in ASHP duty cycle control - Added OCHREException class to handle errors ### OCHRE v0.8.4-beta diff --git a/docs/source/ControllerIntegration.rst b/docs/source/ControllerIntegration.rst index 15612a0..9b25ff8 100644 --- a/docs/source/ControllerIntegration.rst +++ b/docs/source/ControllerIntegration.rst @@ -31,15 +31,29 @@ equipment, by end use. HVAC Heating or HVAC Cooling ---------------------------- -================================ ========== ========================================================================= -**Control Command** **Units** **Description** -================================ ========== ========================================================================= -Load Fraction unitless 1 (no effect) or 0 (force equipment off) -Setpoint C Sets temperature setpoint for one timestep (then reverts to schedule) -Deadband C Sets thermostat deadband (does not revert unless deadband is scheduled) -Duty Cycle unitless Sets the equipment duty cycle for ``ext_time_res`` -Disable Speed X unitless Disables low (X=1) or high (X=2) speed if value is ``True`` [#]_ -================================ ========== ========================================================================= ++-------------------------------+--------------------------+-----------+-------------------------------------------------------------------------------------+ +| **End Use or Equipment Name** | **Control Command** | **Units** | **Description** | ++===============================+==========================+===========+=====================================================================================+ +| HVAC Heating or HVAC Cooling | Load Fraction | unitless | 1 (no effect) or 0 (forces equipment off) | ++-------------------------------+--------------------------+-----------+-------------------------------------------------------------------------------------+ +| HVAC Heating or HVAC Cooling | Setpoint | C | Sets temperature setpoint for one timestep (then reverts back to schedule) | ++-------------------------------+--------------------------+-----------+-------------------------------------------------------------------------------------+ +| HVAC Heating or HVAC Cooling | Deadband | C | Sets temperature deadband (does not revert back unless deadband is in the schedule) | ++-------------------------------+--------------------------+-----------+-------------------------------------------------------------------------------------+ +| HVAC Heating or HVAC Cooling | Capacity | W | Sets HVAC capacity directly, ideal capacity only (reverts back to defaults) | ++-------------------------------+--------------------------+-----------+-------------------------------------------------------------------------------------+ +| HVAC Heating or HVAC Cooling | Max Capacity Fraction | unitless | Limits HVAC max capacity, ideal capacity only (does not revert) | ++-------------------------------+--------------------------+-----------+-------------------------------------------------------------------------------------+ +| HVAC Heating or HVAC Cooling | Duty Cycle | unitless | Sets the equipment duty cycle for ext_time_res, non-ideal capacity only | ++-------------------------------+--------------------------+-----------+-------------------------------------------------------------------------------------+ +| HVAC Heating or HVAC Cooling | Disable Speed X | N/A | Disables low (X=1) or high (X=2) speed if value is ``True`` [#]_ | ++-------------------------------+--------------------------+-----------+-------------------------------------------------------------------------------------+ +| HVAC Heating (ASHP only) | ER Capacity | W | Sets ER element capacity directly, ideal capacity only (reverts back to defaults) | ++-------------------------------+--------------------------+-----------+-------------------------------------------------------------------------------------+ +| HVAC Heating (ASHP only) | Max ER Capacity Fraction | unitless | Limits ER element max capacity, ideal capacity only (does not revert) | ++-------------------------------+--------------------------+-----------+-------------------------------------------------------------------------------------+ +| HVAC Heating (ASHP only) | ER Duty Cycle | unitless | Sets the ER element duty cycle for ext_time_res, non-ideal capacity only | ++-------------------------------+--------------------------+-----------+-------------------------------------------------------------------------------------+ .. [#] Only available for 2 speed equipment, either ASHP or AC. Variable speed equipment modulates between all speeds to perfectly maintain setpoint ( deadband = 0 C) diff --git a/docs/source/ModelingApproach.rst b/docs/source/ModelingApproach.rst index c2fda01..01efcee 100644 --- a/docs/source/ModelingApproach.rst +++ b/docs/source/ModelingApproach.rst @@ -135,60 +135,65 @@ HVAC ---- OCHRE models several different types of heating, ventilation, and air -conditioning (HVAC) technologies commonly found in residential buildings -in the United States. This includes furnaces, boilers, electric -resistance baseboards, central air conditioners (ACs), room air -conditioners, air source heat pumps (ASHPs), and minisplit heat pumps -(MSHPs). OCHRE also includes “ideal” heating and cooling equipment -models that perfectly maintain the indoor setpoint temperature with a -constant efficiency. Ideal equipment is useful for debugging and -determining the loads in a building. - -HVAC equipment use three types of algorithms for determining equipment -capacity and efficiency: - -- Static capacity: System capacity and efficiency is set at +conditioning (HVAC) technologies commonly found in residential buildings in +the United States. This includes furnaces, boilers, electric resistance +baseboards, central air conditioners (ACs), room air conditioners, air source +heat pumps (ASHPs), and minisplit heat pumps (MSHPs). OCHRE also includes +“ideal” heating and cooling equipment models that perfectly maintain the +indoor setpoint temperature with a constant efficiency. + +HVAC equipment use one of two algorithms to determine equipment max capacity +and efficiency: + +- Static: System max capacity and efficiency is set at initialization and does not change (e.g., Gas Furnace, Electric - Baseboard) - -- Dynamic capacity: System capacity and efficiency varies based on - indoor and outdoor temperatures and air flow rate (e.g., Air - Conditioner, Air Source Heat Pump) - -- Ideal capacity: System capacity is calculated at each time step to - maintain constant indoor temperature (e.g., Ideal Heater, Ideal - Cooler) - -Air source heat pumps, minisplit heat pumps, and air conditioners -include multi-speed options, including single-speed, two-speed, and -variable speed options. The one- and two-speed options typically use the -dynamic capacity algorithm for high resolution simulations, while the -variable speed option typically uses the ideal capacity algorithm. This -equipment use curves to determine the capacity of efficiency of the unit -as a function of the outdoor air drybulb temperature and indoor air -wetbulb temperature. These curves are based on “\ `Improved Modeling of -Residential Air Conditioners and Heat Pumps for Energy -Calculations `__\ ”. -Minisplit heat pumps are always modeled as multispeed equipment, while -for other equipment multiple options are available, with more speeds -corresponding to higher efficiency equipment. - -The Air Source Heat Pump and Mini Split Heat Pump models include heating -and cooling functionality. The heat pump heating model includes a -reverse cycle defrost algorithm that reduces efficiency and capacity at -low temperatures, as well as an electric resistance element that is -enabled when the outdoor air temperature is below a threshold. - -By default, HVAC equipment are controlled using a thermostat control. -Heating and cooling setpoints are defined in the input files and can -vary over time. - -All HVAC equipment can be externally controlled by updating the -thermostat setpoints and deadband or by direct load control (i.e., -shut-off). Static and dynamic HVAC equipment can also be controlled -using duty cycle control or by disabling specific speeds. The equipment -will follow the duty cycle control exactly while minimizing temperature -deviation from setpoint and minimizing cycling. + Baseboard). + +- Dynamic: System max capacity and efficiency varies based on indoor and + outdoor temperatures and air flow rate using biquadratic formulas. These + curves are based on “\ `Improved Modeling of Residential Air Conditioners + and Heat Pumps for Energy Calculations +`__\ +” (e.g., Air Conditioner, Air Source Heat Pump). + +In addition, HVAC equipment use one of two modes to determine real-time +capacity and power consumption: + +- Thermostatic mode: A thermostat control with a deadband is used to + turn the equipment on and off. Capacity and power are zero or at their + maximum values. + +- Ideal mode: Capacity is calculated at each time step to perfectly + maintain the indoor setpoint temperature. Power is determined by the + fraction of time that the equipment is on in various modes. + +By default, most HVAC equipment operate in thermostatic mode for simulations +with a time resolution of less than 5 minutes. Otherwise, the ideal mode is +used. The only exceptions are variable speed equipment, which always operate +in ideal capacity mode. + +Air source heat pumps, central air conditioners, and room air conditioners +include single-speed, two-speed, and variable speed options. Minisplit heat +pumps are always modeled as variable speed equipment. + +The Air source heat pump and Minisplit heat pump models include heating and +cooling functionality. The heat pump heating model includes a few unique +features: + +- An electric resistance element with additional controls, including an + offset thermostat deadband. +- A heat pump shut off control when the outdoor air temperature is below a + threshold. +- A reverse cycle defrost algorithm that reduces heat pump efficiency and + capacity at low temperatures. + +All HVAC equipment can be externally controlled by updating the thermostat +setpoints and deadband or by direct load control (i.e., shut-off). Specific +speeds can be disabled in multi-speed equipment. Equipment capacity can also +be set directly or controlled using a maximum capacity fraction in ideal mode. +In thermostatic mode, duty cycle controls can determine the equipment state. +The equipment will follow the duty cycle control exactly while minimizing +cycling and temperature deviation from setpoint. Ducts ~~~~~ diff --git a/ochre/Dwelling.py b/ochre/Dwelling.py index c3613a5..b83d8fc 100644 --- a/ochre/Dwelling.py +++ b/ochre/Dwelling.py @@ -264,6 +264,7 @@ def start_sub_update(self, sub, control_signal): if sub_control_signal is None: sub_control_signal = {} + # TODO: update schedule, not control signal if 'net_power' not in sub_control_signal: sub_control_signal['net_power'] = self.total_p_kw if isinstance(sub, Battery) and 'pv_power' not in sub_control_signal: diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index 842f480..4bccf92 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -56,7 +56,7 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): assert (np.diff(self.capacity_list) > 0).all() self.capacity = self.capacity_list[self.speed_idx] self.capacity_ideal = self.capacity # capacity to maintain setpoint, for ideal equipment, in W - self.capacity_max = self.capacity_list[-1] # controllable for ideal equipment, in W + self.capacity_max = self.capacity_list[-1] # varies for dynamic equipment, in W self.capacity_min = kwargs.get('Minimum Capacity (W)', 0) # for ideal equipment, in W self.space_fraction = kwargs.get('Conditioned Space Fraction (-)', 1.0) self.delivered_heat = 0 # in W, total sensible heat gain, excluding duct losses @@ -172,7 +172,8 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): self.ext_ignore_thermostat = kwargs.get('ext_ignore_thermostat', False) self.setpoint_ramp_rate = kwargs.get('setpoint_ramp_rate') # max setpoint ramp rate, in C/min self.temp_indoor_prev = self.temp_setpoint - self.duty_cycle_capacity = None # Option to set capacity from duty cycle + self.ext_capacity = None # Option to set capacity directly, ideal capacity only + self.ext_capacity_frac = 1 # Option to limit max capacity, ideal capacity only # Results options self.show_eir_shr = kwargs.get('show_eir_shr', False) @@ -206,7 +207,11 @@ def update_external_control(self, control_signal): # - Note: Setpoint must be provided every timestep or it will revert back to the dwelling schedule # - Deadband: Updates heating (cooling) deadband temperature (in C) # - Note: Deadband will only be reset if it is in the schedule - # - Duty Cycle: Forces HVAC on for fraction of external time step (as fraction [0,1]) + # - Capacity: Sets HVAC capacity directly, ideal capacity only + # - Resets every time step + # - Max Capacity Fraction: Limits HVAC max capacity, ideal capacity only + # - For now, does not get reset + # - Duty Cycle: Forces HVAC on for fraction of external time step (as fraction [0,1]), non-ideal capacity only # - If 0 < Duty Cycle < 1, the equipment will cycle once every 2 external time steps # - For ASHP: Can supply HP and ER duty cycles # - Note: does not use clock on/off time @@ -231,10 +236,37 @@ def update_external_control(self, control_signal): elif load_fraction != 1: raise OCHREException(f"{self.name} can't handle non-integer load fractions") + capacity_frac = control_signal.get('Max Capacity Fraction') + if capacity_frac is not None: + if not self.use_ideal_capacity: + raise IOError( + f"Cannot set {self.name} Max Capacity Fraction. " + 'Set `use_ideal_capacity` to True or control "Duty Cycle".' + ) + self.ext_capacity_frac = capacity_frac + + capacity = control_signal.get('Capacity') + if capacity is not None: + if not self.use_ideal_capacity: + raise IOError( + f"Cannot set {self.name} Capacity. " + 'Set `use_ideal_capacity` to True or control "Duty Cycle".' + ) + + self.ext_capacity = capacity + # TODO: remove once schedule is incorporated, test with ASHP modes + return 'On' if self.ext_capacity > 0 else 'Off' + if any(['Duty Cycle' in key for key in control_signal]): + if self.use_ideal_capacity: + raise IOError( + f"Cannot set {self.name} Duty Cycle. " + 'Set `use_ideal_capacity` to False or use "Capacity" control.' + ) return self.run_duty_cycle_control(control_signal) - else: - return self.update_internal_control() + + # if mode isn't set yet, run internal control method + return self.update_internal_control() def run_duty_cycle_control(self, control_signal): duty_cycles = control_signal.get('Duty Cycle', 0) @@ -242,7 +274,7 @@ def run_duty_cycle_control(self, control_signal): self.speed_idx = 0 return 'Off' if duty_cycles == 1: - self.speed_idx = self.n_speeds # max speed, only relevant for non-ideal capacity model + self.speed_idx = self.n_speeds # max speed return 'On' # Parse duty cycles @@ -250,26 +282,17 @@ def run_duty_cycle_control(self, control_signal): duty_cycles = [duty_cycles] assert 0 <= sum(duty_cycles) <= 1 - if self.use_ideal_capacity: - # Set capacity to constant value based on duty cycle - self.duty_cycle_capacity = duty_cycles[0] * self.capacity_max - if self.duty_cycle_capacity < self.capacity_min: - self.duty_cycle_capacity = 0 + # Set mode based on duty cycle from external controller + mode_priority = self.calculate_mode_priority(*duty_cycles) + thermostat_mode = self.run_thermostat_control() + thermostat_mode = thermostat_mode if thermostat_mode is not None else self.mode - mode = 'On' if self.duty_cycle_capacity > 0 else 'Off' + # take thermostat mode if it exists in priority stack, or take highest priority mode (usually current mode) + mode = thermostat_mode if (thermostat_mode in mode_priority and + not self.ext_ignore_thermostat) else mode_priority[0] - else: - # Set mode based on duty cycle from external controller - mode_priority = self.calculate_mode_priority(*duty_cycles) - thermostat_mode = self.run_thermostat_control() - thermostat_mode = thermostat_mode if thermostat_mode is not None else self.mode - - # take thermostat mode if it exists in priority stack, or take highest priority mode (usually current mode) - mode = thermostat_mode if (thermostat_mode in mode_priority and - not self.ext_ignore_thermostat) else mode_priority[0] - - # by default, turn on to max speed - self.speed_idx = self.n_speeds if 'On' in mode else 0 + # by default, turn on to max speed + self.speed_idx = self.n_speeds if 'On' in mode else 0 return mode @@ -278,7 +301,8 @@ def update_internal_control(self): self.update_setpoint() if self.use_ideal_capacity: - self.duty_cycle_capacity = None + # TODO: this won't work until it's added to the schedule + self.ext_capacity = None # run ideal capacity calculation here, just to determine mode and speed # FUTURE: capacity update is done twice per loop, could but updated to improve speed @@ -356,17 +380,22 @@ def update_capacity(self): if self.use_ideal_capacity: # Solve for capacity to meet setpoint self.capacity_ideal = self.solve_ideal_capacity() + capacity = self.capacity_ideal + + # Update from direct capacity controls + if self.ext_capacity is not None: + capacity = self.ext_capacity - if self.duty_cycle_capacity is not None: - capacity = self.duty_cycle_capacity - elif self.capacity_ideal < self.capacity_min: + # Enforce min and max capacity limits + if capacity < self.capacity_min: # If capacity < capacity_min (or capacity is negative), force off capacity = 0 - else: - # Clip at maximum capacity. If ideal capacity is out of bounds, setpoint won't be met - capacity = min(self.capacity_ideal, self.capacity_max) + elif capacity > self.capacity_max * self.ext_capacity_frac: + # Clip at maximum capacity, considering max capacity fraction + # Note: if ideal capacity is out of bounds, setpoint won't be met + capacity = self.capacity_max * self.ext_capacity_frac - # set speed and return capacity + # set speed (only used for non-dynamic equipment) and return capacity self.speed_idx = capacity / self.capacity_max return capacity @@ -704,14 +733,6 @@ def update_external_control(self, control_signal): return super().update_external_control(control_signal) - def run_duty_cycle_control(self, control_signal): - if self.use_ideal_capacity: - # update max capacity using highest enabled speed - max_speed = np.nonzero(~ self.disable_speeds)[0][-1] + 1 - self.capacity_max = self.calculate_biquadratic_param(param='cap', speed_idx=max_speed) - - return super().run_duty_cycle_control(control_signal) - def run_two_speed_control(self): mode = super().run_thermostat_control() # Can be On, Off, or None if self.speed_idx == 0: @@ -929,7 +950,6 @@ def __init__(self, **kwargs): super().__init__(**kwargs) - class MinisplitAHSPCooler(MinisplitHVAC, AirConditioner): name = 'MSHP Cooler' crankcase_kw = 0.015 @@ -977,10 +997,10 @@ def update_capacity(self): self.defrost_power_mult = 0.954 / 0.875 # increase in power relative to the capacity q_defrost = 0.01 * defrost_time_frac * (7.222 - t_ext_db) * (self.capacity_max / 1.01667) - # Update actual capacity or max allowable capacity + # Update actual capacity and max allowable capacity + self.capacity_max = self.capacity_max * defrost_capacity_mult - q_defrost if self.use_ideal_capacity: - self.capacity_max = self.capacity_max * defrost_capacity_mult - q_defrost - capacity = min(capacity, self.capacity_max) + capacity = min(capacity, self.capacity_max * self.ext_capacity_frac) else: capacity = capacity * defrost_capacity_mult - q_defrost @@ -1020,77 +1040,89 @@ def __init__(self, **kwargs): self.er_capacity_rated = kwargs['Supplemental Heater Capacity (W)'] self.er_eir_rated = kwargs.get('Supplemental Heater EIR (-)', 1) self.er_capacity = 0 - self.er_duty_cycle_capacity = None + self.er_ext_capacity = None # Option to set ER capacity directly, ideal capacity only + self.er_ext_capacity_frac = 1 # Option to limit max capacity, ideal capacity only # Update minimum time for ER element er_on_time = kwargs.get(self.end_use + ' Minimum ER On Time', 0) self.min_time_in_mode['HP and ER On'] = dt.timedelta(minutes=er_on_time) self.min_time_in_mode['ER On'] = dt.timedelta(minutes=er_on_time) - # TODO: add option to disable ER + def update_external_control(self, control_signal): + # Additional options for ASHP external control signals: + # - ER Capacity: Sets ER capacity directly, ideal capacity only + # - Resets every time step + # - Max ER Capacity Fraction: Limits ER max capacity, ideal capacity only + # - Recommended to set to 0 to disable ER element + # - For now, does not get reset + # - ER Duty Cycle: Combines with "Duty Cycle" control, see HVAC.update_external_control + + capacity_frac = control_signal.get("Max ER Capacity Fraction") + if capacity_frac is not None: + if not self.use_ideal_capacity: + raise IOError( + f"Cannot set {self.name} Max ER Capacity Fraction. " + 'Set `use_ideal_capacity` to True or control "ER Duty Cycle".' + ) + self.er_ext_capacity_frac = capacity_frac + + capacity = control_signal.get("ER Capacity") + if capacity is not None: + if not self.use_ideal_capacity: + raise IOError( + f"Cannot set {self.name} ER Capacity. " + 'Set `use_ideal_capacity` to True or control "ER Duty Cycle".' + ) + self.er_ext_capacity = capacity + + return super().update_external_control(control_signal) def run_duty_cycle_control(self, control_signal): # If duty cycles exist, combine duty cycles for HP and ER modes er_duty_cycle = control_signal.get('ER Duty Cycle', 0) - if self.use_ideal_capacity: - # Use ideal HVAC to determine HP mode and HP duty cycle capacity - hp_mode = super().run_duty_cycle_control(control_signal) - - # determine ER mode and capacity - assert isinstance(er_duty_cycle, (int, float)) and 0 <= er_duty_cycle <= 1 - self.er_duty_cycle_capacity = er_duty_cycle * self.er_capacity_rated - - # return mode based on HP and ER modes - if self.er_duty_cycle_capacity > 0: - if hp_mode == 'On': - return 'HP and ER On' - else: - return 'ER On' - else: - if hp_mode == 'On': - return 'HP On' - else: - return 'Off' - + hp_duty_cycle = control_signal.get('Duty Cycle', 0) + if er_duty_cycle + hp_duty_cycle > 1: + combo_duty_cycle = 1 - er_duty_cycle - hp_duty_cycle + er_duty_cycle -= combo_duty_cycle + hp_duty_cycle -= combo_duty_cycle + duty_cycles = [hp_duty_cycle, combo_duty_cycle, er_duty_cycle, 0] else: - hp_duty_cycle = control_signal.get('Duty Cycle', 0) - duty_cycles = [min(hp_duty_cycle, 1 - er_duty_cycle), min(hp_duty_cycle, er_duty_cycle), - min(er_duty_cycle, 1 - hp_duty_cycle), 1 - max(hp_duty_cycle, er_duty_cycle)] - - # update control args and determine mode and speed - control_signal['Duty Cycle'] = duty_cycles - mode = super().run_duty_cycle_control(control_signal) - - # update mode counters - if mode == 'HP and ER On': - # update HP only and ER only counters - self.ext_mode_counters['HP On'] += self.time_res - self.ext_mode_counters['ER On'] += self.time_res - elif 'On' in mode: - # update HP+ER counter - self.ext_mode_counters['HP and ER On'] = max(self.ext_mode_counters[mode] + self.time_res, - self.ext_mode_counters['HP On'], - self.ext_mode_counters['ER On']) - return mode + duty_cycles = [hp_duty_cycle, 0, er_duty_cycle, 1 - er_duty_cycle - hp_duty_cycle] + assert sum(duty_cycles) == 1 + + # update control args and determine mode and speed + # TODO: update schedule, not control_signal + control_signal['Duty Cycle'] = duty_cycles + mode = super().run_duty_cycle_control(control_signal) + + # update mode counters + if mode == 'HP and ER On': + # update HP only and ER only counters + self.ext_mode_counters['HP On'] += self.time_res + self.ext_mode_counters['ER On'] += self.time_res + elif 'On' in mode: + # update HP+ER counter + self.ext_mode_counters['HP and ER On'] = max(self.ext_mode_counters[mode] + self.time_res, + self.ext_mode_counters['HP On'], + self.ext_mode_counters['ER On']) + return mode def update_internal_control(self): - # Update setpoint from schedule - self.update_setpoint() - if self.use_ideal_capacity: - self.duty_cycle_capacity = None - self.er_duty_cycle_capacity = None + # Note: not calling super().update_internal_control + # TODO: this won't work until they are added to the schedule + self.ext_capacity = None + self.er_ext_capacity = None - # Update capacity (and HP max capacity) - self.capacity = HeatPumpHeater.update_capacity(self) + # Update setpoint from schedule + self.update_setpoint() - if self.capacity_ideal <= 0: - mode = 'Off' - elif self.capacity_ideal <= self.capacity_max: - mode = 'HP On' - else: - mode = 'HP and ER On' + # Update HP capacity (and HP max capacity) + hp_capacity = HeatPumpHeater.update_capacity(self) + hp_on = hp_capacity > 0 + er_capacity = self.update_er_capacity(hp_capacity) + er_on = er_capacity > 0 else: # get HP and ER modes separately hp_mode = super().update_internal_control() @@ -1098,24 +1130,23 @@ def update_internal_control(self): er_mode = self.run_er_thermostat_control() er_on = er_mode == 'On' if er_mode is not None else 'ER' in self.mode - # combine HP and ER modes - if er_on: - if hp_on: - mode = 'HP and ER On' - else: - mode = 'ER On' - else: - if hp_on: - mode = 'HP On' - else: - mode = 'Off' - # Force HP off if outdoor temp is very cold t_ext_db = self.current_schedule['Ambient Dry Bulb (C)'] - if self.outdoor_temp_limit is not None and t_ext_db < self.outdoor_temp_limit and 'HP' in mode: - mode = 'ER On' - - return mode + if self.outdoor_temp_limit is not None and t_ext_db < self.outdoor_temp_limit and hp_on: + hp_on = False + er_on = True + + # combine HP and ER modes + if er_on: + if hp_on: + return 'HP and ER On' + else: + return 'ER On' + else: + if hp_on: + return 'HP On' + else: + return 'Off' def run_er_thermostat_control(self): # run thermostat control for ER element - lower the setpoint by the deadband @@ -1133,6 +1164,19 @@ def run_er_thermostat_control(self): if self.hvac_mult * (temp_indoor - temp_turn_off) > 0: return 'Off' + def update_er_capacity(self, hp_capacity): + if self.use_ideal_capacity: + if self.er_ext_capacity is not None: + er_capacity = self.er_ext_capacity + else: + # use total ideal capacity - calculated in HVAC.update_capacity + er_capacity = self.capacity_ideal - hp_capacity + er_capacity = min(max(er_capacity, 0), self.er_capacity_rated * self.er_ext_capacity_frac) + else: + er_capacity = self.er_capacity_rated + + return er_capacity + def update_capacity(self): # Get HP capacity and update ideal capacity hp_capacity = super().update_capacity() @@ -1140,25 +1184,17 @@ def update_capacity(self): hp_capacity = 0 if 'ER' in self.mode: - if self.er_duty_cycle_capacity is not None: - er_capacity = self.er_duty_cycle_capacity - elif self.use_ideal_capacity: - # use total ideal capacity - calculated in HVAC.update_capacity - er_capacity = self.capacity_ideal - hp_capacity - er_capacity = min(max(er_capacity, 0), self.er_capacity_rated) - else: - er_capacity = self.er_capacity_rated + self.er_capacity = self.update_er_capacity(hp_capacity) else: - er_capacity = 0 + self.er_capacity = 0 - # save ER capacity - self.er_capacity = er_capacity - return hp_capacity + er_capacity + return hp_capacity + self.er_capacity def update_fan_power(self, capacity): fan_power = super().update_fan_power(capacity) # if ER on and using ideal capacity, fan power is fixed at rated value + # this will cause small changes in indoor temperature if self.use_ideal_capacity and 'ER' in self.mode: if 'HP' in self.mode: fixed_fan_power = self.fan_power_max diff --git a/ochre/Equipment/WaterHeater.py b/ochre/Equipment/WaterHeater.py index 5709f38..11683f6 100644 --- a/ochre/Equipment/WaterHeater.py +++ b/ochre/Equipment/WaterHeater.py @@ -482,6 +482,7 @@ def update_external_control(self, control_signal): # Add HP duty cycle to ERWH control duty_cycles = [control_signal.get('HP Duty Cycle', 0), control_signal.get('ER Duty Cycle', 0) if not self.hp_only_mode else 0] + # TODO: update schedule, not control signal control_signal['Duty Cycle'] = duty_cycles return super().update_external_control(control_signal) diff --git a/ochre/utils/equipment.py b/ochre/utils/equipment.py index 3af3d8e..0f95396 100644 --- a/ochre/utils/equipment.py +++ b/ochre/utils/equipment.py @@ -105,7 +105,7 @@ def update_equipment_properties(properties, schedule, zip_parameters_file='ZIP P raise OCHREException(f'Only 1 {end_use} equipment is allowed, but multiple were included in inputs: {eq_names}') # Get equipment name from named dict and from generic (using name and fuel) - eq_name, eq_data = list(named.items())[0] if named else None, {} + eq_name, eq_data = list(named.items())[0] if named else (None, {}) eq_type = generic.get('Equipment Name') eq_fuel = generic.get('Fuel') if eq_fuel not in ['Electricity', 'Natural gas', None]: diff --git a/test/test_equipment/test_hvac.py b/test/test_equipment/test_hvac.py index 8745602..caf0e7c 100644 --- a/test/test_equipment/test_hvac.py +++ b/test/test_equipment/test_hvac.py @@ -218,12 +218,12 @@ def test_run_duty_cycle_control(self): self.hvac.mode = 'Off' mode = self.hvac.run_duty_cycle_control(update_args_inside, {'Duty Cycle': 0.5}) self.assertEqual(mode, 'On') - self.assertEqual(self.hvac.duty_cycle_capacity, 2500) + self.assertEqual(self.hvac.ext_capacity, 2500) self.hvac.mode = 'Off' mode = self.hvac.run_duty_cycle_control(update_args_inside, {'Duty Cycle': 0}) self.assertEqual(mode, 'Off') - self.assertEqual(self.hvac.duty_cycle_capacity, 0) + self.assertEqual(self.hvac.ext_capacity, 0) def test_update_internal_control(self): mode = self.hvac.update_internal_control(update_args_heat) @@ -529,7 +529,7 @@ def test_run_duty_cycle_control(self): mode = self.hvac.run_duty_cycle_control(update_args_inside, {'Duty Cycle': 0.5}) self.assertEqual(mode, 'On') self.assertAlmostEqual(self.hvac.capacity_max, 6010, places=-1) - self.assertAlmostEqual(self.hvac.duty_cycle_capacity, 3000, places=-1) + self.assertAlmostEqual(self.hvac.ext_capacity, 3000, places=-1) def test_update_capacity(self): # Capacity should follow ideal update