diff --git a/bin/run_dwelling.py b/bin/run_dwelling.py index 269ea19..3ca9c21 100644 --- a/bin/run_dwelling.py +++ b/bin/run_dwelling.py @@ -63,7 +63,7 @@ # HVAC equipment # Note: dictionary key can be end use (e.g., HVAC Heating) or specific equipment name (e.g., Gas Furnace) # 'HVAC Heating': { - # # 'use_ideal_capacity': True, + # # 'use_ideal_mode': True, # # 'show_eir_shr': True, # }, # 'Air Conditioner': { @@ -90,7 +90,7 @@ # # 'hp_only_mode': True # }, # 'Electric Resistance Water Heater': { - # 'use_ideal_capacity': True, + # 'use_ideal_mode': True, # }, # Other equipment diff --git a/bin/run_equipment.py b/bin/run_equipment.py index 0659b6b..18772e0 100644 --- a/bin/run_equipment.py +++ b/bin/run_equipment.py @@ -89,17 +89,18 @@ def run_water_heater(): equipment_args = { # Equipment parameters # 'water_nodes': 1, - 'Initial Temperature (C)': 49, - 'Setpoint Temperature (C)': 51, - 'Deadband Temperature (C)': 5, - 'Capacity (W)': 4800, - 'Efficiency (-)': 1, - 'Tank Volume (L)': 250, - 'Tank Height (m)': 1.22, - 'UA (W/K)': 2.17, - 'schedule': schedule, + "Initial Temperature (C)": 49, + "Setpoint Temperature (C)": 51, + "Deadband Temperature (C)": 5, + "Capacity (W)": 4800, + "Efficiency (-)": 1, + "Tank Volume (L)": 250, + "Tank Height (m)": 1.22, + "UA (W/K)": 2.17, + "schedule": schedule, **default_args, - 'time_res': time_res, + "time_res": time_res, + "duration": dt.timedelta(days=1), } # Initialize equipment @@ -230,7 +231,7 @@ def run_equipment_from_house_model(): # run_battery() # run_battery_controlled() - # run_water_heater() + run_water_heater() # run_hvac() - run_ev() + # run_ev() # run_equipment_from_house_model() diff --git a/bin/run_external_control.py b/bin/run_external_control.py index a949958..89b7b52 100644 --- a/bin/run_external_control.py +++ b/bin/run_external_control.py @@ -9,7 +9,6 @@ dwelling_args.update( { "time_res": dt.timedelta(minutes=10), - "ext_time_res": dt.timedelta(minutes=60), # for duty cycle control only "Equipment": { "EV": { 'vehicle_type': 'BEV', @@ -128,7 +127,6 @@ def get_hvac_controls(hour_of_day, occupancy, heating_setpoint, **unused_inputs) # '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 }, # 'HVAC Cooling': {...}, } diff --git a/docs/source/ControllerIntegration.rst b/docs/source/ControllerIntegration.rst index c8f0a5f..a67b204 100644 --- a/docs/source/ControllerIntegration.rst +++ b/docs/source/ControllerIntegration.rst @@ -45,16 +45,12 @@ HVAC Heating or HVAC Cooling +-------------------------------+--------------------------+-----------+---------------------+---------------------------------------------------------------------------+ | HVAC Heating or HVAC Cooling | Max Capacity Fraction | unitless | Only if in schedule | Limits HVAC max capacity, ideal capacity only | +-------------------------------+--------------------------+-----------+---------------------+---------------------------------------------------------------------------+ -| HVAC Heating or HVAC Cooling | Duty Cycle | unitless | TRUE | Sets the equipment duty cycle for ext_time_res, non-ideal capacity only | -+-------------------------------+--------------------------+-----------+---------------------+---------------------------------------------------------------------------+ | HVAC Heating or HVAC Cooling | Disable Speed X | N/A | FALSE | Flag to disable low (X=1) or high (X=2) speed, only for 2 speed equipment | +-------------------------------+--------------------------+-----------+---------------------+---------------------------------------------------------------------------+ | HVAC Heating (ASHP only) | ER Capacity | W | TRUE | Sets ER element capacity directly, ideal capacity only | +-------------------------------+--------------------------+-----------+---------------------+---------------------------------------------------------------------------+ | HVAC Heating (ASHP only) | Max ER Capacity Fraction | unitless | Only if in schedule | Limits ER element max capacity, ideal capacity only | +-------------------------------+--------------------------+-----------+---------------------+---------------------------------------------------------------------------+ -| HVAC Heating (ASHP only) | ER Duty Cycle | unitless | TRUE | Sets the ER element duty cycle for ext_time_res, non-ideal capacity only | -+-------------------------------+--------------------------+-----------+---------------------+---------------------------------------------------------------------------+ Water Heating ----------------------------- @@ -70,12 +66,6 @@ Water Heating +---------------------+-----------+---------------------+--------------------------------------------------------------------+ | Max Power | kW | Only if in schedule | Sets the maximum power. Does not work for HPWH in HP mode | +---------------------+-----------+---------------------+--------------------------------------------------------------------+ -| Duty Cycle | unitless | TRUE | Sets the equipment duty cycle for ext_time_res | -+---------------------+-----------+---------------------+--------------------------------------------------------------------+ -| HP Duty Cycle | unitless | TRUE | Sets the HPWH heat pump duty cycle for ext_time_res | -+---------------------+-----------+---------------------+--------------------------------------------------------------------+ -| ER Duty Cycle | unitless | TRUE | Sets the HPWH electric resistance duty cycle for ext_time_res [#]_ | -+---------------------+-----------+---------------------+--------------------------------------------------------------------+ .. [#] Sending {'Setpoint': None} will reset the setpoint to the default schedule. Note that a 10 F (5.56 C) decrease in setpoint corresponds to a CTA-2045 'Load Shed' command. A 10 F increase corresponds to an diff --git a/docs/source/InputsAndArguments.rst b/docs/source/InputsAndArguments.rst index 517126b..9f58e24 100644 --- a/docs/source/InputsAndArguments.rst +++ b/docs/source/InputsAndArguments.rst @@ -166,7 +166,6 @@ The table below lists the optional arguments for creating a ``Dwelling`` model. ``save_status`` boolean ``TRUE`` [#]_ Save status file for is simulation completed or failed ``save_schedule_columns`` list Empty list List of time series inputs to save to schedule outputs file ``schedule`` pandas.DataFrame None Schedule with equipment and weather data that overrides the ``schedule_input_file`` and the ``equipment_schedule_file``. Not required for ``Dwelling`` -``ext_time_res`` datetime.timedelta None Time resolution for external controller. Required for Duty Cycle control. ``seed`` int or string HPXML or schedule file Random seed for initial temperatures and EV event data ``modify_hpxml_dict`` dict empty dict Dictionary that directly modifies values from HPXML file ``Occupancy`` dict empty dict Includes arguments for building occupancy @@ -300,8 +299,6 @@ described in the sections below. +-----------------------------+------------------------+--------------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------+ | ``schedule`` | ``pandas.DataFrame`` | None | Schedule with equipment or weather data that overrides the schedule_input_file and the equipment_schedule_file. Not required for Dwelling and some equipment | +-----------------------------+------------------------+--------------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| ``ext_time_res`` | ``datetime.timedelta`` | None | Time resolution for external controller. Required if using Duty Cycle control | -+-----------------------------+------------------------+--------------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------+ | ``seed`` | int or string | HPXML or equipment schedule file | Random seed for setting initial temperatures and EV event data | +-----------------------------+------------------------+--------------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------+ @@ -352,7 +349,7 @@ arguments for HVAC equipment. +================================================+===========================+==============================+====================================================================+====================================================================================================================+ | ``envelope_model`` | ``ochre.Envelope`` | Yes | | Envelope model for measuring temperature impacts | +------------------------------------------------+---------------------------+------------------------------+--------------------------------------------------------------------+--------------------------------------------------------------------------------------------------------------------+ -| ``use_ideal_capacity`` | boolean | No | True if time_res >= 5 minutes or for variable-speed equipment | If True, OCHRE sets HVAC capacity to meet the setpoint. If False, OCHRE uses thermostat deadband control | +| ``use_ideal_mode`` | boolean | No | True if time_res >= 5 minutes or for variable-speed equipment | If True, OCHRE sets HVAC capacity to meet the setpoint. If False, OCHRE uses thermostat deadband control | +------------------------------------------------+---------------------------+------------------------------+--------------------------------------------------------------------+--------------------------------------------------------------------------------------------------------------------+ | ``Capacity (W)`` | number or list of numbers | Yes | Taken from HPXML | Rated capacity of equipment. If a list, it is the rated capacity by speed | +------------------------------------------------+---------------------------+------------------------------+--------------------------------------------------------------------+--------------------------------------------------------------------------------------------------------------------+ @@ -422,7 +419,7 @@ arguments for Water Heating equipment. +-----------------------------------------------------+---------------+------------------------------------+-----------------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------+ | Argument Name | Argument Type | Required? | Default Value | Description | +=====================================================+===============+====================================+=======================================================================+==================================================================================================================+ -| ``use_ideal_capacity`` | boolean | No | True if time_res >= 5 minutes | If True, OCHRE sets water heater capacity to meet the setpoint. If False, OCHRE uses thermostat deadband control | +| ``use_ideal_mode`` | boolean | No | True if time_res >= 5 minutes | If True, OCHRE sets water heater capacity to meet the setpoint. If False, OCHRE uses thermostat deadband control | +-----------------------------------------------------+---------------+------------------------------------+-----------------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------+ | ``water_nodes`` | int | No | 12 if Heat Pump Water Heater, 1 if Tankless Water Heater, otherwise 2 | Number of nodes in water tank model | +-----------------------------------------------------+---------------+------------------------------------+-----------------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------+ diff --git a/docs/source/ModelingApproach.rst b/docs/source/ModelingApproach.rst index b351e92..8dcdd99 100644 --- a/docs/source/ModelingApproach.rst +++ b/docs/source/ModelingApproach.rst @@ -187,9 +187,6 @@ 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 ~~~~~ @@ -255,8 +252,7 @@ schedule. Similar to HVAC equipment, water heater equipment has a thermostat control, and can be externally controlled by updating the thermostat setpoints and -deadband, specifying a duty cycle, or direct shut-off. Tankless equipment can -only be controlled through thermostat control and direct-shut-off. +deadband or direct shut-off. Electric Vehicles ----------------- diff --git a/ochre/Analysis.py b/ochre/Analysis.py index 5f2abf8..d54ea79 100644 --- a/ochre/Analysis.py +++ b/ochre/Analysis.py @@ -438,21 +438,14 @@ def calculate_metrics(results=None, results_file=None, dwelling=None, metrics_ve # Equipment cycling metrics if metrics_verbosity >= 5: - mode_cols = [col for col in results if ' Mode' in col] + mode_cols = [col for col in results if " On-Time Fraction (-)" in col] for mode_col in mode_cols: - name = re.fullmatch('(.*) Mode', mode_col).group(1) - modes = results[mode_col] - unique_modes = [mode for mode in modes.unique() if mode != 'Off'] - for unique_mode in unique_modes: - on = modes == unique_mode - cycle_starts = on & (~on).shift() - cycles = cycle_starts.sum() - if cycles <= 1: - continue - elif len(unique_modes) == 1: - metrics[f'{name} Cycles'] = cycles - else: - metrics[f'{name} "{unique_mode}" Cycles'] = cycles + name = re.fullmatch("(.*) On-Time Fraction (-)", mode_col).group(1) + on_frac = results[mode_col].astype(bool) + cycle_starts = on_frac & (~on_frac).shift() + cycles = cycle_starts.sum() + if cycles > 0: + metrics[f"{name} Cycles"] = cycles # FUTURE: add rates, emissions, other post processing # print('Loading rate file...') diff --git a/ochre/Dwelling.py b/ochre/Dwelling.py index 0cada86..f7b188c 100644 --- a/ochre/Dwelling.py +++ b/ochre/Dwelling.py @@ -142,7 +142,7 @@ def __init__(self, metrics_verbosity=6, save_schedule_columns=None, save_args_to for name, eq in self.equipment.items(): # if time step is large, check that ideal equipment is being used - ideal = eq.use_ideal_capacity if isinstance(eq, (HVAC, WaterHeater)) else True + ideal = eq.use_ideal_mode if isinstance(eq, (HVAC, WaterHeater)) else True if not ideal: if self.time_res >= dt.timedelta(minutes=15): raise OCHREException(f'Cannot use non-ideal equipment {name} with large time step of' @@ -158,7 +158,7 @@ def __init__(self, metrics_verbosity=6, save_schedule_columns=None, save_args_to # force ideal HVAC equipment to go last - so all heat from other equipment is known during update for eq in self.equipment.values(): - if isinstance(eq, HVAC) and eq.use_ideal_capacity: + if isinstance(eq, HVAC) and eq.use_ideal_mode: self.sub_simulators.pop(self.sub_simulators.index(eq)) self.sub_simulators.append(eq) # force generator/battery to go last - so it can run self-consumption controller diff --git a/ochre/Equipment/Battery.py b/ochre/Equipment/Battery.py index 645e3af..e3abe92 100644 --- a/ochre/Equipment/Battery.py +++ b/ochre/Equipment/Battery.py @@ -178,12 +178,12 @@ def reset_time(self, start_time=None, **kwargs): self.capacity_kwh = self.capacity_rated self.capacity_kwh_nominal = self.capacity_rated - def update_external_control(self, control_signal): + def parse_control_signal(self, control_signal): # Options for external control signals: # - SOC: Solves for power setpoint to achieve desired SOC, in 1/hour # - Min SOC: Minimum SOC limit for self-consumption mode # - Max SOC: Maximum SOC limit for self-consumption mode - # - See additional controls in Generator.update_external_control + # - See additional controls in Generator.parse_control_signal # - Note: still subject to SOC limits and charge/discharge limits min_soc = control_signal.get("Min SOC") @@ -203,10 +203,10 @@ def update_external_control(self, control_signal): # Note: P Setpoint overrides SOC control. soc = control_signal.get("SOC") if soc is not None and "P Setpoint" not in control_signal: - self.power_setpoint = self.get_setpoint_from_soc(soc) - return "On" if self.power_setpoint != 0 else "Off" + power_setpoint = self.get_setpoint_from_soc(soc) + self.current_schedule[f"{self.end_use} Electric Power (kW)"] = power_setpoint - return super().update_external_control(control_signal) + return super().parse_control_signal(control_signal) def get_setpoint_from_soc(self, soc): power_dc = (soc - self.soc) * self.capacity_kwh / self.time_res_hours # in kW, DC @@ -214,9 +214,9 @@ def get_setpoint_from_soc(self, soc): power_setpoint = power_dc / efficiency if power_dc > 0 else power_dc * efficiency return power_setpoint - def update_internal_control(self): + def run_internal_control(self): # get setpoint from self-consumption or power setpoint - super().update_internal_control() + super().run_internal_control() if f"{self.end_use} Min SOC (-)" in self.current_schedule: self.soc_min_ctrl = self.current_schedule[f"{self.end_use} Min SOC (-)"] @@ -256,7 +256,7 @@ def update_internal_control(self): if self.power_setpoint < 0 and self.soc <= self.soc_min: self.power_setpoint = 0 - return "On" if self.power_setpoint != 0 else "Off" + self.on_frac_new = 1 if self.power_setpoint != 0 else 0 def get_kwh_remaining(self, discharge=True, include_efficiency=True, max_power=None): # returns the remaining SOC, in units of kWh. Option for remaining charging/discharging diff --git a/ochre/Equipment/EV.py b/ochre/Equipment/EV.py index 3ece4a5..35d824f 100644 --- a/ochre/Equipment/EV.py +++ b/ochre/Equipment/EV.py @@ -218,7 +218,7 @@ def end_event(self): end_soc = next_event['start_soc'] + self.max_power * EV_EFFICIENCY * next_event['duration'] / self.capacity self.event_schedule.loc[self.event_index, 'end_soc'] = np.clip(end_soc, 0, 1) - def update_external_control(self, control_signal): + def parse_control_signal(self, control_signal): # Options for external control signals: # - P Setpoint: Directly sets power setpoint, in kW # - SOC: Solves for power setpoint to achieve desired SOC, unitless @@ -226,7 +226,7 @@ def update_external_control(self, control_signal): # - Max Power: Updates maximum allowed power (in kW) # - Note: Max Power will only be reset if it is in the schedule # - Max SOC: Maximum SOC limit for charging - # - See additional controls in EventBasedLoad.update_external_control + # - See additional controls in EventBasedLoad.parse_control_signal max_power = control_signal.get("Max Power") if max_power is not None: @@ -242,7 +242,7 @@ def update_external_control(self, control_signal): else: self.soc_max_ctrl = max_soc - mode = super().update_external_control(control_signal) + super().parse_control_signal(control_signal) # update power setpoint directly or through SOC or SOC Rate if 'P Setpoint' in control_signal: @@ -257,22 +257,15 @@ def update_external_control(self, control_signal): else: setpoint = None + # Set the setpoint through max power parameter - always resets if setpoint is not None: - setpoint = max(setpoint, 0) - if mode != 'On' and setpoint > 0: - self.warn('Cannot set power when not parked.') - elif self.enable_part_load: - self.setpoint_power = setpoint - else: + setpoint = min(max(setpoint, 0), self.max_power) + if not self.enable_part_load: # set to max power if setpoint > half of max - self.setpoint_power = ( - self.max_power if setpoint >= self.max_power / 2 else 0 - ) - - return mode + setpoint = self.max_power if setpoint >= self.max_power / 2 else 0 + self.current_schedule["EV Max Power (kW)"] = setpoint - def update_internal_control(self): - self.setpoint_power = None + def run_internal_control(self): self.unmet_load = 0 # update control parameters from schedule @@ -281,18 +274,17 @@ def update_internal_control(self): if "EV Max SOC (-)" in self.current_schedule: self.soc_max_ctrl = self.current_schedule["EV Max SOC (-)"] - return super().update_internal_control() + super().run_internal_control() def calculate_power_and_heat(self): # Note: this is copied from the battery model, but they are not linked at all - if self.mode == 'Off': + if not self.on_frac_new: return super().calculate_power_and_heat() # force ac power within kw capacity and SOC limits, no discharge allowed hours = self.time_res.total_seconds() / 3600 - max_power = self.setpoint_power if self.setpoint_power is not None else self.max_power_ctrl ac_power = (self.soc_max_ctrl - self.soc) * self.capacity / hours / EV_EFFICIENCY - ac_power = min(max(ac_power, 0), max_power) + ac_power = min(max(ac_power, 0), max(self.max_power_ctrl, 0)) self.electric_kw = ac_power # update SOC for next time step, check with upper and lower bound of usable SOC diff --git a/ochre/Equipment/Equipment.py b/ochre/Equipment/Equipment.py index affff26..8c7ceb4 100644 --- a/ochre/Equipment/Equipment.py +++ b/ochre/Equipment/Equipment.py @@ -11,21 +11,20 @@ class Equipment(Simulator): end_use = 'Other' is_electric = True is_gas = False - modes = ['On', 'Off'] # On and Off assumed as default modes zone_name = 'Indoor' - def __init__(self, zone_name=None, envelope_model=None, ext_time_res=None, save_ebm_results=False, **kwargs): + def __init__(self, zone_name=None, envelope_model=None, save_ebm_results=False, **kwargs): """ - Base class for all equipment in a dwelling. - All equipment must have: - - A set of modes (default is ['On', 'Off']) + Base class for all equipment in a dwelling. All equipment must have: - Fuel variables (by default, is_electric=True, is_gas=False) - - A control algorithm to determine the mode (update_internal_control) + - A control algorithm to turn on (run_internal_control) - A method to determine the power and heat outputs (calculate_power_and_heat) + Optional features for equipment include: - - A control algorithm to use for external control (update_external_control) + - Control parameters provided by an external control (parse_control_signal) - A ZIP model for voltage-dependent real and reactive power - A parameters file to get loaded as self.parameters + Equipment can use data from: - The dwelling schedule (or from a player file) - Any other information from the dwelling (passed through house_args) @@ -63,22 +62,16 @@ def __init__(self, zone_name=None, envelope_model=None, ext_time_res=None, save_ self.sensible_gain = 0 # in W self.latent_gain = 0 # in W - # Mode and controller parameters (assuming a duty cycle) - self.mode = 'Off' - self.time_in_mode = dt.timedelta(minutes=0) - # self.tot_mode_counters = {mode: dt.timedelta(minutes=0) for mode in self.modes} - self.mode_cycles = {mode: 0 for mode in self.modes} + # Mode and controller parameters + self.on_frac = 0 # fraction of time on (0-1) + self.on_frac_new = 0 # fraction of time on (0-1) + self.time_on = dt.timedelta(minutes=0) # time continuously on + self.time_off = dt.timedelta(minutes=0) # time continuously off + self.cycles = 0 # Minimum On/Off Times - on_time = kwargs.get(self.end_use + ' Minimum On Time', 0) - off_time = kwargs.get(self.end_use + ' Minimum Off Time', 0) - self.min_time_in_mode = {mode: dt.timedelta(minutes=on_time) for mode in self.modes} - self.min_time_in_mode['Off'] = dt.timedelta(minutes=off_time) - - self.ext_time_res = ext_time_res - self.ext_mode_counters = {mode: dt.timedelta(minutes=0) for mode in self.modes} - self.duty_cycle_by_mode = {mode: 0 for mode in self.modes} # fraction of time per mode, should sum to 1 - self.duty_cycle_by_mode['Off'] = 1 + self.min_on_time = kwargs.get(self.end_use + ' Minimum On Time', 0) + self.min_off_time = kwargs.get(self.end_use + ' Minimum Off Time', 0) def initialize_parameters(self, parameter_file=None, name_col='Name', value_col='Value', **kwargs): if parameter_file is None: @@ -95,61 +88,16 @@ def initialize_parameters(self, parameter_file=None, name_col='Name', value_col= parameters.update({key: val for key, val in kwargs.items() if key in parameters}) return parameters - def update_duty_cycles(self, *duty_cycles): - duty_cycles = list(duty_cycles) - if len(duty_cycles) == len(self.modes) - 1: - duty_cycles.append(1 - sum(duty_cycles)) - if len(duty_cycles) != len(self.modes): - raise OCHREException('Error parsing duty cycles. Expected a list of length equal or 1 less than ' + - 'the number of modes ({}): {}'.format(len(self.modes), duty_cycles)) - - self.duty_cycle_by_mode = dict(zip(self.modes, duty_cycles)) - - def calculate_mode_priority(self, *duty_cycles): - """ - Calculates the mode priority based on duty cycles from external controller. Always prioritizes current mode - first. Other modes are prioritized based on the order of Equipment.modes. Excludes modes that have already - "used up" their time in the external control cycle. - :param duty_cycles: iterable of duty cycles from external controller, as decimals. Order should follow the order - of Equipment.modes. Length of list must be equal to or 1 less than the number of modes. If length is 1 less, the - final mode duty cycle is equal to 1 - sum(duty_cycles). - :return: list of mode names in order of priority - """ - if self.ext_time_res is None: - raise OCHREException('External control time resolution is not defined for {}.'.format(self.name)) - if duty_cycles: - self.update_duty_cycles(*duty_cycles) - - if (self.current_time - self.start_time) % self.ext_time_res == 0 or \ - sum(self.ext_mode_counters.values(), dt.timedelta(0)) >= self.ext_time_res: - # reset mode counters - self.ext_mode_counters = {mode: dt.timedelta(minutes=0) for mode in self.modes} - - modes_with_time = [mode for mode in self.modes - if self.ext_mode_counters[mode] / self.ext_time_res < self.duty_cycle_by_mode[mode]] - - # move previous mode to top of priority list - if self.mode in modes_with_time and modes_with_time[0] != self.mode: - modes_with_time.pop(modes_with_time.index(self.mode)) - modes_with_time = [self.mode] + modes_with_time - - if not len(modes_with_time): - self.warn('No available modes, keeping the current mode. ' - 'Duty cycles: {}; Time per mode: {}'.format(duty_cycles, self.ext_mode_counters)) - modes_with_time.append(self.mode) - - return modes_with_time - - def update_external_control(self, control_signal): + def parse_control_signal(self, control_signal): # Overwrite if external control might exist raise OCHREException('Must define external control algorithm for {}'.format(self.name)) - def update_internal_control(self): - # Returns the equipment mode; can return None if the mode doesn't change - # Overwrite if internal control exists - raise NotImplementedError() + def run_internal_control(self): + # Set the equipment on fraction (0 or 1); can set to None if there is no change + self.on_frac_new = None def calculate_power_and_heat(self): + # Sets equipment power and thermal gains to zone raise NotImplementedError() def add_gains_to_zone(self): @@ -179,34 +127,40 @@ def run_zip(self, v, v0=1): self.reactive_kvar = self.electric_kw * pf_mult * zip_p.dot(v_quadratic) self.electric_kw = self.electric_kw * zip_q.dot(v_quadratic) - def update_model(self, control_signal=None): - # run equipment controller to determine mode - if control_signal: - mode = self.update_external_control(control_signal) + def update_mode_times(self): + # updates mode times + if self.on_frac_new: + self.time_on += self.time_res * self.on_frac_new + if not self.on_frac: + self.time_off = dt.timedelta(minutes=0) + # increase number of cycles if equipment was off and turns on + self.cycles += 1 else: - mode = self.update_internal_control() - - if mode is not None and self.time_in_mode < self.min_time_in_mode[self.mode]: - # Don't change mode if minimum on/off time isn't met - mode = self.mode - - # Get voltage, if disconnected then set mode to off - voltage = self.current_schedule.get('Voltage (-)', 1) - if voltage == 0: - mode = 'Off' + self.time_off += self.time_res + if self.on_frac: + self.time_on = dt.timedelta(minutes=0) - if mode is None or mode == self.mode: - self.time_in_mode += self.time_res - else: - if mode not in self.modes: - raise OCHREException( - "Can't set {} mode to {}. Valid modes are: {}".format(self.name, mode, self.modes)) - self.mode = mode - self.time_in_mode = self.time_res - self.mode_cycles[self.mode] += 1 + def update_model(self, control_signal=None): + # update equipment based on control signal if control_signal: - self.ext_mode_counters[self.mode] += self.time_res + self.parse_control_signal(control_signal) + + # run equipment controller and set equipment mode (self.on_frac_new) + self.run_internal_control() + + # Keep existing on fraction if not defined or if minimum time limit isn't reached + if self.on_frac_new is None: + self.on_frac_new = self.on_frac + elif not self.on_frac_new and self.time_on < self.min_on_time: + self.on_frac_new = self.on_frac + elif self.on_frac_new and self.time_off < self.min_off_time: + self.on_frac_new = self.on_frac + + # Get voltage, if disconnected then set to off + voltage = self.current_schedule.get("Voltage (-)", 1) + if voltage == 0: + self.on_frac_new = 0 # calculate electric and gas power and heat gains heat_data = self.calculate_power_and_heat() @@ -248,6 +202,14 @@ def make_equivalent_battery_model(self): # f'{self.results_name} EBM Discharge Efficiency (-)': 1, } + def update_results(self): + current_results = super().update_results() + + self.update_mode_times() + self.on_frac = self.on_frac_new + + return current_results + def generate_results(self): results = super().generate_results() @@ -261,18 +223,23 @@ def generate_results(self): results[f'{self.results_name} Gas Power (therms/hour)'] = self.gas_therms_per_hour if self.verbosity >= 6: - results[f'{self.results_name} Mode'] = self.mode + results[f'{self.results_name} On-Time Fraction (-)'] = self.on_frac + + if self.save_ebm_results: + results.update(self.make_equivalent_battery_model()) return results - def reset_time(self, start_time=None, mode=None, **kwargs): - # TODO: option to remove equipment mode, set initial state + def reset_time(self, start_time=None, on_previous=False, **kwargs): + # TODO: option to set initial state super().reset_time(start_time=start_time, **kwargs) - if mode is not None: - self.mode = mode + # set previous mode, defaults to off + self.on_frac = int(on_previous) - self.time_in_mode = dt.timedelta(minutes=0) - self.mode_cycles = {mode: 0 for mode in self.modes} - self.ext_mode_counters = {mode: dt.timedelta(minutes=0) for mode in self.modes} - # self.tot_mode_counters = {mode: dt.timedelta(minutes=0) for mode in self.modes} + # reset mode times and cycles + if start_time is not None and start_time != self.start_time: + self.warn("Resetting mode times and number of cycles") + self.time_on = dt.timedelta(minutes=0) + self.time_off = dt.timedelta(minutes=0) + self.cycles = 0 diff --git a/ochre/Equipment/EventBasedLoad.py b/ochre/Equipment/EventBasedLoad.py index 47de2bd..09c6d7d 100644 --- a/ochre/Equipment/EventBasedLoad.py +++ b/ochre/Equipment/EventBasedLoad.py @@ -125,14 +125,14 @@ def end_event(self): # # function to set next event start and end time # raise NotImplementedError - def update_external_control(self, control_signal): + def parse_control_signal(self, control_signal): # If Delay=dt.timedelta, extend start time by that time # If Delay=True, delay for self.time_res # If Delay=int, delay for int * self.time_res if 'Delay' in control_signal: delay = control_signal['Delay'] - if delay and self.mode == 'On': + if delay and self.on_frac: self.warn('Ignoring delay signal, event has already started.') delay = False if isinstance(delay, (int, bool)): @@ -141,33 +141,31 @@ def update_external_control(self, control_signal): raise OCHREException(f'Unknown delay for {self.name}: {delay}') if delay: + self.event_start += delay + if self.delay_event_end: self.event_end += delay else: # ensure that start time doesn't exceed end time - if self.event_start + delay > self.event_end: + if self.event_start > self.event_end: self.warn('Event is delayed beyond event end time. Ignoring event.') - delay = self.event_end - self.event_start - - self.event_start += delay - - return self.update_internal_control() + self.event_start = self.event_end - def update_internal_control(self): + def run_internal_control(self): if self.current_time < self.event_start: # waiting for next event to start - return 'Off' + self.on_frac_new = 0 elif self.current_time < self.event_end: - if self.mode == 'Off': + if not self.on_frac: self.start_event() - return 'On' + self.on_frac_new = 1 else: # event has ended, move to next event self.end_event() - return 'Off' + self.on_frac_new = 0 def calculate_power_and_heat(self): - if self.mode == 'On': + if self.on_frac_new: power = self.event_schedule.loc[self.event_index, 'power'] else: power = 0 diff --git a/ochre/Equipment/Generator.py b/ochre/Equipment/Generator.py index 2eb8ffd..b0c4dca 100644 --- a/ochre/Equipment/Generator.py +++ b/ochre/Equipment/Generator.py @@ -63,7 +63,7 @@ def __init__( self.import_limit = self.parameters.get("import_limit", 0) self.export_limit = self.parameters.get("export_limit", 0) - def update_external_control(self, control_signal): + def parse_control_signal(self, control_signal): # Options for external control signals: # - P Setpoint: Directly sets power setpoint, in kW # - Note: still subject to SOC limits and charge/discharge limits @@ -91,15 +91,9 @@ def update_external_control(self, control_signal): # Note: this overrides self consumption mode, it will always set the setpoint directly power_setpoint = control_signal.get("P Setpoint") if power_setpoint is not None: - if f"{self.end_use} Electric Power (kW)" in self.current_schedule: - self.current_schedule[f"{self.end_use} Electric Power (kW)"] = power_setpoint + self.current_schedule[f"{self.end_use} Electric Power (kW)"] = power_setpoint - self.power_setpoint = power_setpoint - return "On" if self.power_setpoint != 0 else "Off" - - return self.update_internal_control() - - def update_internal_control(self): + def run_internal_control(self): if f"{self.end_use} Max Import Limit (kW)" in self.current_schedule: self.import_limit = self.current_schedule[f"{self.end_use} Max Import Limit (kW)"] if f"{self.end_use} Max Export Limit (kW)" in self.current_schedule: @@ -122,7 +116,7 @@ def update_internal_control(self): f"{self.end_use} Electric Power (kW)", 0 ) - return "On" if self.power_setpoint != 0 else "Off" + self.on_frac_new = 1 if self.power_setpoint != 0 else 0 def get_power_limits(self): # Minimum (i.e. generating) output power limit based on capacity and ramp rate @@ -178,12 +172,12 @@ def calculate_efficiency(self, electric_kw=None, is_output_power=True): ) def calculate_power_and_heat(self): - if self.mode == "Off": - self.electric_kw = 0 - else: + if self.on_frac_new: # force ac power within limits min_power, max_power = self.get_power_limits() self.electric_kw = min(max(self.power_setpoint, min_power), max_power) + else: + self.electric_kw = 0 # calculate input (gas) power and CHP power self.efficiency = self.calculate_efficiency() diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index 4064ab9..477a9b5 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -5,7 +5,7 @@ from ochre.utils import OCHREException, convert, load_csv from ochre.utils.units import kwh_to_therms import ochre.utils.equipment as utils_equipment -from ochre.Equipment import Equipment +from ochre.Equipment import ThermostaticLoad SPEED_TYPES = { 1: 'Single', @@ -18,7 +18,7 @@ rho_air = 1.2041 # kg/m^3 -class HVAC(Equipment): +class HVAC(ThermostaticLoad): """ Base HVAC Equipment Class. Options for static and ideal capacity. `end_use` must be specified in child classes. @@ -27,9 +27,8 @@ class HVAC(Equipment): same time step. """ name = 'Generic HVAC' - n_speeds = 1 - def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): + def __init__(self, envelope_model=None, **kwargs): # HVAC type (Heating or Cooling) if self.end_use == 'HVAC Heating': self.is_heater = True @@ -40,12 +39,18 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): else: raise OCHREException(f'HVAC type for {self.name} Equipment must be "Heating" or "Cooling".') + # Get number of speeds + self.n_speeds = kwargs.get("Number of Speeds (-)", 1) + # Building envelope parameters - required for calculating ideal capacity # FUTURE: For now, require envelope model. In future, could use ext_model to provide all schedule values assert self.zone_name == 'Indoor' and envelope_model is not None - self.envelope_model = envelope_model - super().__init__(envelope_model=envelope_model, **kwargs) + # Use ideal capacity mode for variable speed equipment + if self.n_speeds >= 4: + kwargs["use_ideal_mode"] = True + + super().__init__(thermal_model=envelope_model, envelope_model=envelope_model, **kwargs) # Capacity parameters self.speed_idx = 1 # speed index, 0=Off, 1=lowest speed, max=n_speeds @@ -121,7 +126,7 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): # Duct location and distribution system efficiency (DSE) ducts = kwargs.get('Ducts', {'DSE (-)': 1}) self.duct_dse = ducts.get('DSE (-)') # Duct distribution system efficiency - self.duct_zone = self.envelope_model.zones.get(ducts.get('Zone')) + self.duct_zone = self.thermal_model.zones.get(ducts.get('Zone')) if self.duct_dse is None: # Calculate DSE using ASHRAE 152 self.duct_dse = utils_equipment.calculate_duct_dse(self, ducts, **kwargs) @@ -131,7 +136,7 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): self.duct_zone = None # basement zone heat fraction - basement_zone = self.envelope_model.zones.get('Foundation') + basement_zone = self.thermal_model.zones.get('Foundation') if basement_zone: default_basement_frac = 0.2 if basement_zone.zone_type == 'Finished Basement' and self.is_heater else 0 self.basement_heat_frac = kwargs.get('Basement Airflow Ratio (-)', default_basement_frac) @@ -169,7 +174,6 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): # Thermostat Control Parameters self.temp_setpoint = initial_setpoint self.temp_deadband = kwargs.get('Deadband Temperature (C)', 1) - 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.ext_capacity = None # Option to set capacity directly, ideal capacity only @@ -180,13 +184,7 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): # if main simulator, add envelope as sub simulator if self.main_simulator: - self.sub_simulators.append(self.envelope_model) - - # Use ideal or static/dynamic capacity depending on time resolution and number of speeds - # 4 speeds are used for variable speed equipment, which must use ideal capacity - if use_ideal_capacity is None: - use_ideal_capacity = self.time_res >= dt.timedelta(minutes=5) or self.n_speeds >= 4 - self.use_ideal_capacity = use_ideal_capacity + self.sub_simulators.append(self.thermal_model) def initialize_schedule(self, schedule=None, **kwargs): # Compile all HVAC required inputs @@ -200,39 +198,20 @@ def initialize_schedule(self, schedule=None, **kwargs): return super().initialize_schedule(schedule, required_inputs=required_inputs, **kwargs) - def update_external_control(self, control_signal): + def parse_control_signal(self, control_signal): # Options for external control signals: - # - Load Fraction: 1 (no effect) or 0 (forces HVAC off) - # - Setpoint: Updates heating (cooling) setpoint temperature from the dwelling schedule (in C) - # - Note: Setpoint must be provided every timestep or it will revert back to the dwelling schedule - # - Deadband: Updates heating (cooling) deadband temperature (in C) - # - Only resets if it is in the schedule # - Capacity: Sets HVAC capacity directly, ideal capacity only # - Resets every time step # - Max Capacity Fraction: Limits HVAC max capacity, ideal capacity only # - Only resets if it is in the schedule - # - 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 - - ext_setpoint = control_signal.get('Setpoint') - if ext_setpoint is not None: - self.current_schedule[f'{self.end_use} Setpoint (C)'] = ext_setpoint - - ext_db = control_signal.get('Deadband') - if ext_db is not None: - if f'{self.end_use} Deadband (C)' in self.current_schedule: - self.current_schedule[f'{self.end_use} Deadband (C)'] = ext_db - else: - self.temp_deadband = ext_db + # - Other signals, see ThermostaticLoad.parse_control_signal capacity_frac = control_signal.get('Max Capacity Fraction') if capacity_frac is not None: - if not self.use_ideal_capacity: + if not self.use_ideal_mode: raise IOError( f"Cannot set {self.name} Max Capacity Fraction. " - 'Set `use_ideal_capacity` to True or control "Duty Cycle".' + 'Set `use_ideal_mode` to True.' ) if f"{self.end_use} Max Capacity Fraction (-)" in self.current_schedule: self.current_schedule[f"{self.end_use} Max Capacity Fraction (-)"] = capacity_frac @@ -241,70 +220,23 @@ def update_external_control(self, control_signal): capacity = control_signal.get('Capacity') if capacity is not None: - if not self.use_ideal_capacity: + if not self.use_ideal_mode: raise IOError( f"Cannot set {self.name} Capacity. " - 'Set `use_ideal_capacity` to True or control "Duty Cycle".' + 'Set `use_ideal_mode` to True.' ) if f"{self.end_use} Capacity (W)" in self.current_schedule: self.current_schedule[f"{self.end_use} Capacity (W)"] = capacity else: self.ext_capacity = capacity - # If load fraction = 0, force off - load_fraction = control_signal.get("Load Fraction", 1) - if load_fraction == 0: - self.ext_capacity = 0 - elif load_fraction != 1: - raise OCHREException(f"{self.name} can't handle non-integer load fractions") - - 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.' - ) - duty_cycles = self.parse_duty_cycles(control_signal) - return self.run_duty_cycle_control(duty_cycles) - - return self.update_internal_control() - - def parse_duty_cycles(self, control_signal): - return control_signal.get('Duty Cycle', 0) - - def run_duty_cycle_control(self, duty_cycles): - if duty_cycles == 0: - self.speed_idx = 0 - return 'Off' - if duty_cycles == 1: - self.speed_idx = self.n_speeds # max speed - return 'On' - - # Parse duty cycles - if isinstance(duty_cycles, (int, float)): - duty_cycles = [duty_cycles] - assert 0 <= sum(duty_cycles) <= 1 + super().parse_control_signal(control_signal) - # Set mode based on duty cycle from external controller - mode_priority = self.calculate_mode_priority(*duty_cycles) - self.update_setpoint() - 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 - - return mode - - def update_internal_control(self): + def run_internal_control(self): # Update setpoint from schedule self.update_setpoint() - if self.use_ideal_capacity: + if self.use_ideal_mode: # 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 self.capacity = self.update_capacity() @@ -316,27 +248,15 @@ def update_internal_control(self): return self.run_thermostat_control() def update_setpoint(self): - t_set = self.current_schedule[f'{self.end_use} Setpoint (C)'] - if f'{self.end_use} Deadband (C)' in self.current_schedule: - self.temp_deadband = self.current_schedule[f'{self.end_use} Deadband (C)'] - - # updates setpoint with ramp rate constraints - # TODO: create temp_setpoint_old and update in update_results. - # Could get run multiple times per time step in update_model - if self.setpoint_ramp_rate is not None: - delta_t = self.setpoint_ramp_rate * self.time_res.total_seconds() / 60 # in C - self.temp_setpoint = min(max(t_set, self.temp_setpoint - delta_t), self.temp_setpoint + delta_t) - else: - self.temp_setpoint = t_set + super().update_setpoint() # set envelope comfort limits - if self.envelope_model is not None: - if self.is_heater: - self.envelope_model.heating_setpoint = self.temp_setpoint - self.envelope_model.heating_deadband = self.temp_deadband - else: - self.envelope_model.cooling_setpoint = self.temp_setpoint - self.envelope_model.cooling_deadband = self.temp_deadband + if self.is_heater: + self.thermal_model.heating_setpoint = self.temp_setpoint + self.thermal_model.heating_deadband = self.temp_deadband + else: + self.thermal_model.cooling_setpoint = self.temp_setpoint + self.thermal_model.cooling_deadband = self.temp_deadband def run_thermostat_control(self, setpoint=None): if setpoint is None: @@ -357,7 +277,7 @@ def run_thermostat_control(self, setpoint=None): else: return None - def solve_ideal_capacity(self): + def run_ideal_control(self): # Update capacity using ideal algorithm - maintains setpoint exactly x_desired = self.temp_setpoint @@ -368,7 +288,7 @@ def solve_ideal_capacity(self): zone_ratios = list(self.zone_fractions.values()) # Note: h_desired should be equal to self.delivered_heat - h_desired = self.envelope_model.solve_for_inputs(self.zone.t_idx, zone_idxs, x_desired, zone_ratios) # in W + h_desired = self.thermal_model.solve_for_inputs(self.zone.t_idx, zone_idxs, x_desired, zone_ratios) # in W # Account for fan power and SHR - slightly different for heating/cooling # assumes SHR and EIR from previous time step @@ -378,9 +298,9 @@ def solve_ideal_capacity(self): return -h_desired / (self.shr - self.eir * self.fan_power_ratio) def update_capacity(self): - if self.use_ideal_capacity: + if self.use_ideal_mode: # Solve for capacity to meet setpoint - self.capacity_ideal = self.solve_ideal_capacity() + self.capacity_ideal = self.run_ideal_control() capacity = self.capacity_ideal # Update from direct capacity controls @@ -453,7 +373,7 @@ def update_shr(self): return shr def update_fan_power(self, capacity): - if self.use_ideal_capacity: + if self.use_ideal_mode: # Update fan power as proportional to power (power = capacity * eir) return capacity * self.eir * self.fan_power_ratio else: @@ -565,7 +485,7 @@ def make_equivalent_battery_model(self): f'{self.end_use} EBM Max Energy (kWh)': total_capacitance * (max_temp - ref_temp) * self.hvac_mult, f'{self.end_use} EBM Max Power (kW)': self.capacity_max * self.eir / 1000, f'{self.end_use} EBM Efficiency (-)': 1 / self.eir, - f'{self.end_use} EBM Baseline Power (kW)': self.capacity_ideal if self.use_ideal_capacity else None, + f'{self.end_use} EBM Baseline Power (kW)': self.capacity_ideal if self.use_ideal_mode else None, } @@ -661,9 +581,6 @@ class DynamicHVAC(HVAC): """ def __init__(self, control_type='Time', **kwargs): - # Get number of speeds - self.n_speeds = kwargs.get('Number of Speeds (-)', 1) - # 2-speed control type and timing variables self.control_type = control_type # 'Time', 'Time2', or 'Setpoint' self.disable_speeds = np.zeros(self.n_speeds, dtype=bool) # if True, disable that speed @@ -743,7 +660,7 @@ def initialize_biquad_params(self, **kwargs): return biquad_params - def update_external_control(self, control_signal): + def parse_control_signal(self, control_signal): # Options for external control signals: # - Disable Speed X: if True, disables speed X (for 2 speed control, X=1 or 2) # - Note: Can be used for ideal equipment (reduces max capacity) or dynamic equipment @@ -751,7 +668,7 @@ def update_external_control(self, control_signal): for idx in range(self.n_speeds): self.disable_speeds[idx] = bool(control_signal.get(f'Disable Speed {idx + 1}')) - return super().update_external_control(control_signal) + return super().parse_control_signal(control_signal) def run_two_speed_control(self): mode = super().run_thermostat_control() # Can be On, Off, or None @@ -764,7 +681,7 @@ def run_two_speed_control(self): prev_speed_idx = self.speed_idx if self.control_type == 'Time': # Time-based 2-speed HVAC control: High speed turns on if temp continues to drop (for heating) - if self.mode == 'Off': + if not self.on_frac: speed = 1 elif self.hvac_mult * (self.zone.temperature - self.temp_indoor_prev) < 0: speed = 2 @@ -772,7 +689,7 @@ def run_two_speed_control(self): speed = self.speed_idx # elif self.control_type == 'Time-old': # # Time-based 2-speed HVAC control: High speed turns on if temp continues to drop (for heating) - # if self.mode == 'Off': + # if not self.on: # speed_idx = 0 # elif self.hvac_mult * (self.zone.temperature - self.temp_indoor_prev) < 0: # speed_idx = 1 @@ -789,7 +706,7 @@ def run_two_speed_control(self): speed = self.speed_idx elif self.control_type == 'Time2': # Old time-based 2-speed HVAC control - if self.mode == 'Off': + if not self.on_frac: speed = 1 else: speed = 2 @@ -813,7 +730,7 @@ def run_two_speed_control(self): return mode def run_thermostat_control(self, setpoint=None): - if self.use_ideal_capacity: + if self.use_ideal_mode: raise OCHREException('Ideal capacity equipment should not be running a thermostat control.') if self.n_speeds == 1: @@ -869,7 +786,7 @@ def update_capacity(self): max_speed = np.nonzero(~ self.disable_speeds)[0][-1] + 1 self.capacity_max = self.calculate_biquadratic_param(param='cap', speed_idx=max_speed) - if self.use_ideal_capacity: + if self.use_ideal_mode: # determine capacity for each speed, check that capacity_ratio increases with speed capacities = [self.calculate_biquadratic_param(param='cap', speed_idx=speed) for speed in range(self.n_speeds + 1)] @@ -940,7 +857,7 @@ def calculate_power_and_heat(self): # add crankcase power when AC is off and outdoor temp is below threshold # no impact on sensible heat for now if self.crankcase_kw: - if self.mode == 'Off' and self.current_schedule['Ambient Dry Bulb (C)'] < self.crankcase_temp: + if not self.on_frac and self.current_schedule['Ambient Dry Bulb (C)'] < self.crankcase_temp: self.electric_kw += self.crankcase_kw * self.space_fraction @@ -1019,7 +936,7 @@ def update_capacity(self): # Update actual capacity and max allowable capacity self.capacity_max = self.capacity_max * defrost_capacity_mult - q_defrost - if self.use_ideal_capacity: + if self.use_ideal_mode: capacity = min(capacity, self.capacity_max * self.ext_capacity_frac) else: capacity = capacity * defrost_capacity_mult - q_defrost @@ -1050,11 +967,6 @@ class ASHPHeater(HeatPumpHeater): optional_inputs = Heater.optional_inputs + [ "HVAC Heating ER Capacity (W)", "HVAC Heating Max ER Capacity Fraction (-)", - "HVAC Heating ER Duty Cycle (-)", - # - 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 ] def __init__(self, **kwargs): @@ -1077,20 +989,20 @@ def __init__(self, **kwargs): 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) - def update_external_control(self, control_signal): + def parse_control_signal(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 - # - ER Duty Cycle: Combines with "Duty Cycle" control, see HVAC.update_external_control + # - Only resets if in the schedule capacity_frac = control_signal.get("Max ER Capacity Fraction") if capacity_frac is not None: - if not self.use_ideal_capacity: + if not self.use_ideal_mode: raise IOError( f"Cannot set {self.name} Max ER Capacity Fraction. " - 'Set `use_ideal_capacity` to True or control "ER Duty Cycle".' + 'Set `use_ideal_mode` to True.' ) if f"{self.end_use} Max ER Capacity Fraction (-)" in self.current_schedule: self.current_schedule[f"{self.end_use} Max ER Capacity Fraction (-)"] = capacity_frac @@ -1099,35 +1011,21 @@ def update_external_control(self, control_signal): capacity = control_signal.get("ER Capacity") if capacity is not None: - if not self.use_ideal_capacity: + if not self.use_ideal_mode: raise IOError( f"Cannot set {self.name} ER Capacity. " - 'Set `use_ideal_capacity` to True or control "ER Duty Cycle".' + 'Set `use_ideal_mode` to True.' ) if f"{self.end_use} Capacity (W)" in self.current_schedule: self.current_schedule[f"{self.end_use} Capacity (W)"] = capacity else: self.er_ext_capacity = capacity - return super().update_external_control(control_signal) - - def parse_duty_cycles(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) - 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: - duty_cycles = [hp_duty_cycle, 0, er_duty_cycle, 1 - er_duty_cycle - hp_duty_cycle] - assert sum(duty_cycles) == 1 - return duty_cycles + return super().parse_control_signal(control_signal) - def update_internal_control(self): - if self.use_ideal_capacity: - # Note: not calling super().update_internal_control + def run_internal_control(self): + if self.use_ideal_mode: + # Note: not calling super().run_internal_control # Update setpoint from schedule self.update_setpoint() @@ -1139,7 +1037,7 @@ def update_internal_control(self): er_on = er_capacity > 0 else: # get HP and ER modes separately - hp_mode = super().update_internal_control() + hp_mode = super().run_internal_control() hp_on = hp_mode in ['On', 'HP On'] if hp_mode is not None else 'HP' in self.mode er_mode = self.run_er_thermostat_control() er_on = er_mode == 'On' if er_mode is not None else 'ER' in self.mode @@ -1179,7 +1077,7 @@ def run_er_thermostat_control(self): return 'Off' def update_er_capacity(self, hp_capacity): - if self.use_ideal_capacity: + if self.use_ideal_mode: if self.er_ext_capacity is not None: er_capacity = self.er_ext_capacity else: @@ -1209,7 +1107,7 @@ def update_fan_power(self, 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 self.use_ideal_mode and 'ER' in self.mode: if 'HP' in self.mode: fixed_fan_power = self.fan_power_max else: diff --git a/ochre/Equipment/PV.py b/ochre/Equipment/PV.py index b0bb9fb..9e4c685 100644 --- a/ochre/Equipment/PV.py +++ b/ochre/Equipment/PV.py @@ -150,14 +150,13 @@ def initialize_schedule(self, schedule=None, equipment_schedule_file=None, locat return super().initialize_schedule(schedule, equipment_schedule_file, **kwargs) - def update_external_control(self, control_signal): + def parse_control_signal(self, control_signal): # External PV control options: # - P/Q Setpoint: set P and Q directly from external controller (assumes positive = consuming) # - P Curtailment: set P by specifying curtailment in kW or % # - Power Factor: set Q based on P setpoint (internal or external) and given power factor # - Priority: set inverter_priority to 'Watt' or 'Var' - - self.update_internal_control() + p_max = self.current_schedule[self.electric_name] # Update P from external control if 'P Setpoint' in control_signal: @@ -165,24 +164,29 @@ def update_external_control(self, control_signal): if p_set > 0: self.warn('Setpoint should be negative (i.e. generating power). Reversing sign to be negative.') p_set *= -1 - # if p_set < self.p_set_point - 0.1: + # if p_set < p_max - 0.1: # # Print warning if setpoint is significantly larger than max power - # self.warn('Setpoint ({}) is larger than max power ({})'.format(p_set, self.p_set_point)) - self.p_set_point = max(self.p_set_point, p_set) + # self.warn('Setpoint ({}) is larger than max power ({})'.format(p_set, p_max)) + self.current_schedule["PV P Setpoint (kW)"] = p_set elif 'P Curtailment (kW)' in control_signal: - p_curt = min(max(control_signal['P Curtailment (kW)'], 0), -self.p_set_point) - self.p_set_point += p_curt + p_curt = max(control_signal['P Curtailment (kW)'], 0) + p_set = p_max + p_curt + self.current_schedule["PV P Setpoint (kW)"] = min(p_set, 0) elif 'P Curtailment (%)' in control_signal: pct_curt = min(max(control_signal['P Curtailment (%)'], 0), 100) - self.p_set_point *= 1 - pct_curt / 100 + p_set = p_max * (1 - pct_curt) / 100 + self.current_schedule["PV P Setpoint (kW)"] = p_set + else: + p_set = p_max # Update Q from external control if 'Q Setpoint' in control_signal: - self.q_set_point = control_signal['Q Setpoint'] + self.current_schedule["PV Q Setpoint (kW)"] = control_signal["Q Setpoint"] elif 'Power Factor' in control_signal: # Note: power factor should be negative for generating P/consuming Q pf = control_signal['Power Factor'] - self.q_set_point = ((1 / pf ** 2) - 1) ** 0.5 * self.p_set_point * (pf / abs(pf)) + q_set = ((1 / pf**2) - 1) ** 0.5 * p_set * (pf / abs(pf)) + self.current_schedule["PV Q Setpoint (kW)"] = q_set if 'Priority' in control_signal: priority = control_signal['Priority'] @@ -191,14 +195,17 @@ def update_external_control(self, control_signal): else: self.warn(f'Invalid priority type: {priority}') - return 'On' if self.p_set_point != 0 else 'Off' + def run_internal_control(self): + # Set P to maximum power from schedule + super().run_internal_control() + + # Update P and Q from setpoints + if "PV P Setpoint (kW)" in self.current_schedule: + p_set = self.current_schedule["PV P Setpoint (kW)"] + self.p_set_point = max(self.p_set_point, p_set) # min(abs(value)), both are negative + self.q_set_point = self.current_schedule.get("PV Q Setpoint (kW)", 0) - def update_internal_control(self): - # Set to maximum P, Q=0 - super().update_internal_control() - self.p_set_point = min(self.p_set_point, 0) - self.q_set_point = 0 - return 'On' if self.p_set_point < 0 else 'Off' + self.on_frac_new = 1 if self.p_set_point < 0 else 0 def calculate_power_and_heat(self): super().calculate_power_and_heat() diff --git a/ochre/Equipment/ScheduledLoad.py b/ochre/Equipment/ScheduledLoad.py index ef6e22c..45b08f1 100644 --- a/ochre/Equipment/ScheduledLoad.py +++ b/ochre/Equipment/ScheduledLoad.py @@ -78,29 +78,27 @@ def initialize_schedule(self, schedule=None, equipment_schedule_file=None, return super().initialize_schedule(schedule, required_inputs=required_inputs, **kwargs) - def update_external_control(self, control_signal): + def parse_control_signal(self, control_signal): # Control options for changing power: # - Load Fraction: gets multiplied by power from schedule, unitless (applied to electric AND gas) # - P Setpoint: overwrites electric power from schedule, in kW # - Gas Setpoint: overwrites gas power from schedule, in therms/hour - self.update_internal_control() - load_fraction = control_signal.get('Load Fraction') if load_fraction is not None: - self.p_set_point *= load_fraction - self.gas_set_point *= load_fraction + if self.is_electric: + self.current_schedule[self.electric_name] *= load_fraction + if self.is_gas: + self.current_schedule[self.gas] *= load_fraction p_set_ext = control_signal.get('P Setpoint') - if p_set_ext is not None: - self.p_set_point = p_set_ext + if p_set_ext is not None and self.is_electric: + self.current_schedule[self.electric_name] = p_set_ext gas_set_ext = control_signal.get('Gas Setpoint') - if gas_set_ext is not None: - self.gas_set_point = gas_set_ext - - return 'On' if self.p_set_point + self.gas_set_point != 0 else 'Off' + if gas_set_ext is not None and self.is_gas: + self.current_schedule[self.gas] = gas_set_ext - def update_internal_control(self): + def run_internal_control(self): if self.is_electric: self.p_set_point = self.current_schedule[self.electric_name] if abs(self.p_set_point) > 20: @@ -115,10 +113,10 @@ def update_internal_control(self): if abs(self.gas_set_point) > 1: raise OCHREException(f'{self.name} gas power is too large: {self.gas_set_point} therms/hour.') - return 'On' if self.p_set_point + self.gas_set_point != 0 else 'Off' + self.on_frac_new = 1 if self.p_set_point + self.gas_set_point != 0 else 0 def calculate_power_and_heat(self): - if self.mode == 'On': + if self.on_frac_new: self.electric_kw = self.p_set_point if self.is_electric else 0 self.gas_therms_per_hour = self.gas_set_point if self.is_gas else 0 else: diff --git a/ochre/Equipment/ThermostaticLoad.py b/ochre/Equipment/ThermostaticLoad.py new file mode 100644 index 0000000..aa068ed --- /dev/null +++ b/ochre/Equipment/ThermostaticLoad.py @@ -0,0 +1,274 @@ +import datetime as dt + +from ochre.utils import OCHREException +from ochre.utils.units import kwh_to_therms +from ochre.Equipment import Equipment + + +class ThermostaticLoad(Equipment): + setpoint_deadband_position = 0.5 # setpoint at midpoint of deadband + is_heater = True + heat_mult = 1 # 1=heating, -1=cooling + + def __init__(self, thermal_model=None, use_ideal_mode=None, prevent_overshoot=True, **kwargs): + """ + Equipment that controls a StateSpaceModel using thermostatic control + methods. Equipment thermal capacity and power may be controlled + through three modes: + + - 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 desired setpoint. Power is determined by the fraction + of time that the equipment is on in various modes. + + - Thermostatic mode without overshoot: First, the thermostatic mode + is used. If the temperature exceeds the deadband, then the ideal + mode is used to achieve at temperature exactly at the edge of the + deadband. + + """ + + super().__init__(**kwargs) + + self.thermal_model = thermal_model + self.sub_simulators.append(self.thermal_model) + + # Model parameters + self.t_control_idx = 0 # state index for thermostat control + self.h_control_idx = 0 # input index for thermostat control + + # By default, use ideal mode if time resolution >= 15 minutes + if use_ideal_mode is None: + use_ideal_mode = self.time_res >= dt.timedelta(minutes=15) + self.use_ideal_mode = use_ideal_mode + + # By default, prevent overshoot in tstat mode + self.prevent_overshoot = prevent_overshoot + self.on_at_end = False + if self.use_ideal_mode and not self.prevent_overshoot: + self.warn( + "Ignoring prevent_overshoot when running in Ideal Mode. Update prevent_overshoot or use_ideal_mode." + ) + + # Setpoint and deadband parameters + self.temp_setpoint = kwargs['Setpoint Temperature (C)'] + self.temp_setpoint_old = self.temp_setpoint + self.setpoint_ramp_rate = kwargs.get('Max Setpoint Ramp Rate (C/min)') # max setpoint ramp rate, in C/min + self.temp_deadband_range = kwargs.get('Deadband Temperature (C)', 5.56) # deadband range, in delta degC, i.e. Kelvin + self.temp_deadband_on = None + self.temp_deadband_off = None + self.set_deadband_limits() + + # Other control parameters + self.max_power = kwargs.get('Max Power (kW)') + self.force_off = False + + # Capacity and heat output parameters + self.capacity_rated = kwargs['Capacity (W)'] # maximum heat delivered, in W + self.capacity = 0 # heat output from main element, in W + self.delivered_heat = 0 # total heat delivered to the model, in W + + # Efficiency parameters + self.efficiency = kwargs.get('Efficiency (-)', 1) # unitless + + def set_deadband_limits(self): + self.temp_deadband_off = self.temp_setpoint + (1 - self.setpoint_deadband_position) * self.temp_deadband_range * self.heat_mult + self.temp_deadband_on = self.temp_setpoint - self.setpoint_deadband_position * self.temp_deadband_range * self.heat_mult + + def parse_control_signal(self, control_signal): + # Options for external control signals: + # - Load Fraction: 1 (no effect) or 0 (forces WH off) + # - Setpoint: Updates setpoint temperature from the default (in C) + # - Note: Setpoint will only reset back to default value when {'Setpoint': None} is passed. + # - Deadband: Updates deadband temperature (in C) + # - Note: Deadband will only be reset if it is in the schedule + # - Max Power: Updates maximum allowed power (in kW) + # - Note: Max Power will only be reset if it is in the schedule + # - Note: Will not work for HPWH in HP mode + + ext_setpoint = control_signal.get("Setpoint") + if ext_setpoint is not None: + if f"{self.end_use} Setpoint (C)" in self.current_schedule: + self.current_schedule[f"{self.end_use} Setpoint (C)"] = ext_setpoint + else: + self.temp_setpoint = ext_setpoint + + ext_db = control_signal.get("Deadband") + if ext_db is not None: + if f"{self.end_use} Deadband (C)" in self.current_schedule: + self.current_schedule[f"{self.end_use} Deadband (C)"] = ext_db + else: + self.temp_deadband_range = ext_db + + max_power = control_signal.get("Max Power") + if max_power is not None: + if f"{self.end_use} Max Power (kW)" in self.current_schedule: + self.current_schedule[f"{self.end_use} Max Power (kW)"] = max_power + else: + self.max_power = max_power + + # If load fraction = 0, force off (max power = 0) + load_fraction = control_signal.get("Load Fraction", 1) + if load_fraction == 0: + self.current_schedule[f"{self.end_use} Max Power (kW)"] = 0 + elif load_fraction != 1: + raise OCHREException(f"{self.name} can't handle non-integer load fractions") + + def update_setpoint(self): + # TODO + update_deadband_temps = False + + # get setpoint from schedule + if f"{self.end_use} Setpoint (C)" in self.current_schedule: + self.temp_setpoint = self.current_schedule[f"{self.end_use} Setpoint (C)"] + + # constrain setpoint based on max ramp rate + # TODO: create temp_setpoint_old (or new?) and update in update_results. + # Could get run multiple times per time step in update_model + if self.setpoint_ramp_rate and self.temp_setpoint != self.temp_setpoint_old: + delta_t = self.setpoint_ramp_rate * self.time_res.total_seconds() / 60 # in C + self.temp_setpoint = min( + max(self.temp_setpoint, self.temp_setpoint_old - delta_t), + self.temp_setpoint_old + delta_t, + ) + + # get other controls from schedule - deadband and max power + if f"{self.end_use} Deadband (C)" in self.current_schedule: + self.temp_deadband_range = self.current_schedule[f"{self.end_use} Deadband (C)"] + if f"{self.end_use} Max Power (kW)" in self.current_schedule: + self.max_power = self.current_schedule[f"{self.end_use} Max Power (kW)"] + + def solve_ideal_capacity(self, setpoint=None): + # Solve thermal model for input heat injection to achieve setpoint + if setpoint is None: + setpoint = self.temp_setpoint + + return self.thermal_model.solve_for_input( + self.t_control_idx, + self.h_control_idx, + setpoint, + solve_as_output=False, + ) + + def run_ideal_control(self, setpoint=None): + # FUTURE: capacity update is done twice per loop, could but updated to improve speed + + # Solve for ideal capacity + self.capacity = self.solve_ideal_capacity(setpoint) + + # constraint capacity + self.capacity = min(max(self.capacity, 0), self.capacity_rated) + + # return on fraction + return self.capacity / self.capacity_rated + + def solve_deadband_mode(self, t_control=None, on_limit=None, off_limit=None): + if t_control is None: + t_control = self.thermal_model.states[self.t_control_idx] + if on_limit is None: + on_limit = self.temp_deadband_on + if off_limit is None: + off_limit = self.temp_deadband_off + + if (t_control - on_limit) * self.heat_mult < 0: + # turn on if below the on limit (for heaters) + return 1 + elif (t_control - off_limit) * self.heat_mult > 0: + # turn off if below the off limit (for heaters) + return 0 + else: + # maintains existing mode + return None + + def run_thermostat_control(self): + # use thermostat with deadband control + on_frac_new = self.solve_deadband_mode() + + if on_frac_new is None: + on_frac_new = self.on_frac + + # Set capacity from on fraction + self.capacity = on_frac_new * self.capacity_rated + + return on_frac_new + + def limit_overshoot(self): + # check if deadband limits are hit + next_on_frac = self.solve_deadband_mode() + if next_on_frac is None: + return + + # run in ideal mode at deadband limits + if not self.on_frac_new and next_on_frac == 1: + # reached lower deadband limit (for heaters), turn on at end of time step + self.on_frac_new = self.run_ideal_control(setpoint=self.temp_deadband_on) + self.on_at_end = True + elif self.on_frac_new and next_on_frac == 0: + # reached upper deadband limit (for heaters), turn off at end of time step + self.on_frac_new = self.run_ideal_control(setpoint=self.temp_deadband_off) + self.on_at_end = False + else: + # Note: deadband limits may not be met and unmet loads may exist + pass + + def run_internal_control(self): + # Update setpoint from schedule + self.update_setpoint() + + # update the on fraction and the output capacity + if self.use_ideal_mode: + self.on_frac_new = self.run_ideal_control() + else: + # Run thermostat controller and set on fraction and output capacity + self.on_frac_new = self.run_thermostat_control() + + # check new states and prevent overshoot by running in ideal mode + if self.prevent_overshoot: + # get thermal model inputs and update model + heat_data = self.calculate_power_and_heat() + super().update_model(heat_data) + + # check for overshoot, limit on fraction + self.limit_overshoot() + + def get_heat_to_model(self): + return {self.h_control_idx: self.delivered_heat} + + def calculate_power_and_heat(self): + self.delivered_heat = self.capacity + + # calculate power based on efficiency + power = self.capacity / self.efficiency / 1000 # in kW + + # clip power and heat by max power + if self.max_power is not None and power > self.max_power: + power = self.max_power + self.delivered_heat *= self.max_power / power + + # get heat injection to the thermal model, based on capacity + heat_to_model = self.get_heat_to_model() + + if self.is_gas: + # by default, no sensible gains from gas equipment (assume vented) + self.gas_therms_per_hour = power * kwh_to_therms # in therms/hour + self.sensible_gain = 0 + else: + self.electric_kw = power + self.sensible_gain = power * 1000 - self.delivered_heat # in W + + self.latent_gain = 0 + + # send heat gain inputs to tank model + return {self.thermal_model.name: heat_to_model} + + def update_results(self): + current_results = super().update_results() + + # if overshoot correction happened, reset on_frac to 0 or 1 + if not self.use_ideal_mode and (0 < self.on_frac < 1): + self.on_frac = int(self.on_at_end) + + return current_results diff --git a/ochre/Equipment/WaterHeater.py b/ochre/Equipment/WaterHeater.py index 1474aca..898254e 100644 --- a/ochre/Equipment/WaterHeater.py +++ b/ochre/Equipment/WaterHeater.py @@ -9,14 +9,16 @@ from ochre.utils import OCHREException from ochre.utils.units import convert, kwh_to_therms -from ochre.Equipment import Equipment +from ochre.Equipment import ThermostaticLoad from ochre.Models import OneNodeWaterModel, TwoNodeWaterModel, StratifiedWaterModel, IdealWaterModel -class WaterHeater(Equipment): +class WaterHeater(ThermostaticLoad): name = 'Water Heater' end_use = 'Water Heating' default_capacity = 4500 # in W + default_deadband = 5.56 # in C + setpoint_deadband_position = 1 # setpoint at top of the deadband range optional_inputs = [ "Water Heating Setpoint (C)", "Water Heating Deadband (C)", @@ -24,7 +26,7 @@ class WaterHeater(Equipment): "Zone Temperature (C)", # Needed for Water tank model ] - def __init__(self, use_ideal_capacity=None, model_class=None, **kwargs): + def __init__(self, model_class=None, **kwargs): # Create water tank model if model_class is None: nodes = kwargs.get('water_nodes', 2) @@ -41,39 +43,19 @@ def __init__(self, use_ideal_capacity=None, model_class=None, **kwargs): 'name': None, **kwargs.get('Water Tank', {}), } - self.model = model_class(**water_tank_args) + thermal_model = model_class(**water_tank_args) - super().__init__(**kwargs) - - self.sub_simulators.append(self.model) - - # By default, use ideal capacity if time resolution > 5 minutes - if use_ideal_capacity is None: - use_ideal_capacity = self.time_res >= dt.timedelta(minutes=5) - self.use_ideal_capacity = use_ideal_capacity - - # Get tank nodes for upper and lower heat injections - upper_node = '3' if self.model.n_nodes >= 12 else '1' - self.t_upper_idx = self.model.state_names.index('T_WH' + upper_node) - self.h_upper_idx = self.model.input_names.index('H_WH' + upper_node) - self.model.h_1_idx + super().__init__(thermal_model=thermal_model, **kwargs) - lower_node = '10' if self.model.n_nodes >= 12 else str(self.model.n_nodes) - self.t_lower_idx = self.model.state_names.index('T_WH' + lower_node) - self.h_lower_idx = self.model.input_names.index('H_WH' + lower_node) - self.model.h_1_idx - - # Capacity and efficiency parameters - self.efficiency = kwargs.get('Efficiency (-)', 1) # unitless - self.capacity_rated = kwargs.get('Capacity (W)', self.default_capacity) # maximum heat delivered, in W - self.delivered_heat = 0 # heat delivered to the tank, in W + # Set control node to the bottom of the tank (for gas WH or 1-node model) + lower_node = str(self.thermal_model.n_nodes) + self.t_control_idx = self.thermal_model.state_names.index("T_WH" + lower_node) + self.h_control_idx = ( + self.thermal_model.input_names.index("H_WH" + lower_node) - self.thermal_model.h_1_idx + ) # Control parameters - # note: bottom of deadband is (setpoint_temp - deadband_temp) - self.setpoint_temp = kwargs['Setpoint Temperature (C)'] - self.setpoint_temp_ext = None - self.max_temp = kwargs.get('Max Tank Temperature (C)', convert(140, 'degF', 'degC')) - self.setpoint_ramp_rate = kwargs.get('Max Setpoint Ramp Rate (C/min)') # max setpoint ramp rate, in C/min - self.deadband_temp = kwargs.get('Deadband Temperature (C)', 5.56) # deadband range, in delta degC, i.e. Kelvin - self.max_power = kwargs.get('Max Power (kW)') + self.temp_max = kwargs.get('Max Tank Temperature (C)', convert(140, 'degF', 'degC')) def update_inputs(self, schedule_inputs=None): # Add zone temperature to schedule inputs for water tank @@ -82,210 +64,52 @@ def update_inputs(self, schedule_inputs=None): super().update_inputs(schedule_inputs) - def update_external_control(self, control_signal): - # Options for external control signals: - # - Load Fraction: 1 (no effect) or 0 (forces WH off) - # - Setpoint: Updates setpoint temperature from the default (in C) - # - Note: Setpoint will only reset back to default value when {'Setpoint': None} is passed. - # - Deadband: Updates deadband temperature (in C) - # - Note: Deadband will only be reset if it is in the schedule - # - Max Power: Updates maximum allowed power (in kW) - # - Note: Max Power will only be reset if it is in the schedule - # - Note: Will not work for HPWH in HP mode - # - Duty Cycle: Forces WH on for fraction of external time step (as fraction [0,1]) - # - If 0 < Duty Cycle < 1, the equipment will cycle once every 2 external time steps - # - For HPWH: Can supply HP and ER duty cycles - # - Note: does not use clock on/off time - - ext_setpoint = control_signal.get("Setpoint") - if ext_setpoint is not None: - if ext_setpoint > self.max_temp: - self.warn( - f"Setpoint cannot exceed {self.max_temp}C. Setting setpoint to maximum value." - ) - ext_setpoint = self.max_temp - if "Water Heating Setpoint (C)" in self.current_schedule: - self.current_schedule["Water Heating Setpoint (C)"] = ext_setpoint - else: - # Note that this overrides the ramp rate - self.setpoint_temp = ext_setpoint - - ext_db = control_signal.get("Deadband") - if ext_db is not None: - if "Water Heating Deadband (C)" in self.current_schedule: - self.current_schedule["Water Heating Deadband (C)"] = ext_db - else: - self.deadband_temp = ext_db - - max_power = control_signal.get("Max Power") - if max_power is not None: - if "Water Heating Max Power (kW)" in self.current_schedule: - self.current_schedule["Water Heating Max Power (kW)"] = max_power - else: - self.max_power = max_power - - # If load fraction = 0, force off - load_fraction = control_signal.get("Load Fraction", 1) - if load_fraction == 0: - return "Off" - elif load_fraction != 1: - raise OCHREException(f"{self.name} can't handle non-integer load fractions") - - if 'Duty Cycle' in control_signal: - # Parse duty cycles into list for each mode - duty_cycles = control_signal.get('Duty Cycle') - if isinstance(duty_cycles, (int, float)): - duty_cycles = [duty_cycles] - if not isinstance(duty_cycles, list) or not (0 <= sum(duty_cycles) <= 1): - raise OCHREException('Error parsing {} duty cycle control: {}'.format(self.name, duty_cycles)) - - return self.run_duty_cycle_control(duty_cycles) - else: - return self.update_internal_control() - - def run_duty_cycle_control(self, duty_cycles): - # Force off if temperature exceeds maximum, and print warning - t_tank = self.model.states[self.t_upper_idx] - if t_tank > self.max_temp: - self.warn( - f"Temperature over maximum temperature ({self.max_temp}C), forcing off" - ) - return "Off" - - if self.use_ideal_capacity: - # Set capacity directly from duty cycle - self.update_duty_cycles(*duty_cycles) - return [mode for mode, duty_cycle in self.duty_cycle_by_mode.items() if duty_cycle > 0][0] + def solve_ideal_capacity_by_mode(self, setpoint=None, mode=None, lowest_node=None): + if setpoint is None: + setpoint = self.temp_setpoint + if lowest_node is None: + lowest_node = self.t_control_idx + # get next tank states based on mode + if mode is None: + # default is off - no heat added from water heater + self.thermal_model.update_model() else: - # Use internal mode if available, otherwise use mode with highest priority - mode_priority = self.calculate_mode_priority(*duty_cycles) - internal_mode = self.update_internal_control() - if internal_mode is None: - internal_mode = self.mode - if internal_mode in mode_priority: - return internal_mode - else: - return mode_priority[0] # take highest priority mode (usually current mode) - - def update_setpoint(self): - # get setpoint from schedule - if "Water Heating Setpoint (C)" in self.current_schedule: - t_set_new = self.current_schedule["Water Heating Setpoint (C)"] - else: - t_set_new = self.setpoint_temp - - # update setpoint with ramp rate - if self.setpoint_ramp_rate and self.setpoint_temp != t_set_new: - delta_t = self.setpoint_ramp_rate * self.time_res.total_seconds() / 60 # in C - self.setpoint_temp = min(max(t_set_new, self.setpoint_temp - delta_t), - self.setpoint_temp + delta_t, - ) - else: - self.setpoint_temp = t_set_new - - # get other controls from schedule - deadband and max power - if "Water Heating Deadband (C)" in self.current_schedule: - self.temp_deadband = self.current_schedule["Water Heating Deadband (C)"] - if "Water Heating Max Power (kW)" in self.current_schedule: - self.max_power = self.current_schedule["Water Heating Max Power (kW)"] - - def solve_ideal_capacity(self): - # calculate ideal capacity based on achieving lower node setpoint temperature - # Run model with heater off, updates next_states - self.model.update_model() - off_states = self.model.next_states - - # calculate heat needed to reach setpoint - only use nodes at and above lower node - set_states = np.ones(len(off_states)) * self.setpoint_temp - h_desired = np.dot(set_states[:self.t_lower_idx + 1] - off_states[:self.t_lower_idx + 1], # in W - self.model.capacitances[:self.t_lower_idx + 1]) / self.time_res.total_seconds() - - # Convert to duty cycle, maintain min/max bounds - duty_cycle = min(max(h_desired / self.capacity_rated, 0), 1) - self.duty_cycle_by_mode = {'On': duty_cycle, 'Off': 1 - duty_cycle} - - def run_thermostat_control(self): - # use thermostat with deadband control - if self.model.n_nodes <= 2: - t_lower = self.model.states[self.t_lower_idx] - else: + raise NotImplementedError() + next_states = self.thermal_model.next_states + + # get ideal setpoint states + set_states = np.ones(len(next_states)) * setpoint + + # get thermal energy to achieve setpoint states + delta_t = set_states[: lowest_node + 1] - next_states[: lowest_node + 1] + capacitances = self.thermal_model.capacitances[: lowest_node + 1] + q_desired = np.dot(delta_t, capacitances) # in J + + # return thermal capacity to achieve setpoint states, in W + h_desired = q_desired / self.time_res.total_seconds() + h_desired = max(h_desired, 0) + return h_desired + + def solve_ideal_capacity(self, setpoint=None): + if self.thermal_model.n_nodes == 1: + # calculate ideal capacity using tank model directly + # more accurate than code below + return super().solve_ideal_capacity(setpoint) + + # calculate heat needed to reach setpoint + # - only use nodes at and above control node + return self.solve_ideal_capacity_by_mode(setpoint) + + def solve_deadband_mode(self, t_control=None, on_limit=None, off_limit=None): + if t_control is None and self.thermal_model.n_nodes > 2: # take average of lower node and node above - t_lower = (self.model.states[self.t_lower_idx] + self.model.states[self.t_lower_idx - 1]) / 2 - - if t_lower < self.setpoint_temp - self.deadband_temp: - return 'On' - if t_lower > self.setpoint_temp: - return 'Off' - - def update_internal_control(self): - self.update_setpoint() - - if self.use_ideal_capacity: - if self.model.n_nodes == 1: - # FUTURE: remove if not being used - # calculate ideal capacity based on tank model - more accurate than self.solve_ideal_capacity - - # Solve for desired heat delivered, subtracting external gains - h_desired = self.model.solve_for_input(self.model.t_1_idx, self.model.h_1_idx, self.setpoint_temp, - solve_as_output=False) - - # Only allow heating, convert to duty cycle - h_desired = min(max(h_desired, 0), self.capacity_rated) - duty_cycle = h_desired / self.capacity_rated - self.duty_cycle_by_mode = {mode: 0 for mode in self.modes} - self.duty_cycle_by_mode[self.modes[0]] = duty_cycle - self.duty_cycle_by_mode['Off'] = 1 - duty_cycle - else: - self.solve_ideal_capacity() - - return [mode for mode, duty_cycle in self.duty_cycle_by_mode.items() if duty_cycle > 0][0] - else: - return self.run_thermostat_control() - - def add_heat_from_mode(self, mode, heats_to_tank=None, duty_cycle=1): - if heats_to_tank is None: - heats_to_tank = np.zeros(self.model.n_nodes, dtype=float) - - if mode == 'Upper On': - heats_to_tank[self.h_upper_idx] += self.capacity_rated * duty_cycle - elif mode in ['On', 'Lower On']: - # Works for 'On' or 'Lower On', treated the same - heats_to_tank[self.h_lower_idx] += self.capacity_rated * duty_cycle - - return heats_to_tank - - def calculate_power_and_heat(self): - # get heat injections from water heater - if self.use_ideal_capacity and self.mode != 'Off': - heats_to_tank = np.zeros(self.model.n_nodes, dtype=float) - for mode, duty_cycle in self.duty_cycle_by_mode.items(): - heats_to_tank = self.add_heat_from_mode(mode, heats_to_tank, duty_cycle) - else: - heats_to_tank = self.add_heat_from_mode(self.mode) - - self.delivered_heat = heats_to_tank.sum() - power = self.delivered_heat / self.efficiency / 1000 # in kW - - # clip power and heat by max power - if self.max_power and power > self.max_power and 'Heat Pump' not in self.mode: - heats_to_tank *= self.max_power / power - self.delivered_heat *= self.max_power / power - power = self.max_power - - if self.is_gas: - # note: no sensible gains from heater (all is vented) - self.gas_therms_per_hour = power * kwh_to_therms # in therms/hour - self.sensible_gain = 0 - else: - self.electric_kw = power - self.sensible_gain = power * 1000 - self.delivered_heat # in W - - self.latent_gain = 0 + t_control = 1 / 2 * ( + self.thermal_model.states[self.t_control_idx] + + self.thermal_model.states[self.t_control_idx - 1] + ) - # send heat gain inputs to tank model - # note: heat losses from tank are added to sensible gains in parse_sub_update - return {self.model.name: heats_to_tank} + return super().solve_deadband_mode(t_control, on_limit, off_limit) def finish_sub_update(self, sub): # add heat losses from model to sensible gains @@ -301,28 +125,25 @@ def generate_results(self): cop = self.delivered_heat / (self.electric_kw * 1000) if self.electric_kw > 0 else 0 results[f'{self.end_use} COP (-)'] = cop results[f'{self.end_use} Total Sensible Heat Gain (W)'] = self.sensible_gain - results[f'{self.end_use} Deadband Upper Limit (C)'] = self.setpoint_temp - results[f'{self.end_use} Deadband Lower Limit (C)'] = self.setpoint_temp - self.deadband_temp - - if self.save_ebm_results: - results.update(self.make_equivalent_battery_model()) + results[f'{self.end_use} Deadband Upper Limit (C)'] = self.temp_setpoint + results[f'{self.end_use} Deadband Lower Limit (C)'] = self.temp_setpoint - self.temp_deadband return results def make_equivalent_battery_model(self): # returns a dictionary of equivalent battery model parameters - total_cap = convert(sum(self.model.capacitances), 'J', 'kWh') # in kWh/K + total_cap = convert(sum(self.thermal_model.capacitances), 'J', 'kWh') # in kWh/K ref_temp = 0 # temperature at Energy=0, in C - if self.model.n_nodes <= 2: - tank_temp = self.model.states[self.t_lower_idx] + if self.thermal_model.n_nodes <= 2: + tank_temp = self.thermal_model.states[self.t_lower_idx] else: # take average of lower node and node above - tank_temp = (self.model.states[self.t_lower_idx] + self.model.states[self.t_lower_idx - 1]) / 2 - baseline_power = (self.model.h_loss + self.model.h_delivered) / 1000 # from conduction losses and water draw + tank_temp = (self.thermal_model.states[self.t_lower_idx] + self.thermal_model.states[self.t_lower_idx - 1]) / 2 + baseline_power = (self.thermal_model.h_loss + self.thermal_model.h_delivered) / 1000 # from conduction losses and water draw return { f'{self.end_use} EBM Energy (kWh)': total_cap * (tank_temp - ref_temp), - f'{self.end_use} EBM Min Energy (kWh)': total_cap * (self.setpoint_temp - self.deadband_temp - ref_temp), - f'{self.end_use} EBM Max Energy (kWh)': total_cap * (self.setpoint_temp - ref_temp), + f'{self.end_use} EBM Min Energy (kWh)': total_cap * (self.temp_setpoint - self.temp_deadband - ref_temp), + f'{self.end_use} EBM Max Energy (kWh)': total_cap * (self.temp_setpoint - ref_temp), f'{self.end_use} EBM Max Power (kW)': self.capacity_rated / self.efficiency / 1000, f'{self.end_use} EBM Efficiency (-)': self.efficiency, f'{self.end_use} EBM Baseline Power (kW)': baseline_power, @@ -333,76 +154,169 @@ class ElectricResistanceWaterHeater(WaterHeater): name = 'Electric Resistance Water Heater' modes = ['Upper On', 'Lower On', 'Off'] - def run_duty_cycle_control(self, duty_cycles): - if len(duty_cycles) == len(self.modes) - 2: - d_er_total = duty_cycles[-1] - if self.use_ideal_capacity: - # determine optimal allocation of upper/lower elements - self.solve_ideal_capacity() - - # keep upper duty cycle as is, update lower based on external control - d_upper = self.duty_cycle_by_mode['Upper On'] - d_lower = d_er_total - d_upper - self.duty_cycle_by_mode['Lower On'] = d_lower - self.duty_cycle_by_mode['Off'] = 1 - d_er_total + def __init__(self, model_class=None, **kwargs): + super().__init__(model_class, **kwargs) + + # Get tank nodes for upper and lower heat injections + upper_node = "3" if self.thermal_model.n_nodes >= 12 else "1" + self.t_upper_idx = self.thermal_model.state_names.index("T_WH" + upper_node) + self.h_upper_idx = ( + self.thermal_model.input_names.index("H_WH" + upper_node) - self.thermal_model.h_1_idx + ) + + lower_node = "10" if self.thermal_model.n_nodes >= 12 else str(self.thermal_model.n_nodes) + self.t_lower_idx = self.thermal_model.state_names.index("T_WH" + lower_node) + self.h_lower_idx = ( + self.thermal_model.input_names.index("H_WH" + lower_node) - self.thermal_model.h_1_idx + ) + + # Mode and control parameters - for upper and lower elements + # TODO: add these anywhere regular parameters are found + self.capacity_upper = 0 # heat output from upper element, in W + self.upper_on_frac = 0 # fraction of time on (0-1) + self.upper_on_frac_new = 0 # fraction of time on (0-1) + self.capacity_lower = 0 # heat output from lower element, in W + self.lower_on_frac = 0 # fraction of time on (0-1) + self.lower_on_frac_new = 0 # fraction of time on (0-1) + self.upper_cycles = 0 + self.lower_cycles = 0 + + def run_ideal_control(self, setpoint=None): + if self.thermal_model.n_nodes == 1: + return super().run_ideal_control(setpoint) + + # calculate heat for full tank to reach setpoint + h_total = self.solve_ideal_capacity_by_mode(setpoint, lowest_node=self.t_lower_idx) + + # calculate heat for upper portion of tank to reach setpoint + h_upper = self.solve_ideal_capacity_by_mode(setpoint, lowest_node=self.t_upper_idx) + + # constraint and save capacities + if h_total > self.capacity_rated: + # can't reach setpoint, prioritize upper element + self.capacity = self.capacity_rated + self.capacity_upper = min(h_upper, self.capacity_rated) + self.capacity_lower = self.capacity - self.capacity_upper + else: + # can reach setpoint, prioritize lower element + self.capacity = h_total + self.capacity_upper = 0 + self.capacity_lower = h_total + + # save upper/lower on fractions + self.upper_on_frac_new = self.capacity_upper / self.capacity_rated + self.lower_on_frac_new = self.capacity_lower / self.capacity_rated + + # return total on fraction + return self.capacity / self.capacity_rated + + def solve_deadband_mode(self, t_control=None, on_limit=None, off_limit=None): + if t_control is None: + if self.thermal_model.n_nodes <= 2: + # use lower index, not t_control_idx + t_control = self.thermal_model.states[self.t_lower_idx] else: - # copy duty cycle for Upper On and Lower On, and calculate Off duty cycle - duty_cycles.append(d_er_total) - duty_cycles.append(1 - sum(duty_cycles[:-1])) + # take average of lower node and node above + t_control = 1 / 2 * ( + self.thermal_model.states[self.t_lower_idx] + + self.thermal_model.states[self.t_lower_idx - 1] + ) - mode = super().run_duty_cycle_control(duty_cycles) + return super().solve_deadband_mode(t_control, on_limit, off_limit) - if not self.use_ideal_capacity: - # If duty cycle forces WH on, may need to swap to lower element - t_upper = self.model.states[self.t_upper_idx] - if mode == 'Upper On' and t_upper > self.setpoint_temp: - mode = 'Lower On' + def run_thermostat_control(self): + if self.thermal_model.n_nodes == 1: + return super().run_thermostat_control() - # If mode is ER, add time to both mode_counters - if mode == 'Upper On': - self.ext_mode_counters['Lower On'] += self.time_res - if mode == 'Lower On': - self.ext_mode_counters['Upper On'] += self.time_res + # check if upper and lower tank temperatures are within deadband + self.lower_on_frac_new = self.solve_deadband_mode() + t_upper = self.thermal_model.states[self.t_upper_idx] + self.upper_on_frac_new = self.solve_deadband_mode(t_upper) + + # update on fractions from last time step + if self.upper_on_frac_new is None: + self.upper_on_frac_new = self.upper_on_frac + if self.lower_on_frac_new is None: + self.lower_on_frac_new = self.lower_on_frac + + # prioritize upper element if both should be on + if self.upper_on_frac_new and self.lower_on_frac_new: + self.lower_on_frac_new = 0 + + # Set capacities from on fraction + on_frac_new = self.upper_on_frac_new + self.lower_on_frac_new + self.capacity = on_frac_new * self.capacity_rated + self.capacity_upper = self.upper_on_frac_new * self.capacity_rated + self.capacity_lower = self.lower_on_frac_new * self.capacity_rated + + # return main on fraction + return on_frac_new + + def limit_overshoot(self): + if self.thermal_model.n_nodes == 1: + return super().limit_overshoot() + + # check if upper or lower deadband limits are hit + lower_on_frac_new = self.solve_deadband_mode() + t_upper = self.thermal_model.states[self.t_upper_idx] + upper_on_frac_new = self.solve_deadband_mode(t_upper) + + if lower_on_frac_new is None and upper_on_frac_new is None: + # no overshoot, no change in mode + return + elif lower_on_frac_new == 1 and upper_on_frac_new == 0: + # determine upper on fraction at deadband off temp + self.on_frac_new = self.run_ideal_control(setpoint=self.temp_deadband_off) + upper_on_tmp = self.upper_on_frac_new + + # determine lower on fraction at deadband on temp + self.on_frac_new = self.run_ideal_control(setpoint=self.temp_deadband_on) + + # reset upper on fraction and capacity + # may overestimate lower tank temp + self.upper_on_frac_new = upper_on_tmp + self.capacity_upper = self.upper_on_frac_new * self.capacity_rated + self.on_frac_new = self.upper_on_frac_new + self.lower_on_frac_new + self.capacity = self.on_frac_new * self.capacity_rated + if self.capacity > self.capacity_rated: + # reduce lower on fraction + self.on_frac_new = 1 + self.capacity = self.capacity_rated + self.capacity_lower = self.capacity - self.capacity_upper + self.lower_on_frac_new = 1 - self.upper_on_frac_new + + elif lower_on_frac_new == 1: + # reached lower deadband limit, turn lower on at end of time step + self.on_frac_new = self.run_ideal_control(setpoint=self.temp_deadband_on) + self.on_at_end = True + + elif upper_on_frac_new == 0: + # reached upper deadband limit, turn off at end of time step + self.on_frac_new = self.run_ideal_control(setpoint=self.temp_deadband_off) + self.on_at_end = False + else: + # unexpected, print warning and do nothing + self.warn(f"Unexpected on fractions for upper ({upper_on_frac_new}) " + f"and lower ({lower_on_frac_new}) elements") - return mode + def add_heat_from_mode(self, mode, heats_to_tank=None, pct_time_on=1): + if heats_to_tank is None: + heats_to_tank = np.zeros(self.thermal_model.n_nodes, dtype=float) - def solve_ideal_capacity(self): - # calculate ideal capacity based on upper and lower node setpoint temperatures - # Run model with heater off - self.model.update_model() - off_states = self.model.next_states - - # calculate heat needed to reach setpoint - only use nodes at and above upper/lower nodes - set_states = np.ones(len(off_states)) * self.setpoint_temp - h_total = np.dot(set_states[:self.t_lower_idx + 1] - off_states[:self.t_lower_idx + 1], # in W - self.model.capacitances[:self.t_lower_idx + 1]) / self.time_res.total_seconds() - h_upper = np.dot(set_states[:self.t_upper_idx + 1] - off_states[:self.t_upper_idx + 1], # in W - self.model.capacitances[:self.t_upper_idx + 1]) / self.time_res.total_seconds() - h_lower = h_total - h_upper - - # Convert to duty cycle, maintain min/max bounds, upper gets priority - d_upper = min(max(h_upper / self.capacity_rated, 0), 1) - d_lower = min(max(h_lower / self.capacity_rated, 0), 1 - d_upper) - self.duty_cycle_by_mode = {'Upper On': d_upper, 'Lower On': d_lower, 'Off': 1 - d_upper - d_lower} + if mode == "Upper On": + heats_to_tank[self.h_upper_idx] += self.capacity_rated * pct_time_on + elif mode in ["On", "Lower On"]: + # Works for 'On' or 'Lower On', treated the same + heats_to_tank[self.h_lower_idx] += self.capacity_rated * pct_time_on - def run_thermostat_control(self): - # use thermostat with deadband control, upper element gets priority over lower element - t_upper = self.model.states[self.t_upper_idx] - if self.model.n_nodes <= 2: - t_lower = self.model.states[self.t_lower_idx] - else: - # take average of lower node and node above - t_lower = (self.model.states[self.t_lower_idx] + self.model.states[self.t_lower_idx - 1]) / 2 - - lower_threshold_temp = self.setpoint_temp - self.deadband_temp - if t_upper < lower_threshold_temp or (self.mode == 'Upper On' and t_upper < self.setpoint_temp): - return 'Upper On' - if t_lower < lower_threshold_temp: - return 'Lower On' - if self.mode == 'Upper On' and t_upper > self.setpoint_temp: - return 'Off' - if t_lower > self.setpoint_temp: - return 'Off' + return heats_to_tank + + def get_heat_to_model(self): + if self.thermal_model.n_nodes == 1: + return super().get_heat_to_model() + + return {self.h_upper_idx: self.capacity_upper, + self.h_lower_idx: self.capacity_lower} class HeatPumpWaterHeater(ElectricResistanceWaterHeater): @@ -462,14 +376,14 @@ def __init__(self, hp_only_mode=False, water_nodes=12, **kwargs): # self.wall_heat_fraction = 0 # nodes used for HP delivered heat, also used for t_lower for biquadratic equations - if self.model.n_nodes == 1: + if self.thermal_model.n_nodes == 1: self.hp_nodes = np.array([1]) - elif self.model.n_nodes == 2: + elif self.thermal_model.n_nodes == 2: self.hp_nodes = np.array([0, 1]) - elif self.model.n_nodes == 12: + elif self.thermal_model.n_nodes == 12: self.hp_nodes = np.array([0, 0, 0, 0, 0, 5, 10, 15, 20, 25, 30, 5]) / 110 else: - raise OCHREException('{} model not defined for tank with {} nodes'.format(self.name, self.model.n_nodes)) + raise OCHREException('{} model not defined for tank with {} nodes'.format(self.name, self.thermal_model.n_nodes)) def update_inputs(self, schedule_inputs=None): # Add wet and dry bulb temperatures to schedule @@ -479,40 +393,30 @@ def update_inputs(self, schedule_inputs=None): super().update_inputs(schedule_inputs) - def update_external_control(self, control_signal): - if any([dc in control_signal for dc in ['HP Duty Cycle', 'ER Duty Cycle']]): - # 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) - - def solve_ideal_capacity(self): + def run_ideal_control(self): # calculate ideal capacity based on future thermostat control if self.er_only_mode: - super().solve_ideal_capacity() + super().run_ideal_control() self.duty_cycle_by_mode['Heat Pump On'] = 0 return # Run model with heater off - self.model.update_model() - off_states = self.model.next_states.copy() + self.thermal_model.update_model() + off_states = self.thermal_model.next_states.copy() # off_mode = self.run_thermostat_control(use_future_states=True) # Run model with HP on 100% (uses capacity from last time step) - self.model.update_model(self.add_heat_from_mode('Heat Pump On')) - hp_states = self.model.next_states.copy() + self.thermal_model.update_model(self.add_heat_from_mode('Heat Pump On')) + hp_states = self.thermal_model.next_states.copy() hp_mode = self.run_thermostat_control(use_future_states=True) # aim 1/4 of deadband below setpoint to reduce temps at top of tank. - set_states = np.ones(len(off_states)) * (self.setpoint_temp - self.deadband_temp / 4) + set_states = np.ones(len(off_states)) * (self.temp_setpoint - self.deadband_temp / 4) if not self.hp_only_mode and hp_mode == 'Upper On': # determine ER duty cycle to achieve setpoint temp h_upper = np.dot(set_states[:self.t_upper_idx + 1] - hp_states[:self.t_upper_idx + 1], # in W - self.model.capacitances[:self.t_upper_idx + 1]) / self.time_res.total_seconds() + self.thermal_model.capacitances[:self.t_upper_idx + 1]) / self.time_res.total_seconds() d_upper = min(max(h_upper / self.capacity_rated, 0), 1) # force HP on for the rest of the time @@ -523,7 +427,7 @@ def solve_ideal_capacity(self): # determine HP duty cycle to achieve setpoint temp # FUTURE: check against lab data h_hp = np.dot(set_states[:self.t_lower_idx + 1] - off_states[:self.t_lower_idx + 1], # in W - self.model.capacitances[:self.t_lower_idx + 1]) / self.time_res.total_seconds() + self.thermal_model.capacitances[:self.t_lower_idx + 1]) / self.time_res.total_seconds() # using HP capacity from previous time step d_hp = min(max(h_hp / self.hp_capacity, 0), 1) @@ -541,25 +445,25 @@ def run_thermostat_control(self, use_future_states=False): self.mode = 'Off' return super().run_thermostat_control() - model_temps = self.model.states if not use_future_states else self.model.next_states + model_temps = self.thermal_model.states if not use_future_states else self.thermal_model.next_states t_upper = model_temps[self.t_upper_idx] t_lower = model_temps[self.t_lower_idx] t_control = (3 / 4) * t_upper + (1 / 4) * t_lower if not self.hp_only_mode: - if t_upper < self.setpoint_temp - 13 or (self.mode == 'Upper On' and t_upper < self.setpoint_temp): + if t_upper < self.temp_setpoint - 13 or (self.mode == 'Upper On' and t_upper < self.temp_setpoint): return 'Upper On' - elif self.mode in ['Upper On', 'Lower On'] and t_lower < self.setpoint_temp - 15: + elif self.mode in ['Upper On', 'Lower On'] and t_lower < self.temp_setpoint - 15: return 'Lower On' - if self.mode in ['Upper On', 'Lower On'] or t_control < self.setpoint_temp - self.deadband_temp: + if self.mode in ['Upper On', 'Lower On'] or t_control < self.temp_setpoint - self.deadband_temp: return 'Heat Pump On' - elif t_control >= self.setpoint_temp: + elif t_control >= self.temp_setpoint: return 'Off' - elif t_upper >= self.setpoint_temp + 1: # TODO: Could mess with this a little + elif t_upper >= self.temp_setpoint + 1: # TODO: Could mess with this a little return 'Off' - def update_internal_control(self): + def run_internal_control(self): # operate as ERWH when ambient temperatures are out of bounds t_amb = self.current_schedule['Zone Temperature (C)'] if t_amb < 7.222 or t_amb > 43.333: @@ -567,7 +471,7 @@ def update_internal_control(self): else: self.er_only_mode = False - return super().update_internal_control() + return super().run_internal_control() def add_heat_from_mode(self, mode, heats_to_tank=None, duty_cycle=1): heats_to_tank = super().add_heat_from_mode(mode, heats_to_tank, duty_cycle) @@ -577,7 +481,7 @@ def add_heat_from_mode(self, mode, heats_to_tank=None, duty_cycle=1): return heats_to_tank def update_cop_and_capacity(self, t_wet): - t_lower = np.dot(self.hp_nodes, self.model.states) # use node connected to condenser + t_lower = np.dot(self.hp_nodes, self.thermal_model.states) # use node connected to condenser vector = np.array([1, t_wet, t_wet ** 2, t_lower, t_lower ** 2, t_lower * t_wet]) self.hp_capacity = self.hp_capacity_nominal * np.dot(self.hp_capacity_coeff, vector) self.hp_cop = self.cop_nominal * np.dot(self.cop_coeff, vector) @@ -593,7 +497,7 @@ def calculate_power_and_heat(self): heats_to_model = super().calculate_power_and_heat() # get HP and ER delivered heat and power - if self.use_ideal_capacity: + if self.use_ideal_mode: d_hp = self.duty_cycle_by_mode['Heat Pump On'] d_er = self.duty_cycle_by_mode['Upper On'] + self.duty_cycle_by_mode['Lower On'] else: @@ -636,7 +540,7 @@ def finish_sub_update(self, sub): def generate_results(self): results = super().generate_results() if self.verbosity >= 6: - if self.use_ideal_capacity: + if self.use_ideal_mode: hp_on_frac = self.duty_cycle_by_mode['Heat Pump On'] else: hp_on_frac = 1 if 'Heat Pump' in self.mode else 0 @@ -667,61 +571,44 @@ def finish_sub_update(self, sub): self.sensible_gain = sub.h_loss * self.skin_loss_frac -# TODO: Tankless probably shouldn't have a WaterTank model, maybe don't inherit from TankWaterHeater? class TanklessWaterHeater(WaterHeater): name = 'Tankless Water Heater' default_capacity = 20000 # in W def __init__(self, **kwargs): - kwargs.update({'use_ideal_capacity': True, + kwargs.update({'use_ideal_mode': True, 'model_class': IdealWaterModel}) super().__init__(**kwargs) - self.heat_from_draw = 0 # Used to determine current capacity # update initial state to top of deadband (for 1-node model) - self.model.states[self.t_upper_idx] = self.setpoint_temp + self.thermal_model.states[self.t_control_idx] = self.temp_setpoint - def update_internal_control(self): - self.update_setpoint() - self.model.states[self.t_upper_idx] = self.setpoint_temp + def update_setpoint(self): + super().update_setpoint() - self.heat_from_draw = -self.model.update_water_draw()[0] - self.heat_from_draw = max(self.heat_from_draw, 0) + # set state to setpoint temperature + self.thermal_model.states[self.t_control_idx] = self.temp_setpoint - return 'On' if self.heat_from_draw > 0 else 'Off' + def solve_ideal_capacity(self, setpoint=None): + capacity = super().solve_ideal_capacity(setpoint) - def calculate_power_and_heat(self): - # clip heat by max power - power = self.heat_from_draw / self.efficiency / 1000 # in kW - if self.max_power and power > self.max_power: - self.heat_from_draw *= self.max_power / power - - if self.mode == 'Off': - # do not update heat, force water heater off - self.delivered_heat = 0 - elif self.heat_from_draw > self.capacity_rated: - # cannot meet setpoint temperature. Update outlet temp for 1 time step - t_set = self.setpoint_temp - t_mains = self.model.current_schedule['Mains Temperature (C)'] - t_outlet = t_mains + (t_set - t_mains) * (self.capacity_rated / self.heat_from_draw) - self.model.states[self.model.t_1_idx] = t_outlet - self.model.update_water_draw() - - # Reset tank model and update delivered heat - self.model.states[self.model.t_1_idx] = t_set - self.delivered_heat = self.capacity_rated - else: - self.delivered_heat = self.heat_from_draw + if capacity > self.capacity_rated: + # cannot meet setpoint temperature. Update outlet temp for unmet loads + t_set = self.temp_setpoint + t_mains = self.thermal_model.current_schedule['Mains Temperature (C)'] + t_outlet = t_mains + (t_set - t_mains) * (self.capacity_rated / self.capacity) + self.thermal_model.states[self.thermal_model.t_1_idx] = t_outlet + self.thermal_model.update_water_draw() - self.electric_kw = self.delivered_heat / self.efficiency / 1000 + return capacity - # for now, no extra heat gains for tankless water heater - # self.sensible_gain = self.delivered_heat * (1 / self.efficiency - 1) - self.sensible_gain = 0 + def update_results(self): + current_results = super().update_results() - # send heat gain inputs to tank model - # note: heat losses from tank are added to sensible gains in update_results - return {self.model.name: np.array([self.delivered_heat])} + # Reset tank model to setpoint temperature + self.thermal_model.states[self.thermal_model.t_1_idx] = self.temp_setpoint + + return current_results class GasTanklessWaterHeater(TanklessWaterHeater): @@ -737,15 +624,7 @@ def __init__(self, **kwargs): def calculate_power_and_heat(self): heats_to_model = super().calculate_power_and_heat() - # gas power in therms/hour - power_kw = self.delivered_heat / self.efficiency / 1000 - self.gas_therms_per_hour = power_kw * kwh_to_therms - - # electric power is constant + # add constant parasitic power self.electric_kw = self.parasitic_power - # if self.mode == 'On': - # self.electric_kw = 65 / 1000 # hardcoded parasitic electric power - # else: - # self.electric_kw = 5 / 1000 # hardcoded electric power return heats_to_model \ No newline at end of file diff --git a/ochre/Equipment/__init__.py b/ochre/Equipment/__init__.py index 144514a..ec3ca05 100644 --- a/ochre/Equipment/__init__.py +++ b/ochre/Equipment/__init__.py @@ -1,5 +1,6 @@ from .Equipment import Equipment from .ScheduledLoad import ScheduledLoad, LightingLoad +from .ThermostaticLoad import ThermostaticLoad from .EventBasedLoad import EventBasedLoad, DailyLoad from .HVAC import HVAC, Heater, ElectricFurnace, ElectricBaseboard, ElectricBoiler, GasFurnace, GasBoiler,\ HeatPumpHeater, ASHPHeater, MinisplitAHSPHeater, Cooler, AirConditioner, ASHPCooler, RoomAC, MinisplitAHSPCooler diff --git a/test/test_dwelling/test_dwelling.py b/test/test_dwelling/test_dwelling.py index 076b4b4..cc9d668 100644 --- a/test/test_dwelling/test_dwelling.py +++ b/test/test_dwelling/test_dwelling.py @@ -14,7 +14,6 @@ 'start_time': dt.datetime(2019, 5, 5, 12, 0, 0), # May 5, 12:00PM 'time_res': dt.timedelta(minutes=15), 'duration': dt.timedelta(days=1), - 'ext_time_res': dt.timedelta(hours=1), # Input and Output Files 'output_path': test_output_path, @@ -27,7 +26,7 @@ 'metrics_verbosity': 9, # verbosity of results file (0-9) } -test_equipment = {'Air Source Heat Pump': {'use_ideal_capacity': True}, +test_equipment = {'Air Source Heat Pump': {'use_ideal_mode': True}, } @@ -166,9 +165,9 @@ def test_update(self): self.assertAlmostEqual(results['Total Reactive Power (kVAR)'], 0.3, places=1) self.assertAlmostEqual(results['Lighting Electric Power (kW)'], 0.1, places=1) self.assertAlmostEqual(results['Temperature - Indoor (C)'], 22.2, places=1) - self.assertEqual(results['HVAC Heating Mode'], 'Off') - self.assertEqual(results['HVAC Cooling Mode'], 'On') - self.assertEqual(results['Water Heating Mode'], 'Upper On') + self.assertEqual(results["HVAC Heating On-Time Fraction (-)"], 0) + self.assertEqual(results["HVAC Cooling On-Time Fraction (-)"], 1) + self.assertEqual(results["Water Heating On-Time Fraction (-)"], 1) for e in self.dwelling.equipment: self.assertEquals(e.current_time, self.dwelling.current_time) @@ -178,18 +177,10 @@ def test_update(self): self.assertAlmostEqual(results['Total Reactive Power (kVAR)'], 0, places=2) self.assertAlmostEqual(results['Temperature - Indoor (C)'], 22.2, places=1) self.assertAlmostEqual(self.dwelling.envelope.indoor_zone.temperature, 23.1, places=1) - self.assertEqual(results['HVAC Cooling Mode'], 'Off') + self.assertEqual(results["HVAC Cooling On-Time Fraction (-)"], 0) for e in self.dwelling.equipment: self.assertEquals(e.current_time, self.dwelling.current_time) - def test_update_external(self): - control = {'HVAC Heating': {'Duty Cycle': 0.3}, 'Load Fractions': {'Lighting': 0, 'Exterior Lighting': 0}} - results = self.dwelling.update(control_signal=control) - self.assertEqual(results['HVAC Heating Mode'], 'HP On') - self.assertAlmostEqual(results['Total Electric Power (kW)'], 12, places=0) - self.assertAlmostEqual(results['Total Reactive Power (kVAR)'], 0.3, places=1) - self.assertEqual(results['Lighting Electric Power (kW)'], 0) - def test_simulate(self): t0 = time.time() df, metrics, hourly = self.dwelling.simulate() diff --git a/test/test_equipment/__init__.py b/test/test_equipment/__init__.py index 8468248..7164ed9 100644 --- a/test/test_equipment/__init__.py +++ b/test/test_equipment/__init__.py @@ -4,7 +4,6 @@ 'start_time': dt.datetime(2019, 4, 1), 'duration': dt.timedelta(days=1), 'time_res': dt.timedelta(minutes=1), - 'ext_time_res': dt.timedelta(minutes=15), 'initial_schedule': {}, 'schedule': {}, 'zip_model': {'Test Equipment': {'pf': 0.9, 'pf_inductive': True, diff --git a/test/test_equipment/test_battery.py b/test/test_equipment/test_battery.py index adde5a0..a9f7dab 100644 --- a/test/test_equipment/test_battery.py +++ b/test/test_equipment/test_battery.py @@ -42,39 +42,39 @@ def test_init(self): self.assertAlmostEqual(self.battery.voc_curve(0.5), 3.7, places=1) self.assertAlmostEqual(self.battery.uneg_curve(0.5), 0.12, places=2) - def test_update_external_control(self): + def test_parse_control_signal(self): # test SOC Rate control - self.battery.update_external_control({}, {'SOC Rate': 0.2}) + self.battery.parse_control_signal({}, {'SOC Rate': 0.2}) self.assertAlmostEqual(self.battery.power_setpoint, 2.077, places=3) - self.battery.update_external_control({}, {'SOC Rate': -0.2}) + self.battery.parse_control_signal({}, {'SOC Rate': -0.2}) self.assertAlmostEqual(self.battery.power_setpoint, -1.926, places=3) - def test_update_internal_control(self): + def test_run_internal_control(self): # test self-consumption with charge_from_solar self.battery.control_type = 'Self-Consumption' self.battery.parameters['charge_from_solar'] = 1 - mode = self.battery.update_internal_control({'net_power': -1}) - self.assertEqual(mode, 'Off') + mode = self.battery.run_internal_control({'net_power': -1}) + self.assertEqual(mode, 0) self.assertAlmostEqual(self.battery.power_setpoint, 0) - mode = self.battery.update_internal_control({'net_power': -1, 'pv_power': -2}) - self.assertEqual(mode, 'On') + mode = self.battery.run_internal_control({'net_power': -1, 'pv_power': -2}) + self.assertEqual(mode, 1) self.assertAlmostEqual(self.battery.power_setpoint, 1) - mode = self.battery.update_internal_control({'net_power': -2, 'pv_power': -1}) - self.assertEqual(mode, 'On') + mode = self.battery.run_internal_control({'net_power': -2, 'pv_power': -1}) + self.assertEqual(mode, 1) self.assertAlmostEqual(self.battery.power_setpoint, 1) self.battery.parameters['charge_from_solar'] = 0 - mode = self.battery.update_internal_control({'net_power': -2, 'pv_power': -1}) - self.assertEqual(mode, 'On') + mode = self.battery.run_internal_control({'net_power': -2, 'pv_power': -1}) + self.assertEqual(mode, 1) self.assertAlmostEqual(self.battery.power_setpoint, 2) # test SOC limits self.battery.soc = self.battery.soc_max - mode = self.battery.update_internal_control({'net_power': -1}) - self.assertEqual(mode, 'Off') + mode = self.battery.run_internal_control({'net_power': -1}) + self.assertEqual(mode, 0) self.assertEqual(self.battery.power_setpoint, 0) def test_get_power_limits(self): @@ -96,7 +96,7 @@ def test_get_power_limits(self): def test_calculate_power_and_heat(self): self.battery.soc = 0.5 self.battery.power_setpoint = -10 - self.battery.mode = 'On' + self.battery.on_frac = 1 self.battery.calculate_power_and_heat({}) self.assertAlmostEqual(self.battery.capacity_kwh_nominal, self.battery.parameters['capacity_kwh']) self.assertAlmostEqual(self.battery.capacity_kwh, self.battery.parameters['capacity_kwh']) @@ -145,7 +145,7 @@ def test_update_model(self): # test SOC limit self.battery.soc = self.battery.soc_max - 0.005 self.battery.power_setpoint = 5 - self.battery.mode = 'On' + self.battery.on_frac = 1 self.battery.calculate_power_and_heat({}) self.assertAlmostEquals(self.battery.electric_kw, 3.1, places=1) self.battery.update_model({}) @@ -185,7 +185,7 @@ def test_init(self): def test_calculate_power_and_heat(self): self.battery.soc = 0.5 self.battery.power_setpoint = -15 - self.battery.mode = 'On' + self.battery.on_frac = 1 self.battery.thermal_model.states[0] = 20 self.battery.calculate_power_and_heat(update_args) self.assertAlmostEqual(self.battery.capacity_kwh_nominal, self.battery.parameters['capacity_kwh']) diff --git a/test/test_equipment/test_equipment.py b/test/test_equipment/test_equipment.py index d30a135..e65f757 100644 --- a/test/test_equipment/test_equipment.py +++ b/test/test_equipment/test_equipment.py @@ -15,15 +15,15 @@ def __init__(self, max_p, **kwargs): self.max_p = max_p - def update_internal_control(self): + def run_internal_control(self): # Turns on for 5 minutes, then off for 5 minutes if self.current_time.minute % 10 >= 5: - return 'Off' + self.on_frac_new = 0 else: - return 'On' + self.on_frac_new = 1 def calculate_power_and_heat(self): - if self.mode == 'On': + if self.on_frac_new: self.electric_kw = min(self.current_time.minute, self.max_p) else: self.electric_kw = 0 @@ -38,8 +38,7 @@ def setUp(self): self.equipment = TestEquipment(15, **equip_init_args) def test_initialize(self): - self.assertEqual(self.equipment.mode, 'Off') - self.assertListEqual(self.equipment.modes, ['On', 'Off']) + self.assertEqual(self.equipment.mode, 0) self.assertEqual(self.equipment.current_time, equip_init_args['start_time']) self.assertEqual(self.equipment.zone_name, 'Indoor') self.assertDictEqual(self.equipment.parameters, {}) @@ -58,9 +57,8 @@ def test_update(self): self.equipment.update(1, {}, {}) self.equipment.update_model({}) self.assertEqual(self.equipment.current_time, equip_init_args['start_time'] + equip_init_args['time_res'] * 3) - self.assertEqual(self.equipment.mode, 'On') - self.assertEqual(self.equipment.time_in_mode, equip_init_args['time_res'] * 3) - self.assertDictEqual(self.equipment.mode_cycles, {'On': 1, 'Off': 0}) + self.assertEqual(self.equipment.on_frac, 1) + self.assertEqual(self.equipment.time_on, equip_init_args['time_res'] * 3) self.assertEqual(self.equipment.electric_kw, 2) # run for 5 time steps @@ -68,40 +66,39 @@ def test_update(self): self.equipment.update(1, {}, {}) self.equipment.update_model({}) self.assertEqual(self.equipment.current_time, equip_init_args['start_time'] + equip_init_args['time_res'] * 8) - self.assertEqual(self.equipment.mode, 'Off') - self.assertEqual(self.equipment.time_in_mode, equip_init_args['time_res'] * 3) - self.assertDictEqual(self.equipment.mode_cycles, {'On': 1, 'Off': 1}) + self.assertEqual(self.equipment.mode, 0) + self.assertEqual(self.equipment.time_on, equip_init_args['time_res'] * 3) self.assertEqual(self.equipment.electric_kw, 0) # Test with minimum on/off times - self.equipment.mode = 'On' - self.equipment.time_in_mode = dt.timedelta(minutes=0) - self.equipment.min_time_in_mode = {'On': dt.timedelta(minutes=2), - 'Off': dt.timedelta(minutes=2)} + self.equipment.on_frac = 1 + self.equipment.time_on = dt.timedelta(minutes=0) + self.equipment.min_time_in_mode = {1: dt.timedelta(minutes=2), + 0: dt.timedelta(minutes=2)} self.equipment.update(1, {}, {}) - self.assertEqual(self.equipment.mode, 'On') - self.assertEqual(self.equipment.time_in_mode, equip_init_args['time_res']) + self.assertEqual(self.equipment.on_frac, 1) + self.assertEqual(self.equipment.time_on, equip_init_args['time_res']) - self.equipment.time_in_mode = dt.timedelta(minutes=2) + self.equipment.time_on = dt.timedelta(1tes=2) self.equipment.update(1, {}, {}) - self.assertEqual(self.equipment.mode, 'Off') - self.assertEqual(self.equipment.time_in_mode, equip_init_args['time_res']) + self.assertEqual(self.equipment.mode, 0) + self.assertEqual(self.equipment.time_on, equip_init_args['time_res']) def test_simulate(self): results = self.equipment.simulate(duration=dt.timedelta(hours=1)) self.assertEqual(len(results), 60) self.assertIn('Test Equipment Electric Power (kW)', results.columns) - self.assertIn('Test Equipment Mode', results.columns) + self.assertIn("Test Equipment On-Time Fraction (-)", results.columns) self.assertNotIn('Test Equipment Gas Power (therms/hour)', results.columns) - modes = (['On'] * 5 + ['Off'] * 5) * 6 - self.assertListEqual(results['Test Equipment Mode'].values.tolist(), modes) + modes = ([1] * 5 + [0] * 5) * 6 + self.assertListEqual(results["Test Equipment On-Time Fraction (-)"].values.tolist(), modes) powers = [min(i, 15) if m == 'On' else 0 for i, m in enumerate(modes)] self.assertListEqual(results['Test Equipment Electric Power (kW)'].values.tolist(), powers) - def test_generate_results(self): + def test_generate_results(self):1 self.equipment.update({}, {}) # low verbosity @@ -110,40 +107,7 @@ def test_generate_results(self): # high verbosity results = self.equipment.generate_results(9) - self.assertDictEqual(results, {'Test Equipment Mode': 'On'}) - - def test_calculate_mode_priority(self): - self.assertDictEqual(self.equipment.ext_mode_counters, {mode: dt.timedelta(0) for mode in self.equipment.modes}) - self.equipment.current_time += dt.timedelta(minutes=1) - - duty_cycle = 1 / 2 - self.equipment.mode = 'Off' - mode_priority = self.equipment.calculate_mode_priority(duty_cycle) - self.assertListEqual(mode_priority, ['Off', 'On']) - - self.equipment.mode = 'On' - self.equipment.ext_mode_counters['On'] = dt.timedelta(minutes=7) - mode_priority = self.equipment.calculate_mode_priority(duty_cycle) - self.assertListEqual(mode_priority, ['On', 'Off']) - - self.equipment.ext_mode_counters['On'] = dt.timedelta(minutes=8) - mode_priority = self.equipment.calculate_mode_priority(duty_cycle) - self.assertListEqual(mode_priority, ['Off']) - - duty_cycle = 1 / 5 - self.equipment.mode = 'On' - self.equipment.ext_mode_counters['On'] = dt.timedelta(minutes=2) - mode_priority = self.equipment.calculate_mode_priority(duty_cycle) - self.assertListEqual(mode_priority, ['On', 'Off']) - - self.equipment.ext_mode_counters['On'] = dt.timedelta(minutes=3) - mode_priority = self.equipment.calculate_mode_priority(duty_cycle) - self.assertListEqual(mode_priority, ['Off']) - - duty_cycle = 1 - self.equipment.mode = 'Off' - mode_priority = self.equipment.calculate_mode_priority(duty_cycle) - self.assertListEqual(mode_priority, ['On']) + self.assertDictEqual(results, {"Test Equipment On-Time Fraction (-)": 1}) def test_run_zip(self): pf_multiplier = 0.48432210483785254 diff --git a/test/test_equipment/test_ev.py b/test/test_equipment/test_ev.py index 924611e..b2bc08c 100644 --- a/test/test_equipment/test_ev.py +++ b/test/test_equipment/test_ev.py @@ -60,57 +60,57 @@ def test_generate_all_events(self): # test with overlap self.ev.generate_all_events(probabilities, event_data, None) - def test_update_external_control(self): + def test_parse_control_signal(self): start = self.ev.event_start end = self.ev.event_end one_min = dt.timedelta(minutes=1) # test outside of event - self.ev.update_external_control({}, {'Delay': False}) + self.ev.parse_control_signal({}, {'Delay': False}) self.assertEqual(self.ev.event_start, start) self.assertEqual(self.ev.event_end, end) self.assertEqual(self.ev.setpoint_power, None) - self.ev.update_external_control({}, {'Delay': True}) + self.ev.parse_control_signal({}, {'Delay': True}) self.assertEqual(self.ev.event_start, start + one_min) self.assertEqual(self.ev.event_end, end) - self.ev.update_external_control({}, {'Delay': 2}) + self.ev.parse_control_signal({}, {'Delay': 2}) self.assertEqual(self.ev.event_start, start + one_min * 3) self.assertEqual(self.ev.event_end, end) - self.ev.update_external_control({}, {'Delay': 10000}) + self.ev.parse_control_signal({}, {'Delay': 10000}) self.assertEqual(self.ev.event_start, end) self.assertEqual(self.ev.event_end, end) # setpoint control - self.ev.update_external_control({}, {'P Setpoint': 1}) + self.ev.parse_control_signal({}, {'P Setpoint': 1}) self.assertEqual(self.ev.setpoint_power, None) # setpoint with part load enabled self.ev.event_start = self.ev.current_time - self.ev.update_external_control({}, {'P Setpoint': 1}) + self.ev.parse_control_signal({}, {'P Setpoint': 1}) self.assertEqual(self.ev.setpoint_power, 1.4) self.ev.enable_part_load = True - self.ev.update_external_control({}, {'P Setpoint': 1}) + self.ev.parse_control_signal({}, {'P Setpoint': 1}) self.assertEqual(self.ev.setpoint_power, 1) # SOC rate control self.ev.event_start = self.ev.current_time - self.ev.update_external_control({}, {'SOC Rate': 0.2}) + self.ev.parse_control_signal({}, {'SOC Rate': 0.2}) self.assertAlmostEqual(self.ev.setpoint_power, 1.444, places=3) - def test_update_internal_control(self): + def test_run_internal_control(self): # test outside of event - mode = self.ev.update_internal_control({}) - self.assertEqual(mode, 'Off') + mode = self.ev.run_internal_control({}) + self.assertEqual(mode, 0) self.assertIsNone(self.ev.setpoint_power) # test event start self.ev.current_time = self.ev.event_start + dt.timedelta(minutes=2) self.soc = 0.5 - mode = self.ev.update_internal_control({}) + mode = self.ev.run_internal_control({}) self.assertEqual(mode, 'On') self.assertIsNone(self.ev.setpoint_power) self.assertNotEqual(self.ev.soc, 0.5) @@ -118,12 +118,12 @@ def test_update_internal_control(self): # test event end with unmet load self.ev.current_time = self.ev.event_end + dt.timedelta(minutes=2) self.ev.soc = 0.1 - mode = self.ev.update_internal_control({}) - self.assertEqual(mode, 'Off') + mode = self.ev.run_internal_control({}) + self.assertEqual(mode, 0) self.assertGreater(self.ev.unmet_load, 0) def test_calculate_power_and_heat(self): - self.ev.mode = 'Off' + self.ev.mode = 0 self.ev.calculate_power_and_heat({}) self.assertEqual(self.ev.electric_kw, 0) @@ -165,9 +165,9 @@ def test_simulate(self): self.assertEqual(results['EV SOC (-)'].max(), 1) self.assertEqual(results['EV SOC (-)'].min(), 0) - self.assertAlmostEqual((results['EV Mode'] == 'On').mean(), 0.28, places=2) + self.assertAlmostEqual((results["EV On-Time Fraction (-)"] == 1).mean(), 0.28, places=2) - self.assertDictEqual(self.ev.mode_cycles, {'On': 1, 'Off': 0}) + self.assertDictEqual(self.ev.mode_cycles, {'On': 1, 0: 0}) class ScheduledEVTestCase(unittest.TestCase): diff --git a/test/test_equipment/test_eventbased.py b/test/test_equipment/test_eventbased.py index 3a27ed4..d217eaa 100644 --- a/test/test_equipment/test_eventbased.py +++ b/test/test_equipment/test_eventbased.py @@ -28,47 +28,47 @@ def test_init(self): self.assertEqual(self.e.event_start.date(), self.e.current_time.date()) self.assertEqual(self.e.event_end - self.e.event_start, init_args['event_duration']) - def test_update_external_control(self): + def test_parse_control_signal(self): first_event_start = self.e.event_start - mode = self.e.update_external_control({}, {'nothing': 0}) + mode = self.e.parse_control_signal({}, {'nothing': 0}) self.assertEqual(self.e.event_start, first_event_start) - self.assertEqual(mode, 'Off') + self.assertEqual(mode, 0) - mode = self.e.update_external_control({}, {'Delay': True}) + mode = self.e.parse_control_signal({}, {'Delay': True}) self.assertEqual(self.e.event_start, first_event_start + equip_init_args['time_res']) - self.assertEqual(mode, 'Off') + self.assertEqual(mode, 0) - mode = self.e.update_external_control({}, {'Delay': 2}) + mode = self.e.parse_control_signal({}, {'Delay': 2}) self.assertEqual(self.e.event_start, first_event_start + 3 * equip_init_args['time_res']) - self.assertEqual(mode, 'Off') + self.assertEqual(mode, 0) # negative delay - start immediately - mode = self.e.update_external_control({}, {'Delay': self.e.current_time - self.e.event_start}) + mode = self.e.parse_control_signal({}, {'Delay': self.e.current_time - self.e.event_start}) self.assertEqual(self.e.event_start, self.e.current_time) - self.assertEqual(mode, 'On') + self.assertEqual(mode, 1) - def test_update_internal_control(self): + def test_run_internal_control(self): first_event_start = self.e.event_start current_index = self.e.event_index - mode = self.e.update_internal_control({}) - self.assertEqual(mode, 'Off') + mode = self.e.run_internal_control({}) + self.assertEqual(mode, 0) self.assertEqual(self.e.event_index, current_index) self.e.current_time = self.e.event_start - mode = self.e.update_internal_control({}) - self.assertEqual(mode, 'On') + mode = self.e.run_internal_control({}) + self.assertEqual(mode, 1) self.assertEqual(self.e.event_index, current_index) self.e.current_time = self.e.event_end - mode = self.e.update_internal_control({}) - self.assertEqual(mode, 'Off') + mode = self.e.run_internal_control({}) + self.assertEqual(mode, 0) self.assertEqual(self.e.event_index, current_index + 1) self.assertNotEqual(self.e.event_start, first_event_start) def test_calculate_power_and_heat(self): - self.e.mode = 'On' + self.e.on_frac = 1 self.e.calculate_power_and_heat({}) self.assertEqual(self.e.electric_kw, init_args['max_power']) self.assertEqual(self.e.sensible_gain, 0) diff --git a/test/test_equipment/test_generator.py b/test/test_equipment/test_generator.py index cbbbb22..daefa36 100644 --- a/test/test_equipment/test_generator.py +++ b/test/test_equipment/test_generator.py @@ -20,73 +20,73 @@ def test_init(self): self.assertAlmostEqual(self.generator.efficiency_rated, 0.95) self.assertEqual(self.generator.control_type, 'Off') - def test_update_external_control(self): + def test_parse_control_signal(self): # test setpoint control - mode = self.generator.update_external_control({}, {'P Setpoint': -2}) - self.assertEqual(mode, 'On') + mode = self.generator.parse_control_signal({}, {'P Setpoint': -2}) + self.assertEqual(mode, 1 self.assertEqual(self.generator.power_setpoint, -2) - mode = self.generator.update_external_control({}, {'P Setpoint': 0}) - self.assertEqual(mode, 'Off') + mode = self.generator.parse_control_signal({}, {'P Setpoint': 0}) + self.assertEqual(mode, 0) # test control type control_signal = {'Control Type': 'Schedule'} - mode = self.generator.update_external_control({}, control_signal) - self.assertEqual(mode, 'Off') + mode = self.generator.parse_control_signal({}, control_signal) + self.assertEqual(mode, 0) self.assertEqual(self.generator.control_type, 'Schedule') control_signal = {'Control Type': 'Other'} - mode = self.generator.update_external_control({}, control_signal) - self.assertEqual(mode, 'Off') + mode = self.generator.parse_control_signal({}, control_signal) + self.assertEqual(mode, 0) self.assertEqual(self.generator.control_type, 'Schedule') # test parameter update control_signal = {'Parameters': {'charge_start_hour': 0}} - mode = self.generator.update_external_control({}, control_signal) - self.assertEqual(mode, 'On') + mode = self.generator.parse_control_signal({}, control_signal) + self.assertEqual(mode, 1) self.assertEqual(self.generator.control_type, 'Schedule') self.assertEqual(self.generator.power_setpoint, 1) - def test_update_internal_control(self): + def test_run_internal_control(self): # test schedule-based control self.generator.control_type = 'Schedule' - mode = self.generator.update_internal_control({}) - self.assertEqual(mode, 'Off') + mode = self.generator.run_internal_control({}) + self.assertEqual(mode, 0) self.assertEqual(self.generator.power_setpoint, 0) self.generator.current_time = init_args['start_time'] + dt.timedelta( hours=self.generator.parameters['discharge_start_hour']) - mode = self.generator.update_internal_control({}) - self.assertEqual(mode, 'On') + mode = self.generator.run_internal_control({}) + self.assertEqual(mode, 1) self.assertEqual(self.generator.power_setpoint, - self.generator.parameters['discharge_power']) # test self-consumption control self.generator.control_type = 'Self-Consumption' - mode = self.generator.update_internal_control({}) - self.assertEqual(mode, 'Off') + mode = self.generator.run_internal_control({}) + self.assertEqual(mode, 0) - mode = self.generator.update_internal_control({'net_power': 2}) - self.assertEqual(mode, 'On') + mode = self.generator.run_internal_control({'net_power': 2}) + self.assertEqual(mode, 1) self.assertEqual(self.generator.power_setpoint, -2) - mode = self.generator.update_internal_control({'net_power': -1}) - self.assertEqual(mode, 'On') + mode = self.generator.run_internal_control({'net_power': -1}) + self.assertEqual(mode, 1) self.assertEqual(self.generator.power_setpoint, 1) # test self-consumption with export limit self.generator.parameters['export_limit'] = 1 - mode = self.generator.update_internal_control({'net_power': 3}) - self.assertEqual(mode, 'On') + mode = self.generator.run_internal_control({'net_power': 3}) + self.assertEqual(mode, 1) self.assertAlmostEqual(self.generator.power_setpoint, -2) - mode = self.generator.update_internal_control({'net_power': 0.9}) - self.assertEqual(mode, 'Off') + mode = self.generator.run_internal_control({'net_power': 0.9}) + self.assertEqual(mode, 0) self.assertAlmostEqual(self.generator.power_setpoint, 0) # test off self.generator.control_type = 'Off' - mode = self.generator.update_internal_control({}) - self.assertEqual(mode, 'Off') + mode = self.generator.run_internal_control({}) + self.assertEqual(mode, 0) def test_get_power_limits(self): # test without ramp rate @@ -114,13 +114,13 @@ def test_get_power_limits(self): self.assertEqual(p_max, -1) def test_calculate_power_and_heat(self): - self.generator.mode = 'Off' + self.generator.mode = 0 self.generator.calculate_power_and_heat({}) self.assertEqual(self.generator.electric_kw, 0) self.assertEqual(self.generator.sensible_gain, 0) # test generation - with ramp rate - self.generator.mode = 'On' + self.generator.on = 'O1 self.generator.power_setpoint = -2 self.generator.electric_kw = -1 self.generator.calculate_power_and_heat({}) @@ -128,7 +128,7 @@ def test_calculate_power_and_heat(self): self.assertAlmostEquals(self.generator.sensible_gain, 58, places=0) # test consumption - not allowed for generators - self.generator.mode = 'On' + self.generator.on = 'O1 self.generator.power_setpoint = 2 self.generator.calculate_power_and_heat({}) self.assertAlmostEquals(self.generator.electric_kw, 0) diff --git a/test/test_equipment/test_hvac.py b/test/test_equipment/test_hvac.py index caf0e7c..abcae45 100644 --- a/test/test_equipment/test_hvac.py +++ b/test/test_equipment/test_hvac.py @@ -71,61 +71,40 @@ def setUp(self): def test_init(self): self.assertTrue(self.hvac.is_heater) - self.assertFalse(self.hvac.use_ideal_capacity) + self.assertFalse(self.hvac.use_ideal_mode) self.assertAlmostEqual(self.hvac.fan_power_max, 75, places=1) self.assertIsNone(self.hvac.Ao_list) - self.assertIsNotNone(self.hvac.envelope_model) + self.assertIsNotNone(self.hvac.thermal_model) self.assertEqual(self.hvac.zone, envelope.indoor_zone) - def test_update_external_control(self): + def test_parse_control_signal(self): # test with load fraction - mode = self.hvac.update_external_control(update_args_heat, {'Load Fraction': 0}) + mode = self.hvac.parse_control_signal(update_args_heat, {'Load Fraction': 0}) self.assertEqual(mode, 'Off') with self.assertRaises(Exception): - self.hvac.update_external_control(update_args_heat, {'Load Fraction': 0.5}) + self.hvac.parse_control_signal(update_args_heat, {'Load Fraction': 0.5}) # test with setpoint and deadband self.hvac.mode = 'Off' - mode = self.hvac.update_external_control(update_args_inside, {'Setpoint': 18}) + mode = self.hvac.parse_control_signal(update_args_inside, {'Setpoint': 18}) self.assertEqual(mode, 'Off') self.assertEqual(self.hvac.temp_setpoint, 18) self.assertEqual(self.hvac.temp_deadband, 1) - mode = self.hvac.update_external_control(update_args_inside, {'Deadband': 2}) + mode = self.hvac.parse_control_signal(update_args_inside, {'Deadband': 2}) self.assertEqual(mode, None) self.assertEqual(self.hvac.temp_setpoint, 19.9) self.assertEqual(self.hvac.temp_deadband, 2) - mode = self.hvac.update_external_control(update_args_inside, {'Setpoint': 22}) + mode = self.hvac.parse_control_signal(update_args_inside, {'Setpoint': 22}) self.assertEqual(mode, 'On') self.assertEqual(self.hvac.temp_setpoint, 22) self.assertEqual(self.hvac.temp_deadband, 2) - def test_run_duty_cycle_control(self): - self.hvac.mode = 'Off' - mode = self.hvac.run_duty_cycle_control(update_args_inside, {'Duty Cycle': 0.99}) - self.assertEqual(mode, 'Off') - - mode = self.hvac.run_duty_cycle_control(update_args_inside, {'Duty Cycle': 1}) - self.assertEqual(mode, 'On') - - self.hvac.mode = 'On' - mode = self.hvac.run_duty_cycle_control(update_args_inside, {'Duty Cycle': 0.5}) - self.assertEqual(mode, 'On') - - self.hvac.mode = 'Off' - mode = self.hvac.run_duty_cycle_control(update_args_heat, {'Duty Cycle': 0.5}) - self.assertEqual(mode, 'On') - - # test ignoring current mode - self.hvac.ext_ignore_thermostat = True - mode = self.hvac.run_duty_cycle_control(update_args_heat, {'Duty Cycle': 0.5}) - self.assertEqual(mode, 'Off') - - def test_update_internal_control(self): - mode = self.hvac.update_internal_control(update_args_heat) + def test_run_internal_control(self): + mode = self.hvac.run_internal_control(update_args_heat) self.assertEqual(mode, 'On') def test_update_setpoint(self): @@ -206,46 +185,35 @@ def test_generate_results(self): results = self.hvac.generate_results(6) self.assertEqual(len(results), 9) self.assertAlmostEqual(results['HVAC Heating COP (-)'], 2.0) - self.assertEqual(results['HVAC Heating Mode'], 'On') + self.assertEqual(results["HVAC Heating On-Time Fraction (-)"], 1) class IdealHeaterTestCase(unittest.TestCase): def setUp(self): - self.hvac = Heater(use_ideal_capacity=True, **init_args) - - 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.ext_capacity, 2500) + self.hvac = Heater(use_ideal_mode=True, **init_args) - 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.ext_capacity, 0) - - def test_update_internal_control(self): - mode = self.hvac.update_internal_control(update_args_heat) + def test_run_internal_control(self): + mode = self.hvac.run_internal_control(update_args_heat) self.assertEqual(mode, 'On') - mode = self.hvac.update_internal_control(update_args_inside) + mode = self.hvac.run_internal_control(update_args_inside) self.assertEqual(mode, 'On') - mode = self.hvac.update_internal_control(update_args_cool) + mode = self.hvac.run_internal_control(update_args_cool) self.assertEqual(mode, 'Off') def test_solve_ideal_capacity(self): self.hvac.temp_setpoint = 19 - capacity = self.hvac.solve_ideal_capacity() + capacity = self.hvac.run_ideal_control() self.assertAlmostEqual(capacity, -6000, places=-2) self.hvac.temp_setpoint = 20 - capacity = self.hvac.solve_ideal_capacity() + capacity = self.hvac.run_ideal_control() self.assertAlmostEqual(capacity, 700, places=-2) self.hvac.temp_setpoint = 21 - capacity = self.hvac.solve_ideal_capacity() + capacity = self.hvac.run_ideal_control() self.assertAlmostEqual(capacity, 7400, places=-2) def test_update_capacity(self): @@ -267,19 +235,19 @@ def test_update_capacity(self): class IdealCoolerTestCase(unittest.TestCase): def setUp(self): - self.hvac = Cooler(use_ideal_capacity=True, **init_args) + self.hvac = Cooler(use_ideal_mode=True, **init_args) def test_solve_ideal_capacity(self): self.hvac.temp_setpoint = 21 - capacity = self.hvac.solve_ideal_capacity() + capacity = self.hvac.run_ideal_control() self.assertAlmostEqual(capacity, -8500, places=-2) self.hvac.temp_setpoint = 20 - capacity = self.hvac.solve_ideal_capacity() + capacity = self.hvac.run_ideal_control() self.assertAlmostEqual(capacity, -800, places=-2) self.hvac.temp_setpoint = 19 - capacity = self.hvac.solve_ideal_capacity() + capacity = self.hvac.run_ideal_control() self.assertAlmostEqual(capacity, 6900, places=-2) @@ -326,7 +294,7 @@ def setUp(self): def test_init(self): self.assertAlmostEqual(self.hvac.Ao_list, 0.495, places=3) self.assertAlmostEqual(self.hvac.biquad_params[0]['eir_t'][0], -0.30428) - self.assertEqual(self.hvac.use_ideal_capacity, False) + self.assertEqual(self.hvac.use_ideal_mode, False) def test_calculate_biquadratic_param(self): self.hvac.mode = 'On' @@ -386,14 +354,14 @@ def test_init(self): self.assertListEqual(self.hvac.capacity_list, [2500, 5000]) self.assertEqual(self.hvac.min_time_in_speed[0], dt.timedelta(minutes=5)) - def test_update_external_control(self): + def test_parse_control_signal(self): # test disable speeds - mode = self.hvac.update_external_control(update_args_cool, {'Disable Speed 1': 1}) + mode = self.hvac.parse_control_signal(update_args_cool, {'Disable Speed 1': 1}) self.assertEqual(mode, 'On') self.assertEqual(self.hvac.speed_idx, 1) self.assertListEqual(list(self.hvac.disable_speeds), [True, False]) - mode = self.hvac.update_external_control(update_args_cool, {'Disable Speed 1': 0, 'Disable Speed 2': 1}) + mode = self.hvac.parse_control_signal(update_args_cool, {'Disable Speed 1': 0, 'Disable Speed 2': 1}) self.assertEqual(mode, 'On') self.assertEqual(self.hvac.speed_idx, 0) self.assertListEqual(list(self.hvac.disable_speeds), [False, True]) @@ -521,16 +489,9 @@ def setUp(self): def test_init(self): self.assertAlmostEqual(self.hvac.Ao_list, 0.495, places=3) self.assertEqual(self.hvac.n_speeds, 4) - self.assertEqual(self.hvac.use_ideal_capacity, True) + self.assertEqual(self.hvac.use_ideal_mode, True) self.assertAlmostEqual(self.hvac.capacity_list[0], 5000 / 4, places=3) - def test_run_duty_cycle_control(self): - # test update max capacity - 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.ext_capacity, 3000, places=-1) - def test_update_capacity(self): # Capacity should follow ideal update self.hvac.temp_setpoint = 19.8 @@ -641,57 +602,37 @@ class ASHPHeaterTestCase(unittest.TestCase): def setUp(self): self.hvac = ASHPHeater(**init_args) - def test_run_duty_cycle_control(self): - mode = self.hvac.run_duty_cycle_control(update_args_heat, {'Duty Cycle': 0}) - self.assertEqual(mode, 'Off') - - self.hvac.mode = 'HP and ER On' - mode = self.hvac.run_duty_cycle_control(update_args_heat, {'Duty Cycle': 0.5}) - self.assertEqual(mode, 'HP On') - self.assertEqual(self.hvac.ext_mode_counters['HP On'], dt.timedelta(minutes=0)) - self.assertEqual(self.hvac.ext_mode_counters['HP and ER On'], dt.timedelta(minutes=1)) - - self.hvac.mode = 'HP On' - mode = self.hvac.run_duty_cycle_control(update_args_heat, {'ER Duty Cycle': 0.5}) - self.assertEqual(mode, 'ER On') - self.assertEqual(self.hvac.ext_mode_counters['HP and ER On'], dt.timedelta(minutes=1)) - - # test HP mode counter - mode = self.hvac.run_duty_cycle_control(update_args_heat, {'Duty Cycle': 0.5, 'ER Duty Cycle': 1}) - self.assertEqual(mode, 'HP and ER On') - self.assertEqual(self.hvac.ext_mode_counters['HP On'], dt.timedelta(minutes=1)) - - def test_update_internal_control(self): + def test_run_internal_control(self): self.hvac.mode = 'Off' - mode = self.hvac.update_internal_control(update_args_heat) + mode = self.hvac.run_internal_control(update_args_heat) self.assertEqual(mode, 'HP On') self.hvac.mode = 'HP and ER On' - mode = self.hvac.update_internal_control(update_args_heat) + mode = self.hvac.run_internal_control(update_args_heat) self.assertEqual(mode, 'HP and ER On') self.hvac.mode = 'HP and ER On' - mode = self.hvac.update_internal_control(update_args_inside) + mode = self.hvac.run_internal_control(update_args_inside) self.assertEqual(mode, 'HP On') self.hvac.mode = 'HP On' - mode = self.hvac.update_internal_control(update_args_inside) + mode = self.hvac.run_internal_control(update_args_inside) self.assertEqual(mode, 'HP On') self.hvac.mode = 'HP and ER On' - mode = self.hvac.update_internal_control(update_args_cool) + mode = self.hvac.run_internal_control(update_args_cool) self.assertEqual(mode, 'Off') # test with cold indoor temperature update_args = update_args_heat.copy() self.hvac.zone.temperature = 16 - mode = self.hvac.update_internal_control(update_args) + mode = self.hvac.run_internal_control(update_args) self.assertEqual(mode, 'HP and ER On') # test with cold outdoor temperature self.hvac.zone.temperature = 18 update_args['ambient_dry_bulb'] = -20 - mode = self.hvac.update_internal_control(update_args) + mode = self.hvac.run_internal_control(update_args) self.assertEqual(mode, 'ER On') # reset zone temperature @@ -745,40 +686,27 @@ def test_update_eir(self): class VariableSpeedASHPHeaterTestCase(unittest.TestCase): def setUp(self): - self.hvac = ASHPHeater(use_ideal_capacity=True, **init_args) - - def test_run_duty_cycle_control(self): - mode = self.hvac.run_duty_cycle_control(update_args_heat, {'Duty Cycle': 0}) - self.assertEqual(mode, 'Off') - - mode = self.hvac.run_duty_cycle_control(update_args_heat, {'Duty Cycle': 0.5}) - self.assertEqual(mode, 'HP On') - - mode = self.hvac.run_duty_cycle_control(update_args_heat, {'ER Duty Cycle': 0.5}) - self.assertEqual(mode, 'ER On') - - mode = self.hvac.run_duty_cycle_control(update_args_heat, {'Duty Cycle': 0.5, 'ER Duty Cycle': 0.5}) - self.assertEqual(mode, 'HP and ER On') + self.hvac = ASHPHeater(use_ideal_mode=True, **init_args) - def test_update_internal_control(self): - mode = self.hvac.update_internal_control(update_args_heat) + def test_run_internal_control(self): + mode = self.hvac.run_internal_control(update_args_heat) self.assertEqual(mode, 'HP and ER On') - mode = self.hvac.update_internal_control(update_args_inside) + mode = self.hvac.run_internal_control(update_args_inside) self.assertEqual(mode, 'HP On') - mode = self.hvac.update_internal_control(update_args_cool) + mode = self.hvac.run_internal_control(update_args_cool) self.assertEqual(mode, 'Off') # test with cold indoor temperature update_args = update_args_heat.copy() update_args['heating_setpoint'] = 22 - mode = self.hvac.update_internal_control(update_args) + mode = self.hvac.run_internal_control(update_args) self.assertEqual(mode, 'HP and ER On') # test with cold outdoor temperature update_args['ambient_dry_bulb'] = -20 - mode = self.hvac.update_internal_control(update_args) + mode = self.hvac.run_internal_control(update_args) self.assertEqual(mode, 'ER On') def test_update_capacity(self): diff --git a/test/test_equipment/test_pv.py b/test/test_equipment/test_pv.py index 63ca1b8..5272e75 100644 --- a/test/test_equipment/test_pv.py +++ b/test/test_equipment/test_pv.py @@ -37,40 +37,40 @@ def test_init(self): self.assertAlmostEqual(self.pv.schedule[init_args['start_time'] + dt.timedelta(hours=12)], -8.16, places=2) self.assertAlmostEqual(self.pv.inverter_min_pf_factor, 0.75, places=2) - def test_update_external_control(self): - mode = self.pv.update_external_control({}, {'P Setpoint': -5, 'Q Setpoint': 1}) - self.assertEqual(mode, 'On') + def test_parse_control_signal(self): + mode = self.pv.parse_control_signal({}, {'P Setpoint': -5, 'Q Setpoint': 1}) + self.assertEqual(mode, 1) self.assertAlmostEqual(self.pv.p_set_point, -5) self.assertAlmostEqual(self.pv.q_set_point, 1) - mode = self.pv.update_external_control({}, {'P Setpoint': -20, 'Q Setpoint': 1}) - self.assertEqual(mode, 'On') + mode = self.pv.parse_control_signal({}, {'P Setpoint': -20, 'Q Setpoint': 1}) + self.assertEqual(mode, 1) self.assertAlmostEqual(self.pv.p_set_point, -8.22, places=2) self.assertAlmostEqual(self.pv.q_set_point, 1) # test PV curtailment - mode = self.pv.update_external_control({}, {'P Curtailment (kW)': 1}) - self.assertEqual(mode, 'On') + mode = self.pv.parse_control_signal({}, {'P Curtailment (kW)': 1}) + self.assertEqual(mode, 1) self.assertAlmostEqual(self.pv.p_set_point, -7.25, places=2) # test PV curtailment - mode = self.pv.update_external_control({}, {'P Curtailment (%)': 50}) - self.assertEqual(mode, 'On') + mode = self.pv.parse_control_signal({}, {'P Curtailment (%)': 50}) + self.assertEqual(mode, 1) self.assertAlmostEqual(self.pv.p_set_point, -4.13, places=2) # test power factor - mode = self.pv.update_external_control({}, {'Power Factor': -0.95}) - self.assertEqual(mode, 'On') + mode = self.pv.parse_control_signal({}, {'Power Factor': -0.95}) + self.assertEqual(mode, 1) self.assertAlmostEqual(self.pv.p_set_point, -6.37, places=2) self.assertAlmostEqual(self.pv.q_set_point, 2.09, places=2) # test priority - self.pv.update_external_control({}, {'Priority': 'CPF'}) + self.pv.parse_control_signal({}, {'Priority': 'CPF'}) self.assertEqual(self.pv.inverter_priority, 'CPF') - def test_update_internal_control(self): - mode = self.pv.update_internal_control({}) - self.assertEqual(mode, 'On') + def test_run_internal_control(self): + mode = self.pv.run_internal_control({}) + self.assertEqual(mode, 1) self.assertAlmostEqual(self.pv.p_set_point, -8.16, places=2) self.assertAlmostEqual(self.pv.q_set_point, 0) @@ -131,9 +131,9 @@ def test_init(self): self.assertEqual(len(self.pv.schedule), 4 * 24) self.assertAlmostEqual(self.pv.schedule[init_args['start_time'] + dt.timedelta(hours=12)], -2.01, places=2) - def test_update_internal_control(self): - mode = self.pv.update_internal_control({}) - self.assertEqual(mode, 'On') + def test_run_internal_control(self): + mode = self.pv.run_internal_control({}) + self.assertEqual(mode, 1) self.assertAlmostEqual(self.pv.p_set_point, -2.01, places=2) self.assertAlmostEqual(self.pv.q_set_point, 0) diff --git a/test/test_equipment/test_scheduled.py b/test/test_equipment/test_scheduled.py index de0771f..9389e73 100644 --- a/test/test_equipment/test_scheduled.py +++ b/test/test_equipment/test_scheduled.py @@ -40,30 +40,30 @@ def test_init(self): self.assertEqual(self.equipment.schedule_name, 'plug_loads') self.assertEqual(self.equipment.sensible_gain_fraction, 0.5) - def test_update_external_control(self): - mode = self.equipment.update_external_control({'plug_loads': 100}, {'Load Fraction': 1}) - self.assertEqual(mode, 'On') + def test_parse_control_signal(self): + mode = self.equipment.parse_control_signal({'plug_loads': 100}, {'Load Fraction': 1}) + self.assertEqual(mode, 1) self.assertAlmostEqual(self.equipment.p_set_point, 0.1) - mode = self.equipment.update_external_control({'plug_loads': 200}, {'Load Fraction': 0.5}) - self.assertEqual(mode, 'On') + mode = self.equipment.parse_control_signal({'plug_loads': 200}, {'Load Fraction': 0.5}) + self.assertEqual(mode, 1) self.assertAlmostEqual(self.equipment.p_set_point, 0.2 * 0.5) - mode = self.equipment.update_external_control({'plug_loads': 200}, {'Load Fraction': 0}) - self.assertEqual(mode, 'Off') + mode = self.equipment.parse_control_signal({'plug_loads': 200}, {'Load Fraction': 0}) + self.assertEqual(mode, 0) self.assertAlmostEqual(self.equipment.p_set_point, 0) - def test_update_internal_control(self): - mode = self.equipment.update_internal_control({'plug_loads': 100}) - self.assertEqual(mode, 'On') + def test_run_internal_control(self): + mode = self.equipment.run_internal_control({'plug_loads': 100}) + self.assertEqual(mode, 1) self.assertAlmostEqual(self.equipment.p_set_point, 0.1) - mode = self.equipment.update_internal_control({'plug_loads': 0}) - self.assertEqual(mode, 'Off') + mode = self.equipment.run_internal_control({'plug_loads': 0}) + self.assertEqual(mode, 0) self.assertAlmostEqual(self.equipment.p_set_point, 0) def test_calculate_power_and_heat(self): - self.equipment.mode = 'On' + self.equipment.on_frac = 'O1 self.equipment.p_set_point = 2 self.equipment.calculate_power_and_heat({}) self.assertAlmostEqual(self.equipment.sensible_gain, 1000) @@ -99,9 +99,9 @@ def test_reset_time(self, start_time=None): p = next(self.equipment.schedule_iterable) self.assertEqual(p, 0.2) - def test_update_internal_control(self): - mode = self.equipment.update_internal_control({}) - self.assertEqual(mode, 'On') + def test_run_internal_control(self): + mode = self.equipment.run_internal_control({}) + self.assertEqual(mode, 1) self.assertAlmostEqual(self.equipment.p_set_point, 0.2) diff --git a/test/test_equipment/test_waterheater.py b/test/test_equipment/test_waterheater.py index 4087a6b..eda113b 100644 --- a/test/test_equipment/test_waterheater.py +++ b/test/test_equipment/test_waterheater.py @@ -37,7 +37,7 @@ def setUp(self): self.wh = WaterHeater(**init_args) def test_init(self): - self.assertFalse(self.wh.use_ideal_capacity) + self.assertFalse(self.wh.use_ideal_mode self.assertTrue(isinstance(self.wh.model, TwoNodeWaterModel)) self.assertEqual(self.wh.h_upper_idx, 0) self.assertEqual(self.wh.h_lower_idx, 1) @@ -45,56 +45,39 @@ def test_init(self): self.assertNotEqual(self.wh.setpoint_temp, self.wh.model.states[0]) - def test_update_external_control(self): + def test_parse_control_signal(self): # test load fraction self.wh.mode = 'Off' control_signal = {'Load Fraction': 1} - mode = self.wh.update_external_control(update_args_no_draw, control_signal) + mode = self.wh.parse_control_signal(update_args_no_draw, control_signal) self.assertEqual(mode, None) self.wh.mode = 'On' control_signal = {'Load Fraction': 0} - mode = self.wh.update_external_control(update_args_no_draw, control_signal) + mode = self.wh.parse_control_signal(update_args_no_draw, control_signal) self.assertEqual(mode, 'Off') control_signal = {'Load Fraction': 0.5} with self.assertRaises(Exception): - self.wh.update_external_control(update_args_no_draw, control_signal) + self.wh.parse_control_signal(update_args_no_draw, control_signal) # test with setpoint and deadband self.wh.mode = 'Off' - mode = self.wh.update_external_control(update_args_no_draw, {'Setpoint': 55, 'Deadband': 5}) + mode = self.wh.parse_control_signal(update_args_no_draw, {'Setpoint': 55, 'Deadband': 5}) self.assertEqual(mode, None) self.assertEqual(self.wh.setpoint_temp, 55) self.assertEqual(self.wh.deadband_temp, 5) - mode = self.wh.update_external_control(update_args_no_draw, {'Deadband': 4}) + mode = self.wh.parse_control_signal(update_args_no_draw, {'Deadband': 4}) self.assertEqual(mode, None) self.assertEqual(self.wh.setpoint_temp, 51.666667) self.assertEqual(self.wh.deadband_temp, 4) - mode = self.wh.update_external_control(update_args_no_draw, {'Setpoint': 60}) + mode = self.wh.parse_control_signal(update_args_no_draw, {'Setpoint': 60}) self.assertEqual(mode, 'On') self.assertEqual(self.wh.setpoint_temp, 60) self.assertEqual(self.wh.deadband_temp, 4) - def test_run_duty_cycle_control(self): - self.wh.mode = 'Off' - mode = self.wh.update_external_control(update_args_no_draw, {'Duty Cycle': 0.5}) - self.assertEqual(mode, 'Off') - - mode = self.wh.update_external_control(update_args_no_draw, {'Duty Cycle': [1, 0]}) - self.assertEqual(mode, 'On') - - self.wh.mode = 'On' - mode = self.wh.update_external_control(update_args_no_draw, {'Duty Cycle': 0.5}) - self.assertEqual(mode, 'On') - - self.wh.mode = 'On' - self.wh.model.states[self.wh.t_lower_idx] = self.wh.setpoint_temp + 1 - mode = self.wh.update_external_control(update_args_no_draw, {'Duty Cycle': 0.5}) - self.assertEqual(mode, 'Off') - def test_run_thermostat_control(self): self.wh.mode = 'Off' mode = self.wh.run_thermostat_control(update_args_no_draw) @@ -144,7 +127,7 @@ def test_generate_results(self): results = self.wh.generate_results(6) self.assertEqual(len(results), 16) - self.assertIn('Water Heating Mode', results) + self.assertIn('Water Heating On-Time Fraction (-)', results) self.assertIn('Hot Water Delivered (L/min)', results) self.assertIn('Water Heating COP (-)', results) @@ -155,71 +138,57 @@ class IdealWaterHeaterTestCase(unittest.TestCase): """ def setUp(self): - self.wh = WaterHeater(use_ideal_capacity=True, **init_args) + self.wh = WaterHeater(use_ideal_mode=True, **init_args) # update initial state to top of deadband (for 1-node model) self.wh.model.states[self.wh.t_upper_idx] = self.wh.setpoint_temp def test_init(self): - self.assertTrue(self.wh.use_ideal_capacity) + self.assertTrue(self.wh.use_ideal_mode) self.assertTrue(isinstance(self.wh.model, TwoNodeWaterModel)) self.assertEqual(self.wh.h_lower_idx, 1) self.assertEqual(self.wh.h_upper_idx, 0) - def test_run_duty_cycle_control(self): + def test_run_internal_control(self): self.wh.mode = 'Off' - mode = self.wh.update_external_control(update_args_no_draw, {'Duty Cycle': 0.5}) - self.assertEqual(self.wh.duty_cycle_by_mode['On'], 0.5) - self.assertEqual(mode, 'On') - - mode = self.wh.update_external_control(update_args_no_draw, {'Duty Cycle': [1, 0]}) - self.assertEqual(self.wh.duty_cycle_by_mode['On'], 1) - self.assertEqual(mode, 'On') - - mode = self.wh.update_external_control(update_args_no_draw, {'Duty Cycle': 0}) - self.assertEqual(self.wh.duty_cycle_by_mode['On'], 0) - self.assertEqual(mode, 'Off') - - def test_update_internal_control(self): - self.wh.mode = 'Off' - mode = self.wh.update_internal_control(update_args_no_draw) + mode = self.wh.run_internal_control(update_args_no_draw) self.assertEqual(mode, 'On') self.assertAlmostEqual(self.wh.duty_cycle_by_mode['On'], 0.823, places=2) # test with draw - mode = self.wh.update_internal_control(update_args_small_draw) + mode = self.wh.run_internal_control(update_args_small_draw) self.assertEqual(mode, 'On') self.assertAlmostEqual(self.wh.duty_cycle_by_mode['On'], 1, places=2) # test with temperature change self.wh.model.states[self.wh.t_lower_idx] = self.wh.setpoint_temp - 0.1 - mode = self.wh.update_internal_control(update_args_no_draw) + mode = self.wh.run_internal_control(update_args_no_draw) self.assertEqual(mode, 'On') self.assertAlmostEqual(self.wh.duty_cycle_by_mode['On'], 0.16, places=2) # test off self.wh.model.states[self.wh.t_lower_idx] = self.wh.setpoint_temp + 1 - mode = self.wh.update_internal_control(update_args_no_draw) + mode = self.wh.run_internal_control(update_args_no_draw) self.assertEqual(mode, 'Off') self.assertEqual(self.wh.duty_cycle_by_mode['On'], 0) def test_calculate_power_and_heat(self): # test with no draw - self.wh.mode = self.wh.update_internal_control(update_args_no_draw) + self.wh.mode = self.wh.run_internal_control(update_args_no_draw) self.wh.calculate_power_and_heat(update_args_no_draw) self.assertAlmostEqual(self.wh.electric_kw, 4.115, places=2) self.assertAlmostEqual(self.wh.sensible_gain, 870, places=0) self.assertAlmostEqual(self.wh.model.next_states[0], self.wh.setpoint_temp, places=1) # test with draw - self.wh.update_internal_control(update_args_small_draw) + self.wh.run_internal_control(update_args_small_draw) self.wh.calculate_power_and_heat(update_args_small_draw) self.assertAlmostEqual(self.wh.electric_kw, 5, places=2) self.assertAlmostEqual(self.wh.sensible_gain, 1050, places=-1) self.assertAlmostEqual(self.wh.model.next_states[0], self.wh.setpoint_temp, places=0) # test with large draw - self.wh.update_internal_control(update_args_large_draw) + self.wh.run_internal_control(update_args_large_draw) self.wh.calculate_power_and_heat(update_args_large_draw) self.assertAlmostEqual(self.wh.electric_kw, 5, places=2) self.assertAlmostEqual(self.wh.model.next_states[0], 35, places=0) @@ -230,36 +199,23 @@ class ERWaterHeaterTestCase(unittest.TestCase): def setUp(self): self.wh = ElectricResistanceWaterHeater(**init_args) - def test_update_external_control(self): - self.wh.mode = 'Off' - control_signal = {'Duty Cycle': 1} - mode = self.wh.update_external_control(update_args_no_draw, control_signal) - self.assertEqual(mode, 'Upper On') - self.assertEqual(self.wh.ext_mode_counters['Upper On'], dt.timedelta(minutes=0)) # gets updated later - self.assertEqual(self.wh.ext_mode_counters['Lower On'], dt.timedelta(minutes=1)) + # def test_parse_control_signal(self): - # test swap from upper to lower - self.wh.mode = 'Upper On' - self.wh.model.states[self.wh.t_upper_idx] = self.wh.setpoint_temp + 1 - control_signal = {'Duty Cycle': 1} - mode = self.wh.update_external_control(update_args_no_draw, control_signal) - self.assertEqual(mode, 'Lower On') - - def test_update_internal_control(self): + def test_run_internal_control(self): self.wh.mode = 'Lower On' self.wh.model.states[self.wh.t_upper_idx] = self.wh.setpoint_temp - self.wh.deadband_temp - 1 self.wh.model.states[self.wh.t_lower_idx] = self.wh.setpoint_temp - self.wh.deadband_temp - 1 - mode = self.wh.update_internal_control(update_args_no_draw) + mode = self.wh.run_internal_control(update_args_no_draw) self.assertEqual(mode, 'Upper On') # Upper element gets priority self.wh.mode = 'Upper On' self.wh.model.states[self.wh.t_upper_idx] = self.wh.setpoint_temp + 1 - mode = self.wh.update_internal_control(update_args_no_draw) + mode = self.wh.run_internal_control(update_args_no_draw) self.assertEqual(mode, 'Lower On') # Lower turns on after 1 turn self.wh.mode = 'Lower On' self.wh.model.states[self.wh.t_lower_idx] = self.wh.setpoint_temp + 1 - mode = self.wh.update_internal_control(update_args_no_draw) + mode = self.wh.run_internal_control(update_args_no_draw) self.assertEqual(mode, 'Off') @@ -268,76 +224,44 @@ class HPWaterHeaterTestCase(unittest.TestCase): def setUp(self): self.wh = HeatPumpWaterHeater(**hpwh_init_args) - def test_update_external_control(self): - self.wh.mode = 'Off' - control_signal = {'HP Duty Cycle': 0, 'ER Duty Cycle': 0.9} - mode = self.wh.update_external_control(update_args_no_draw, control_signal) - self.assertEqual(mode, 'Off') - - self.wh.mode = 'Off' - control_signal = {'HP Duty Cycle': 0.6, 'ER Duty Cycle': 0.4} - mode = self.wh.update_external_control(update_args_no_draw, control_signal) - self.assertEqual(mode, 'Heat Pump On') - - self.wh.mode = 'Upper On' - control_signal = {'HP Duty Cycle': 0.5, 'ER Duty Cycle': 0} - mode = self.wh.update_external_control(update_args_no_draw, control_signal) - self.assertEqual(mode, 'Heat Pump On') - - self.wh.mode = 'Heat Pump On' - self.wh.model.states[self.wh.t_upper_idx] = 60 - control_signal = {'HP Duty Cycle': 0.5, 'ER Duty Cycle': 0} - mode = self.wh.update_external_control(update_args_no_draw, control_signal) - self.assertEqual(mode, 'Off') - - # test HP only mode - self.wh.hp_only_mode = True - self.wh.mode = 'Off' - control_signal = {'HP Duty Cycle': 1, 'ER Duty Cycle': 1} - mode = self.wh.update_external_control(update_args_no_draw, control_signal) - self.assertEqual(mode, 'Heat Pump On') - - self.wh.mode = 'Off' - control_signal = {'HP Duty Cycle': 0, 'ER Duty Cycle': 1} - mode = self.wh.update_external_control(update_args_no_draw, control_signal) - self.assertEqual(mode, 'Off') + # def test_parse_control_signal(self): - def test_update_internal_control(self): + def test_run_internal_control(self): # TODO: Jeff - may need more tests here to make sure HP thermostat control works self.assertEqual(self.wh.model.n_nodes, 12) self.wh.mode = 'Off' - mode = self.wh.update_internal_control(update_args_no_draw) + mode = self.wh.run_internal_control(update_args_no_draw) self.assertEqual(mode, None) self.wh.mode = 'Heat Pump On' - mode = self.wh.update_internal_control(update_args_no_draw) + mode = self.wh.run_internal_control(update_args_no_draw) self.assertEqual(mode, None) self.wh.mode = 'Upper On' - mode = self.wh.update_internal_control(update_args_no_draw) + mode = self.wh.run_internal_control(update_args_no_draw) self.assertEqual(mode, 'Upper On') self.wh.mode = 'Off' self.wh.model.states[self.wh.t_lower_idx] = 20 - mode = self.wh.update_internal_control(update_args_no_draw) + mode = self.wh.run_internal_control(update_args_no_draw) self.assertEqual(mode, 'Heat Pump On') self.wh.mode = 'Off' self.wh.model.states[self.wh.t_upper_idx] = 30 - mode = self.wh.update_internal_control(update_args_no_draw) + mode = self.wh.run_internal_control(update_args_no_draw) self.assertEqual(mode, 'Upper On') # test HP only mode self.wh.hp_only_mode = True self.wh.mode = 'Off' - mode = self.wh.update_internal_control(update_args_no_draw) + mode = self.wh.run_internal_control(update_args_no_draw) self.assertEqual(mode, 'Heat Pump On') self.wh.mode = 'Heat Pump On' self.wh.model.states[self.wh.t_upper_idx] = 60 self.wh.model.states[self.wh.t_lower_idx] = 60 - mode = self.wh.update_internal_control(update_args_no_draw) + mode = self.wh.run_internal_control(update_args_no_draw) self.assertEqual(mode, 'Off') def test_add_heat_from_mode(self): @@ -365,7 +289,7 @@ def test_calculate_power_and_heat(self): self.assertAlmostEqual(self.wh.electric_kw, 1.05, places=2) # test with ideal capacity - self.wh.use_ideal_capacity = True + self.wh.use_ideal_moderue self.wh.duty_cycle_by_mode['Heat Pump On'] = 0 self.wh.calculate_power_and_heat(update_args_no_draw) self.assertAlmostEqual(self.wh.delivered_heat, 0, places=-1) @@ -413,7 +337,7 @@ def test_calculate_power_and_heat(self): self.assertEqual(self.wh.electric_kw, 0) # Test with ideal capacity - self.wh.use_ideal_capacity = True + self.wh.use_ideal_moderue self.wh.duty_cycle_by_mode['On'] = 0.125 self.wh.mode = 'On' self.wh.calculate_power_and_heat(update_args_no_draw) @@ -430,15 +354,15 @@ def setUp(self): self.wh = TanklessWaterHeater(**init_args) def test_init(self): - self.assertEqual(self.wh.use_ideal_capacity, True) + self.assertEqual(self.wh.use_ideal_modeue) self.assertTrue(isinstance(self.wh.model, IdealWaterModel)) - def test_update_internal_control(self): - mode = self.wh.update_internal_control(update_args_no_draw) + def test_run_internal_control(self): + mode = self.wh.run_internal_control(update_args_no_draw) self.assertAlmostEqual(self.wh.duty_cycle_by_mode['On'], 0, places=0) self.assertEqual(mode, 'Off') - mode = self.wh.update_internal_control(update_args_small_draw) + mode = self.wh.run_internal_control(update_args_small_draw) self.assertAlmostEqual(self.wh.duty_cycle_by_mode['On'], 1, places=-1) self.assertEqual(mode, 'On')