From 1dd5c58fa3ccdde7ad949b47fd892b0c4af67987 Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Mon, 2 Sep 2024 15:28:06 -0600 Subject: [PATCH 01/18] adding ThermostaticLoad --- ochre/Equipment/HVAC.py | 28 ++-- ochre/Equipment/ThermostaticLoad.py | 238 ++++++++++++++++++++++++++++ ochre/Equipment/WaterHeater.py | 10 +- ochre/Equipment/__init__.py | 1 + test/test_equipment/test_hvac.py | 2 +- 5 files changed, 257 insertions(+), 22 deletions(-) create mode 100644 ochre/Equipment/ThermostaticLoad.py diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index 4064ab9..082dff2 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. @@ -43,9 +43,8 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): # 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) + 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 +120,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 +130,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) @@ -180,7 +179,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) + self.sub_simulators.append(self.thermal_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 @@ -330,13 +329,12 @@ def update_setpoint(self): self.temp_setpoint = t_set # 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: @@ -368,7 +366,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 diff --git a/ochre/Equipment/ThermostaticLoad.py b/ochre/Equipment/ThermostaticLoad.py new file mode 100644 index 0000000..fe66461 --- /dev/null +++ b/ochre/Equipment/ThermostaticLoad.py @@ -0,0 +1,238 @@ +import numpy as np +import datetime as dt + +from ochre.utils import OCHREException +from ochre.utils.units import convert, kwh_to_therms +from ochre.Equipment import Equipment +from ochre.Models import OneNodeWaterModel, TwoNodeWaterModel, StratifiedWaterModel, IdealWaterModel + + +class ThermostaticLoad(Equipment): + optional_inputs = [ + "Water Heating Setpoint (C)", + "Water Heating Deadband (C)", + "Water Heating Max Power (kW)", + "Zone Temperature (C)", # Needed for Water tank model + ] + + def __init__(self, thermal_model=None, use_ideal_capacity=None, **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) + + # 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 + + # Control parameters + # note: bottom of deadband is (setpoint_temp - deadband_temp) + self.setpoint_temp = kwargs['Setpoint Temperature (C)'] + self.setpoint_temp_ext = None + self.setpoint_ramp_rate = kwargs.get('Max Setpoint Ramp Rate (C/min)') # max setpoint ramp rate, in C/min + # TODO: convert to deadband min and max temps + 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)') + + # Thermal model parameters + self.delivered_heat = 0 # heat delivered to the model, in W + + 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.thermal_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] + + 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.thermal_model.update_model() + off_states = self.thermal_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.thermal_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.thermal_model.n_nodes <= 2: + t_lower = self.thermal_model.states[self.t_lower_idx] + else: + # take average of lower node and node above + t_lower = (self.thermal_model.states[self.t_lower_idx] + self.thermal_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.thermal_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.thermal_model.solve_for_input(self.thermal_model.t_1_idx, self.thermal_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 generate_results(self): + results = super().generate_results() + + # Note: using end use, not equipment name, for all results + if self.verbosity >= 3: + results[f'{self.end_use} Delivered (W)'] = self.delivered_heat + if self.verbosity >= 6: + 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()) + + return results diff --git a/ochre/Equipment/WaterHeater.py b/ochre/Equipment/WaterHeater.py index 1474aca..e8db17e 100644 --- a/ochre/Equipment/WaterHeater.py +++ b/ochre/Equipment/WaterHeater.py @@ -9,11 +9,11 @@ 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 @@ -41,11 +41,9 @@ 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) + super().__init__(model=thermal_model, **kwargs) # By default, use ideal capacity if time resolution > 5 minutes if use_ideal_capacity is None: 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_equipment/test_hvac.py b/test/test_equipment/test_hvac.py index caf0e7c..c952f95 100644 --- a/test/test_equipment/test_hvac.py +++ b/test/test_equipment/test_hvac.py @@ -75,7 +75,7 @@ def test_init(self): 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): From 0567f7864dc422907d920cb666e3d7b7f647f6f0 Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Mon, 2 Sep 2024 15:45:37 -0600 Subject: [PATCH 02/18] update `use_ideal_capacity` input to `use_ideal_mode` --- bin/run_dwelling.py | 4 +- docs/source/InputsAndArguments.rst | 4 +- ochre/Dwelling.py | 4 +- ochre/Equipment/HVAC.py | 59 ++++++++++++------------- ochre/Equipment/ThermostaticLoad.py | 17 ++++--- ochre/Equipment/WaterHeater.py | 23 ++++------ test/test_dwelling/test_dwelling.py | 2 +- test/test_equipment/test_hvac.py | 12 ++--- test/test_equipment/test_waterheater.py | 12 ++--- 9 files changed, 66 insertions(+), 71 deletions(-) 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/docs/source/InputsAndArguments.rst b/docs/source/InputsAndArguments.rst index 517126b..e126a56 100644 --- a/docs/source/InputsAndArguments.rst +++ b/docs/source/InputsAndArguments.rst @@ -352,7 +352,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 +422,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/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/HVAC.py b/ochre/Equipment/HVAC.py index 082dff2..15b88fe 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -27,9 +27,8 @@ class HVAC(ThermostaticLoad): 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,10 +39,17 @@ 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 + # 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 @@ -181,12 +187,6 @@ def __init__(self, envelope_model=None, use_ideal_capacity=None, **kwargs): if self.main_simulator: self.sub_simulators.append(self.thermal_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 - def initialize_schedule(self, schedule=None, **kwargs): # Compile all HVAC required inputs required_inputs = [f'{self.end_use} Setpoint (C)'] @@ -228,10 +228,10 @@ def update_external_control(self, 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 or control "Duty Cycle".' ) if f"{self.end_use} Max Capacity Fraction (-)" in self.current_schedule: self.current_schedule[f"{self.end_use} Max Capacity Fraction (-)"] = capacity_frac @@ -240,10 +240,10 @@ 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 or control "Duty Cycle".' ) if f"{self.end_use} Capacity (W)" in self.current_schedule: self.current_schedule[f"{self.end_use} Capacity (W)"] = capacity @@ -258,10 +258,10 @@ def update_external_control(self, control_signal): 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: + if self.use_ideal_mode: raise IOError( f"Cannot set {self.name} Duty Cycle. " - 'Set `use_ideal_capacity` to False or use "Capacity" control.' + 'Set `use_ideal_mode` to False or use "Capacity" control.' ) duty_cycles = self.parse_duty_cycles(control_signal) return self.run_duty_cycle_control(duty_cycles) @@ -303,7 +303,7 @@ def update_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() @@ -376,7 +376,7 @@ 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() capacity = self.capacity_ideal @@ -451,7 +451,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: @@ -563,7 +563,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, } @@ -659,9 +659,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 @@ -811,7 +808,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: @@ -867,7 +864,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)] @@ -1017,7 +1014,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 @@ -1085,10 +1082,10 @@ def update_external_control(self, control_signal): 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 or control "ER Duty Cycle".' ) 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 @@ -1097,10 +1094,10 @@ 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 or control "ER Duty Cycle".' ) if f"{self.end_use} Capacity (W)" in self.current_schedule: self.current_schedule[f"{self.end_use} Capacity (W)"] = capacity @@ -1124,7 +1121,7 @@ def parse_duty_cycles(self, control_signal): return duty_cycles def update_internal_control(self): - if self.use_ideal_capacity: + if self.use_ideal_mode: # Note: not calling super().update_internal_control # Update setpoint from schedule self.update_setpoint() @@ -1177,7 +1174,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: @@ -1207,7 +1204,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/ThermostaticLoad.py b/ochre/Equipment/ThermostaticLoad.py index fe66461..e7d8c1b 100644 --- a/ochre/Equipment/ThermostaticLoad.py +++ b/ochre/Equipment/ThermostaticLoad.py @@ -15,7 +15,7 @@ class ThermostaticLoad(Equipment): "Zone Temperature (C)", # Needed for Water tank model ] - def __init__(self, thermal_model=None, use_ideal_capacity=None, **kwargs): + 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 @@ -41,10 +41,13 @@ def __init__(self, thermal_model=None, use_ideal_capacity=None, **kwargs): self.thermal_model = thermal_model self.sub_simulators.append(self.thermal_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 + # 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 # Control parameters # note: bottom of deadband is (setpoint_temp - deadband_temp) @@ -128,7 +131,7 @@ def run_duty_cycle_control(self, duty_cycles): ) return "Off" - if self.use_ideal_capacity: + if self.use_ideal_mode: # 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] @@ -197,7 +200,7 @@ def run_thermostat_control(self): def update_internal_control(self): self.update_setpoint() - if self.use_ideal_capacity: + if self.use_ideal_mode: if self.thermal_model.n_nodes == 1: # FUTURE: remove if not being used # calculate ideal capacity based on tank model - more accurate than self.solve_ideal_capacity diff --git a/ochre/Equipment/WaterHeater.py b/ochre/Equipment/WaterHeater.py index e8db17e..2f80c30 100644 --- a/ochre/Equipment/WaterHeater.py +++ b/ochre/Equipment/WaterHeater.py @@ -24,7 +24,7 @@ class WaterHeater(ThermostaticLoad): "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) @@ -45,11 +45,6 @@ def __init__(self, use_ideal_capacity=None, model_class=None, **kwargs): super().__init__(model=thermal_model, **kwargs) - # 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) @@ -150,7 +145,7 @@ def run_duty_cycle_control(self, duty_cycles): ) return "Off" - if self.use_ideal_capacity: + if self.use_ideal_mode: # 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] @@ -219,7 +214,7 @@ def run_thermostat_control(self): def update_internal_control(self): self.update_setpoint() - if self.use_ideal_capacity: + if self.use_ideal_mode: 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 @@ -255,7 +250,7 @@ def add_heat_from_mode(self, mode, heats_to_tank=None, duty_cycle=1): def calculate_power_and_heat(self): # get heat injections from water heater - if self.use_ideal_capacity and self.mode != 'Off': + if self.use_ideal_mode 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) @@ -334,7 +329,7 @@ class ElectricResistanceWaterHeater(WaterHeater): 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: + if self.use_ideal_mode: # determine optimal allocation of upper/lower elements self.solve_ideal_capacity() @@ -350,7 +345,7 @@ def run_duty_cycle_control(self, duty_cycles): mode = super().run_duty_cycle_control(duty_cycles) - if not self.use_ideal_capacity: + if not self.use_ideal_mode: # 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: @@ -591,7 +586,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: @@ -634,7 +629,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 @@ -671,7 +666,7 @@ class TanklessWaterHeater(WaterHeater): 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 diff --git a/test/test_dwelling/test_dwelling.py b/test/test_dwelling/test_dwelling.py index 076b4b4..41896f7 100644 --- a/test/test_dwelling/test_dwelling.py +++ b/test/test_dwelling/test_dwelling.py @@ -27,7 +27,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}, } diff --git a/test/test_equipment/test_hvac.py b/test/test_equipment/test_hvac.py index c952f95..d7be367 100644 --- a/test/test_equipment/test_hvac.py +++ b/test/test_equipment/test_hvac.py @@ -71,7 +71,7 @@ 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) @@ -212,7 +212,7 @@ def test_generate_results(self): class IdealHeaterTestCase(unittest.TestCase): def setUp(self): - self.hvac = Heater(use_ideal_capacity=True, **init_args) + self.hvac = Heater(use_ideal_mode=True, **init_args) def test_run_duty_cycle_control(self): self.hvac.mode = 'Off' @@ -267,7 +267,7 @@ 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 @@ -326,7 +326,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' @@ -521,7 +521,7 @@ 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): @@ -745,7 +745,7 @@ def test_update_eir(self): class VariableSpeedASHPHeaterTestCase(unittest.TestCase): def setUp(self): - self.hvac = ASHPHeater(use_ideal_capacity=True, **init_args) + self.hvac = ASHPHeater(use_ideal_mode=True, **init_args) def test_run_duty_cycle_control(self): mode = self.hvac.run_duty_cycle_control(update_args_heat, {'Duty Cycle': 0}) diff --git a/test/test_equipment/test_waterheater.py b/test/test_equipment/test_waterheater.py index 4087a6b..36bf442 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) @@ -155,13 +155,13 @@ 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) @@ -365,7 +365,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 +413,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,7 +430,7 @@ 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): From 0ef9c751a55e36fc5128161e305dad1248cbe332 Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Tue, 3 Sep 2024 11:48:42 -0600 Subject: [PATCH 03/18] replace `update_external_control` with `parse_control_signal` --- ochre/Equipment/Battery.py | 10 +-- ochre/Equipment/EV.py | 26 +++---- ochre/Equipment/Equipment.py | 13 ++-- ochre/Equipment/EventBasedLoad.py | 12 ++-- ochre/Equipment/Generator.py | 10 +-- ochre/Equipment/HVAC.py | 14 ++-- ochre/Equipment/PV.py | 39 ++++++----- ochre/Equipment/ScheduledLoad.py | 20 +++--- ochre/Equipment/ThermostaticLoad.py | 70 ++++++++----------- ochre/Equipment/WaterHeater.py | 90 ++++++++++++------------- test/test_equipment/test_battery.py | 6 +- test/test_equipment/test_ev.py | 18 ++--- test/test_equipment/test_eventbased.py | 10 +-- test/test_equipment/test_generator.py | 12 ++-- test/test_equipment/test_hvac.py | 18 ++--- test/test_equipment/test_pv.py | 14 ++-- test/test_equipment/test_scheduled.py | 8 +-- test/test_equipment/test_waterheater.py | 48 ++++++------- 18 files changed, 207 insertions(+), 231 deletions(-) diff --git a/ochre/Equipment/Battery.py b/ochre/Equipment/Battery.py index 645e3af..165f29a 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 diff --git a/ochre/Equipment/EV.py b/ochre/Equipment/EV.py index 3ece4a5..37465b2 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 self.unmet_load = 0 # update control parameters from schedule @@ -290,9 +283,8 @@ def calculate_power_and_heat(self): # 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), self.max_power_ctrl) 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..4f32881 100644 --- a/ochre/Equipment/Equipment.py +++ b/ochre/Equipment/Equipment.py @@ -23,7 +23,7 @@ def __init__(self, zone_name=None, envelope_model=None, ext_time_res=None, save_ - A control algorithm to determine the mode (update_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) + - A control algorithm to use for 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: @@ -140,7 +140,7 @@ def calculate_mode_priority(self, *duty_cycles): 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)) @@ -180,11 +180,12 @@ def run_zip(self, v, v0=1): self.electric_kw = self.electric_kw * zip_q.dot(v_quadratic) def update_model(self, control_signal=None): - # run equipment controller to determine mode + # update equipment based on control signal if control_signal: - mode = self.update_external_control(control_signal) - else: - mode = self.update_internal_control() + self.parse_control_signal(control_signal) + + # run equipment controller to determine mode + 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 diff --git a/ochre/Equipment/EventBasedLoad.py b/ochre/Equipment/EventBasedLoad.py index 47de2bd..03284b4 100644 --- a/ochre/Equipment/EventBasedLoad.py +++ b/ochre/Equipment/EventBasedLoad.py @@ -125,7 +125,7 @@ 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 @@ -141,17 +141,15 @@ 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): if self.current_time < self.event_start: diff --git a/ochre/Equipment/Generator.py b/ochre/Equipment/Generator.py index 2eb8ffd..bf9cbbe 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,13 +91,7 @@ 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.power_setpoint = power_setpoint - return "On" if self.power_setpoint != 0 else "Off" - - return self.update_internal_control() + self.current_schedule[f"{self.end_use} Electric Power (kW)"] = power_setpoint def update_internal_control(self): if f"{self.end_use} Max Import Limit (kW)" in self.current_schedule: diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index 15b88fe..c7db948 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -199,7 +199,7 @@ 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) @@ -738,7 +738,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 @@ -746,7 +746,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 @@ -1049,7 +1049,7 @@ class ASHPHeater(HeatPumpHeater): # - 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 + # - ER Duty Cycle: Combines with "Duty Cycle" control, see HVAC.parse_control_signal ] def __init__(self, **kwargs): @@ -1072,13 +1072,13 @@ 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 + # - ER Duty Cycle: Combines with "Duty Cycle" control, see HVAC.parse_control_signal capacity_frac = control_signal.get("Max ER Capacity Fraction") if capacity_frac is not None: @@ -1104,7 +1104,7 @@ def update_external_control(self, control_signal): else: self.er_ext_capacity = capacity - return super().update_external_control(control_signal) + return super().parse_control_signal(control_signal) def parse_duty_cycles(self, control_signal): # If duty cycles exist, combine duty cycles for HP and ER modes diff --git a/ochre/Equipment/PV.py b/ochre/Equipment/PV.py index b0bb9fb..1004781 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,13 +195,16 @@ 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 update_internal_control(self): - # Set to maximum P, Q=0 + # Set P to maximum power from schedule super().update_internal_control() - self.p_set_point = min(self.p_set_point, 0) - self.q_set_point = 0 + + # 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) + return 'On' if self.p_set_point < 0 else 'Off' def calculate_power_and_heat(self): diff --git a/ochre/Equipment/ScheduledLoad.py b/ochre/Equipment/ScheduledLoad.py index ef6e22c..e8ddb9e 100644 --- a/ochre/Equipment/ScheduledLoad.py +++ b/ochre/Equipment/ScheduledLoad.py @@ -78,27 +78,25 @@ 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): if self.is_electric: diff --git a/ochre/Equipment/ThermostaticLoad.py b/ochre/Equipment/ThermostaticLoad.py index e7d8c1b..3cbd183 100644 --- a/ochre/Equipment/ThermostaticLoad.py +++ b/ochre/Equipment/ThermostaticLoad.py @@ -45,23 +45,24 @@ def __init__(self, thermal_model=None, use_ideal_mode=None, prevent_overshoot=Tr 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 # Control parameters # note: bottom of deadband is (setpoint_temp - deadband_temp) - self.setpoint_temp = kwargs['Setpoint Temperature (C)'] - self.setpoint_temp_ext = None + self.temp_setpoint = kwargs['Setpoint Temperature (C)'] + self.temp_setpoint_ext = None self.setpoint_ramp_rate = kwargs.get('Max Setpoint Ramp Rate (C/min)') # max setpoint ramp rate, in C/min # TODO: convert to deadband min and max temps - self.deadband_temp = kwargs.get('Deadband Temperature (C)', 5.56) # deadband range, in delta degC, i.e. Kelvin + self.temp_deadband = kwargs.get('Deadband Temperature (C)', 5.56) # deadband range, in delta degC, i.e. Kelvin self.max_power = kwargs.get('Max Power (kW)') + self.force_off = False # Thermal model parameters self.delivered_heat = 0 # heat delivered to the model, in W - 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 WH off) # - Setpoint: Updates setpoint temperature from the default (in C) @@ -78,35 +79,30 @@ def update_external_control(self, control_signal): 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 + if f"{self.end_use} Setpoint (C)" in self.current_schedule: + self.current_schedule[f"{self.end_use} Setpoint (C)"] = ext_setpoint else: # Note that this overrides the ramp rate - self.setpoint_temp = ext_setpoint + self.temp_setpoint = 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 + if f"{self.end_use} Deadband (C)" in self.current_schedule: + self.current_schedule[f"{self.end_use} Deadband (C)"] = ext_db else: - self.deadband_temp = ext_db + self.temp_deadband = 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 + 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 + # If load fraction = 0, force off (max power = 0) load_fraction = control_signal.get("Load Fraction", 1) if load_fraction == 0: - return "Off" + 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") @@ -118,19 +114,9 @@ def update_external_control(self, control_signal): 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() + self.run_duty_cycle_control(duty_cycles) def run_duty_cycle_control(self, duty_cycles): - # Force off if temperature exceeds maximum, and print warning - t_tank = self.thermal_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_mode: # Set capacity directly from duty cycle self.update_duty_cycles(*duty_cycles) @@ -152,16 +138,16 @@ def update_setpoint(self): 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 + t_set_new = self.temp_setpoint # update setpoint with ramp rate - if self.setpoint_ramp_rate and self.setpoint_temp != t_set_new: + if self.setpoint_ramp_rate and self.temp_setpoint != 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, + self.temp_setpoint = min(max(t_set_new, self.temp_setpoint - delta_t), + self.temp_setpoint + delta_t, ) else: - self.setpoint_temp = t_set_new + self.temp_setpoint = t_set_new # get other controls from schedule - deadband and max power if "Water Heating Deadband (C)" in self.current_schedule: @@ -176,7 +162,7 @@ def solve_ideal_capacity(self): off_states = self.thermal_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 + set_states = np.ones(len(off_states)) * self.temp_setpoint h_desired = np.dot(set_states[:self.t_lower_idx + 1] - off_states[:self.t_lower_idx + 1], # in W self.thermal_model.capacitances[:self.t_lower_idx + 1]) / self.time_res.total_seconds() @@ -192,9 +178,9 @@ def run_thermostat_control(self): # take average of lower node and node above t_lower = (self.thermal_model.states[self.t_lower_idx] + self.thermal_model.states[self.t_lower_idx - 1]) / 2 - if t_lower < self.setpoint_temp - self.deadband_temp: + if t_lower < self.temp_setpoint - self.temp_deadband: return 'On' - if t_lower > self.setpoint_temp: + if t_lower > self.temp_setpoint: return 'Off' def update_internal_control(self): @@ -206,7 +192,7 @@ def update_internal_control(self): # 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.thermal_model.solve_for_input(self.thermal_model.t_1_idx, self.thermal_model.h_1_idx, self.setpoint_temp, + h_desired = self.thermal_model.solve_for_input(self.thermal_model.t_1_idx, self.thermal_model.h_1_idx, self.temp_setpoint, solve_as_output=False) # Only allow heating, convert to duty cycle @@ -232,8 +218,8 @@ 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 + 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 if self.save_ebm_results: results.update(self.make_equivalent_battery_model()) diff --git a/ochre/Equipment/WaterHeater.py b/ochre/Equipment/WaterHeater.py index 2f80c30..b3d98cd 100644 --- a/ochre/Equipment/WaterHeater.py +++ b/ochre/Equipment/WaterHeater.py @@ -61,11 +61,11 @@ def __init__(self, model_class=None, **kwargs): # 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.temp_setpoint = kwargs['Setpoint Temperature (C)'] + self.temp_setpoint_ext = None + self.temp_max = 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.temp_deadband = kwargs.get('Deadband Temperature (C)', 5.56) # deadband range, in delta degC, i.e. Kelvin self.max_power = kwargs.get('Max Power (kW)') def update_inputs(self, schedule_inputs=None): @@ -75,7 +75,7 @@ def update_inputs(self, schedule_inputs=None): super().update_inputs(schedule_inputs) - 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 WH off) # - Setpoint: Updates setpoint temperature from the default (in C) @@ -92,23 +92,18 @@ def update_external_control(self, control_signal): 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 + self.temp_setpoint = 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 + self.temp_deadband = ext_db max_power = control_signal.get("Max Power") if max_power is not None: @@ -139,9 +134,9 @@ def update_external_control(self, control_signal): 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: + if t_tank > self.temp_max: self.warn( - f"Temperature over maximum temperature ({self.max_temp}C), forcing off" + f"Temperature over maximum temperature ({self.temp_max}C), forcing off" ) return "Off" @@ -166,16 +161,21 @@ def update_setpoint(self): 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 - + t_set_new = self.temp_setpoint + if t_set_new > self.temp_max: + self.warn( + f"Setpoint cannot exceed {self.temp_max}C. Setting setpoint to maximum value." + ) + t_set_new = self.temp_max + # update setpoint with ramp rate - if self.setpoint_ramp_rate and self.setpoint_temp != t_set_new: + if self.setpoint_ramp_rate and self.temp_setpoint != 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, + self.temp_setpoint = min(max(t_set_new, self.temp_setpoint - delta_t), + self.temp_setpoint + delta_t, ) else: - self.setpoint_temp = t_set_new + self.temp_setpoint = t_set_new # get other controls from schedule - deadband and max power if "Water Heating Deadband (C)" in self.current_schedule: @@ -190,7 +190,7 @@ def solve_ideal_capacity(self): 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 + set_states = np.ones(len(off_states)) * self.temp_setpoint 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() @@ -206,9 +206,9 @@ def run_thermostat_control(self): # 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: + if t_lower < self.temp_setpoint - self.temp_deadband: return 'On' - if t_lower > self.setpoint_temp: + if t_lower > self.temp_setpoint: return 'Off' def update_internal_control(self): @@ -220,7 +220,7 @@ def update_internal_control(self): # 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, + h_desired = self.model.solve_for_input(self.model.t_1_idx, self.model.h_1_idx, self.temp_setpoint, solve_as_output=False) # Only allow heating, convert to duty cycle @@ -294,8 +294,8 @@ 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 + 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 if self.save_ebm_results: results.update(self.make_equivalent_battery_model()) @@ -314,8 +314,8 @@ def make_equivalent_battery_model(self): baseline_power = (self.model.h_loss + self.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, @@ -348,7 +348,7 @@ def run_duty_cycle_control(self, duty_cycles): if not self.use_ideal_mode: # 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: + if mode == 'Upper On' and t_upper > self.temp_setpoint: mode = 'Lower On' # If mode is ER, add time to both mode_counters @@ -366,7 +366,7 @@ def solve_ideal_capacity(self): 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 + set_states = np.ones(len(off_states)) * self.temp_setpoint 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 @@ -387,14 +387,14 @@ def run_thermostat_control(self): # 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): + lower_threshold_temp = self.temp_setpoint - self.temp_deadband + if t_upper < lower_threshold_temp or (self.mode == 'Upper On' and t_upper < self.temp_setpoint): return 'Upper On' if t_lower < lower_threshold_temp: return 'Lower On' - if self.mode == 'Upper On' and t_upper > self.setpoint_temp: + if self.mode == 'Upper On' and t_upper > self.temp_setpoint: return 'Off' - if t_lower > self.setpoint_temp: + if t_lower > self.temp_setpoint: return 'Off' @@ -472,7 +472,7 @@ def update_inputs(self, schedule_inputs=None): super().update_inputs(schedule_inputs) - def update_external_control(self, control_signal): + def parse_control_signal(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), @@ -480,7 +480,7 @@ def update_external_control(self, control_signal): # TODO: update schedule, not control signal control_signal['Duty Cycle'] = duty_cycles - return super().update_external_control(control_signal) + return super().parse_control_signal(control_signal) def solve_ideal_capacity(self): # calculate ideal capacity based on future thermostat control @@ -500,7 +500,7 @@ def solve_ideal_capacity(self): 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 @@ -540,16 +540,16 @@ def run_thermostat_control(self, use_future_states=False): 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): @@ -672,11 +672,11 @@ def __init__(self, **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.model.states[self.t_upper_idx] = self.temp_setpoint def update_internal_control(self): self.update_setpoint() - self.model.states[self.t_upper_idx] = self.setpoint_temp + self.model.states[self.t_upper_idx] = self.temp_setpoint self.heat_from_draw = -self.model.update_water_draw()[0] self.heat_from_draw = max(self.heat_from_draw, 0) @@ -694,7 +694,7 @@ def calculate_power_and_heat(self): 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_set = self.temp_setpoint 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 diff --git a/test/test_equipment/test_battery.py b/test/test_equipment/test_battery.py index adde5a0..3ea1179 100644 --- a/test/test_equipment/test_battery.py +++ b/test/test_equipment/test_battery.py @@ -42,12 +42,12 @@ 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): diff --git a/test/test_equipment/test_ev.py b/test/test_equipment/test_ev.py index 924611e..91bcdd6 100644 --- a/test/test_equipment/test_ev.py +++ b/test/test_equipment/test_ev.py @@ -60,45 +60,45 @@ 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): diff --git a/test/test_equipment/test_eventbased.py b/test/test_equipment/test_eventbased.py index 3a27ed4..e9a20a5 100644 --- a/test/test_equipment/test_eventbased.py +++ b/test/test_equipment/test_eventbased.py @@ -28,23 +28,23 @@ 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') - 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') - 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') # 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') diff --git a/test/test_equipment/test_generator.py b/test/test_equipment/test_generator.py index cbbbb22..1e1c854 100644 --- a/test/test_equipment/test_generator.py +++ b/test/test_equipment/test_generator.py @@ -20,29 +20,29 @@ 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}) + mode = self.generator.parse_control_signal({}, {'P Setpoint': -2}) self.assertEqual(mode, 'On') self.assertEqual(self.generator.power_setpoint, -2) - mode = self.generator.update_external_control({}, {'P Setpoint': 0}) + mode = self.generator.parse_control_signal({}, {'P Setpoint': 0}) self.assertEqual(mode, 'Off') # test control type control_signal = {'Control Type': 'Schedule'} - mode = self.generator.update_external_control({}, control_signal) + mode = self.generator.parse_control_signal({}, control_signal) self.assertEqual(mode, 'Off') self.assertEqual(self.generator.control_type, 'Schedule') control_signal = {'Control Type': 'Other'} - mode = self.generator.update_external_control({}, control_signal) + mode = self.generator.parse_control_signal({}, control_signal) self.assertEqual(mode, 'Off') 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) + mode = self.generator.parse_control_signal({}, control_signal) self.assertEqual(mode, 'On') self.assertEqual(self.generator.control_type, 'Schedule') self.assertEqual(self.generator.power_setpoint, 1) diff --git a/test/test_equipment/test_hvac.py b/test/test_equipment/test_hvac.py index d7be367..b63ee43 100644 --- a/test/test_equipment/test_hvac.py +++ b/test/test_equipment/test_hvac.py @@ -78,27 +78,27 @@ def test_init(self): 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) @@ -386,14 +386,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]) diff --git a/test/test_equipment/test_pv.py b/test/test_equipment/test_pv.py index 63ca1b8..c1fcedf 100644 --- a/test/test_equipment/test_pv.py +++ b/test/test_equipment/test_pv.py @@ -37,35 +37,35 @@ 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}) + def test_parse_control_signal(self): + mode = self.pv.parse_control_signal({}, {'P Setpoint': -5, 'Q Setpoint': 1}) self.assertEqual(mode, 'On') 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}) + mode = self.pv.parse_control_signal({}, {'P Setpoint': -20, 'Q Setpoint': 1}) self.assertEqual(mode, 'On') 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}) + mode = self.pv.parse_control_signal({}, {'P Curtailment (kW)': 1}) self.assertEqual(mode, 'On') self.assertAlmostEqual(self.pv.p_set_point, -7.25, places=2) # test PV curtailment - mode = self.pv.update_external_control({}, {'P Curtailment (%)': 50}) + mode = self.pv.parse_control_signal({}, {'P Curtailment (%)': 50}) self.assertEqual(mode, 'On') self.assertAlmostEqual(self.pv.p_set_point, -4.13, places=2) # test power factor - mode = self.pv.update_external_control({}, {'Power Factor': -0.95}) + mode = self.pv.parse_control_signal({}, {'Power Factor': -0.95}) self.assertEqual(mode, 'On') 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): diff --git a/test/test_equipment/test_scheduled.py b/test/test_equipment/test_scheduled.py index de0771f..19e7be2 100644 --- a/test/test_equipment/test_scheduled.py +++ b/test/test_equipment/test_scheduled.py @@ -40,16 +40,16 @@ 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}) + def test_parse_control_signal(self): + mode = self.equipment.parse_control_signal({'plug_loads': 100}, {'Load Fraction': 1}) self.assertEqual(mode, 'On') self.assertAlmostEqual(self.equipment.p_set_point, 0.1) - mode = self.equipment.update_external_control({'plug_loads': 200}, {'Load Fraction': 0.5}) + mode = self.equipment.parse_control_signal({'plug_loads': 200}, {'Load Fraction': 0.5}) self.assertEqual(mode, 'On') self.assertAlmostEqual(self.equipment.p_set_point, 0.2 * 0.5) - mode = self.equipment.update_external_control({'plug_loads': 200}, {'Load Fraction': 0}) + mode = self.equipment.parse_control_signal({'plug_loads': 200}, {'Load Fraction': 0}) self.assertEqual(mode, 'Off') self.assertAlmostEqual(self.equipment.p_set_point, 0) diff --git a/test/test_equipment/test_waterheater.py b/test/test_equipment/test_waterheater.py index 36bf442..1ae22a1 100644 --- a/test/test_equipment/test_waterheater.py +++ b/test/test_equipment/test_waterheater.py @@ -45,54 +45,54 @@ 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}) + mode = self.wh.parse_control_signal(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]}) + mode = self.wh.parse_control_signal(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}) + mode = self.wh.parse_control_signal(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}) + mode = self.wh.parse_control_signal(update_args_no_draw, {'Duty Cycle': 0.5}) self.assertEqual(mode, 'Off') def test_run_thermostat_control(self): @@ -168,15 +168,15 @@ def test_init(self): 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}) + mode = self.wh.parse_control_signal(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]}) + mode = self.wh.parse_control_signal(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}) + mode = self.wh.parse_control_signal(update_args_no_draw, {'Duty Cycle': 0}) self.assertEqual(self.wh.duty_cycle_by_mode['On'], 0) self.assertEqual(mode, 'Off') @@ -230,10 +230,10 @@ class ERWaterHeaterTestCase(unittest.TestCase): def setUp(self): self.wh = ElectricResistanceWaterHeater(**init_args) - def test_update_external_control(self): + def test_parse_control_signal(self): self.wh.mode = 'Off' control_signal = {'Duty Cycle': 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, '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)) @@ -242,7 +242,7 @@ def test_update_external_control(self): 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) + mode = self.wh.parse_control_signal(update_args_no_draw, control_signal) self.assertEqual(mode, 'Lower On') def test_update_internal_control(self): @@ -268,38 +268,38 @@ class HPWaterHeaterTestCase(unittest.TestCase): def setUp(self): self.wh = HeatPumpWaterHeater(**hpwh_init_args) - def test_update_external_control(self): + def test_parse_control_signal(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) + mode = self.wh.parse_control_signal(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) + mode = self.wh.parse_control_signal(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) + mode = self.wh.parse_control_signal(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) + mode = self.wh.parse_control_signal(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) + mode = self.wh.parse_control_signal(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) + mode = self.wh.parse_control_signal(update_args_no_draw, control_signal) self.assertEqual(mode, 'Off') def test_update_internal_control(self): From 8a3add9d49444fcb575c9b2f32e0e46483a8d4b3 Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Tue, 3 Sep 2024 13:55:01 -0600 Subject: [PATCH 04/18] remove duty cycle control --- bin/run_external_control.py | 1 - docs/source/ControllerIntegration.rst | 10 --- docs/source/InputsAndArguments.rst | 3 - ochre/Equipment/Equipment.py | 47 -------------- ochre/Equipment/HVAC.py | 74 ++-------------------- ochre/Equipment/ThermostaticLoad.py | 49 +++------------ ochre/Equipment/WaterHeater.py | 51 --------------- test/test_dwelling/test_dwelling.py | 8 --- test/test_equipment/test_equipment.py | 33 ---------- test/test_equipment/test_hvac.py | 72 ---------------------- test/test_equipment/test_waterheater.py | 82 +------------------------ 11 files changed, 16 insertions(+), 414 deletions(-) diff --git a/bin/run_external_control.py b/bin/run_external_control.py index a949958..50ac021 100644 --- a/bin/run_external_control.py +++ b/bin/run_external_control.py @@ -128,7 +128,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 e126a56..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 | +-----------------------------+------------------------+--------------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------+ diff --git a/ochre/Equipment/Equipment.py b/ochre/Equipment/Equipment.py index 4f32881..844cf78 100644 --- a/ochre/Equipment/Equipment.py +++ b/ochre/Equipment/Equipment.py @@ -77,8 +77,6 @@ def __init__(self, zone_name=None, envelope_model=None, ext_time_res=None, save_ 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 def initialize_parameters(self, parameter_file=None, name_col='Name', value_col='Value', **kwargs): if parameter_file is None: @@ -95,51 +93,6 @@ 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 parse_control_signal(self, control_signal): # Overwrite if external control might exist raise OCHREException('Must define external control algorithm for {}'.format(self.name)) diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index c7db948..d006aeb 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -210,10 +210,7 @@ def parse_control_signal(self, control_signal): # - 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 + # TODO: remove duty cycle, ext_time_res, ext_ignore_thermostat ext_setpoint = control_signal.get('Setpoint') if ext_setpoint is not None: @@ -231,7 +228,7 @@ def parse_control_signal(self, control_signal): if not self.use_ideal_mode: raise IOError( f"Cannot set {self.name} Max Capacity Fraction. " - 'Set `use_ideal_mode` 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 @@ -243,7 +240,7 @@ def parse_control_signal(self, control_signal): if not self.use_ideal_mode: raise IOError( f"Cannot set {self.name} Capacity. " - 'Set `use_ideal_mode` 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 @@ -257,48 +254,8 @@ def parse_control_signal(self, control_signal): 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_mode: - raise IOError( - f"Cannot set {self.name} Duty Cycle. " - 'Set `use_ideal_mode` 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 - - # 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): # Update setpoint from schedule self.update_setpoint() @@ -1045,11 +1002,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.parse_control_signal ] def __init__(self, **kwargs): @@ -1078,14 +1030,14 @@ def parse_control_signal(self, control_signal): # - 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.parse_control_signal + # - 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_mode: raise IOError( f"Cannot set {self.name} Max ER Capacity Fraction. " - 'Set `use_ideal_mode` 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 @@ -1097,7 +1049,7 @@ def parse_control_signal(self, control_signal): if not self.use_ideal_mode: raise IOError( f"Cannot set {self.name} ER Capacity. " - 'Set `use_ideal_mode` 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 @@ -1106,20 +1058,6 @@ def parse_control_signal(self, control_signal): return super().parse_control_signal(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 - def update_internal_control(self): if self.use_ideal_mode: # Note: not calling super().update_internal_control diff --git a/ochre/Equipment/ThermostaticLoad.py b/ochre/Equipment/ThermostaticLoad.py index 3cbd183..8ac8d84 100644 --- a/ochre/Equipment/ThermostaticLoad.py +++ b/ochre/Equipment/ThermostaticLoad.py @@ -60,7 +60,8 @@ def __init__(self, thermal_model=None, use_ideal_mode=None, prevent_overshoot=Tr self.force_off = False # Thermal model parameters - self.delivered_heat = 0 # heat delivered to the model, in W + self.capacity = 0 # heat output from main element, in W + self.delivered_heat = 0 # total heat delivered to the model, in W def parse_control_signal(self, control_signal): # Options for external control signals: @@ -72,10 +73,6 @@ def parse_control_signal(self, control_signal): # - 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: @@ -106,33 +103,6 @@ def parse_control_signal(self, control_signal): 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)) - - self.run_duty_cycle_control(duty_cycles) - - def run_duty_cycle_control(self, duty_cycles): - if self.use_ideal_mode: - # 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] - - 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: @@ -166,9 +136,8 @@ def solve_ideal_capacity(self): h_desired = np.dot(set_states[:self.t_lower_idx + 1] - off_states[:self.t_lower_idx + 1], # in W self.thermal_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} + # return ideal capacity, maintain min/max bounds + return min(max(h_desired, 0), self.capacity_rated) def run_thermostat_control(self): # use thermostat with deadband control @@ -196,15 +165,11 @@ def update_internal_control(self): 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 + self.capacity = min(max(h_desired, 0), self.capacity_rated) else: - self.solve_ideal_capacity() + self.capacity = self.solve_ideal_capacity() - return [mode for mode, duty_cycle in self.duty_cycle_by_mode.items() if duty_cycle > 0][0] + return "On" if self.capacity > 0 else "Off" else: return self.run_thermostat_control() diff --git a/ochre/Equipment/WaterHeater.py b/ochre/Equipment/WaterHeater.py index b3d98cd..a4885eb 100644 --- a/ochre/Equipment/WaterHeater.py +++ b/ochre/Equipment/WaterHeater.py @@ -85,10 +85,6 @@ def parse_control_signal(self, control_signal): # - 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: @@ -119,43 +115,6 @@ def parse_control_signal(self, control_signal): 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.temp_max: - self.warn( - f"Temperature over maximum temperature ({self.temp_max}C), forcing off" - ) - return "Off" - - if self.use_ideal_mode: - # 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] - - 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: @@ -472,16 +431,6 @@ def update_inputs(self, schedule_inputs=None): super().update_inputs(schedule_inputs) - def parse_control_signal(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().parse_control_signal(control_signal) - def solve_ideal_capacity(self): # calculate ideal capacity based on future thermostat control if self.er_only_mode: diff --git a/test/test_dwelling/test_dwelling.py b/test/test_dwelling/test_dwelling.py index 41896f7..d279ed6 100644 --- a/test/test_dwelling/test_dwelling.py +++ b/test/test_dwelling/test_dwelling.py @@ -182,14 +182,6 @@ def test_update(self): 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/test_equipment.py b/test/test_equipment/test_equipment.py index d30a135..15a1190 100644 --- a/test/test_equipment/test_equipment.py +++ b/test/test_equipment/test_equipment.py @@ -112,39 +112,6 @@ def test_generate_results(self): 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']) - def test_run_zip(self): pf_multiplier = 0.48432210483785254 self.equipment.electric_kw = 2 diff --git a/test/test_equipment/test_hvac.py b/test/test_equipment/test_hvac.py index b63ee43..b0bfa61 100644 --- a/test/test_equipment/test_hvac.py +++ b/test/test_equipment/test_hvac.py @@ -103,27 +103,6 @@ def test_parse_control_signal(self): 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) self.assertEqual(mode, 'On') @@ -214,17 +193,6 @@ class IdealHeaterTestCase(unittest.TestCase): def setUp(self): self.hvac = Heater(use_ideal_mode=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.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) self.assertEqual(mode, 'On') @@ -524,13 +492,6 @@ def test_init(self): 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,26 +602,6 @@ 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): self.hvac.mode = 'Off' mode = self.hvac.update_internal_control(update_args_heat) @@ -747,19 +688,6 @@ class VariableSpeedASHPHeaterTestCase(unittest.TestCase): def setUp(self): self.hvac = ASHPHeater(use_ideal_mode=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') - def test_update_internal_control(self): mode = self.hvac.update_internal_control(update_args_heat) self.assertEqual(mode, 'HP and ER On') diff --git a/test/test_equipment/test_waterheater.py b/test/test_equipment/test_waterheater.py index 1ae22a1..26077bb 100644 --- a/test/test_equipment/test_waterheater.py +++ b/test/test_equipment/test_waterheater.py @@ -78,23 +78,6 @@ def test_parse_control_signal(self): 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.parse_control_signal(update_args_no_draw, {'Duty Cycle': 0.5}) - self.assertEqual(mode, 'Off') - - mode = self.wh.parse_control_signal(update_args_no_draw, {'Duty Cycle': [1, 0]}) - self.assertEqual(mode, 'On') - - self.wh.mode = 'On' - mode = self.wh.parse_control_signal(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.parse_control_signal(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) @@ -161,25 +144,11 @@ def setUp(self): self.wh.model.states[self.wh.t_upper_idx] = self.wh.setpoint_temp def test_init(self): - self.assertTrue(self.wh.use_ideal_mode + 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): - self.wh.mode = 'Off' - mode = self.wh.parse_control_signal(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.parse_control_signal(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.parse_control_signal(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) @@ -230,20 +199,7 @@ class ERWaterHeaterTestCase(unittest.TestCase): def setUp(self): self.wh = ElectricResistanceWaterHeater(**init_args) - def test_parse_control_signal(self): - self.wh.mode = 'Off' - control_signal = {'Duty Cycle': 1} - mode = self.wh.parse_control_signal(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)) - - # 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.parse_control_signal(update_args_no_draw, control_signal) - self.assertEqual(mode, 'Lower On') + # def test_parse_control_signal(self): def test_update_internal_control(self): self.wh.mode = 'Lower On' @@ -268,39 +224,7 @@ class HPWaterHeaterTestCase(unittest.TestCase): def setUp(self): self.wh = HeatPumpWaterHeater(**hpwh_init_args) - def test_parse_control_signal(self): - self.wh.mode = 'Off' - control_signal = {'HP Duty Cycle': 0, 'ER Duty Cycle': 0.9} - mode = self.wh.parse_control_signal(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.parse_control_signal(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.parse_control_signal(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.parse_control_signal(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.parse_control_signal(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.parse_control_signal(update_args_no_draw, control_signal) - self.assertEqual(mode, 'Off') + # def test_parse_control_signal(self): def test_update_internal_control(self): # TODO: Jeff - may need more tests here to make sure HP thermostat control works From c7f13da6476a7496ba2b7a0cb079149efdcf500f Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Tue, 3 Sep 2024 14:00:39 -0600 Subject: [PATCH 05/18] remove other duty cycle fields --- bin/run_external_control.py | 1 - docs/source/ModelingApproach.rst | 6 +----- ochre/Equipment/Equipment.py | 10 ++-------- ochre/Equipment/HVAC.py | 2 -- ochre/Equipment/ThermostaticLoad.py | 2 +- ochre/Equipment/WaterHeater.py | 6 ------ test/test_dwelling/test_dwelling.py | 1 - test/test_equipment/__init__.py | 1 - 8 files changed, 4 insertions(+), 25 deletions(-) diff --git a/bin/run_external_control.py b/bin/run_external_control.py index 50ac021..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', 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/Equipment/Equipment.py b/ochre/Equipment/Equipment.py index 844cf78..e2e1cfa 100644 --- a/ochre/Equipment/Equipment.py +++ b/ochre/Equipment/Equipment.py @@ -14,7 +14,7 @@ class Equipment(Simulator): 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: @@ -64,6 +64,7 @@ def __init__(self, zone_name=None, envelope_model=None, ext_time_res=None, save_ self.latent_gain = 0 # in W # Mode and controller parameters (assuming a duty cycle) + # TODO: convert to self.on = bool 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} @@ -75,9 +76,6 @@ def __init__(self, zone_name=None, envelope_model=None, ext_time_res=None, save_ 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} - def initialize_parameters(self, parameter_file=None, name_col='Name', value_col='Value', **kwargs): if parameter_file is None: return {} @@ -159,9 +157,6 @@ def update_model(self, control_signal=None): self.time_in_mode = self.time_res self.mode_cycles[self.mode] += 1 - if control_signal: - self.ext_mode_counters[self.mode] += self.time_res - # calculate electric and gas power and heat gains heat_data = self.calculate_power_and_heat() @@ -228,5 +223,4 @@ def reset_time(self, start_time=None, mode=None, **kwargs): 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} diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index d006aeb..8bc705d 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -174,7 +174,6 @@ def __init__(self, envelope_model=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 @@ -210,7 +209,6 @@ def parse_control_signal(self, control_signal): # - Resets every time step # - Max Capacity Fraction: Limits HVAC max capacity, ideal capacity only # - Only resets if it is in the schedule - # TODO: remove duty cycle, ext_time_res, ext_ignore_thermostat ext_setpoint = control_signal.get('Setpoint') if ext_setpoint is not None: diff --git a/ochre/Equipment/ThermostaticLoad.py b/ochre/Equipment/ThermostaticLoad.py index 8ac8d84..0e2b322 100644 --- a/ochre/Equipment/ThermostaticLoad.py +++ b/ochre/Equipment/ThermostaticLoad.py @@ -164,7 +164,7 @@ def update_internal_control(self): h_desired = self.thermal_model.solve_for_input(self.thermal_model.t_1_idx, self.thermal_model.h_1_idx, self.temp_setpoint, solve_as_output=False) - # Only allow heating, convert to duty cycle + # Save capacity, within bounds self.capacity = min(max(h_desired, 0), self.capacity_rated) else: self.capacity = self.solve_ideal_capacity() diff --git a/ochre/Equipment/WaterHeater.py b/ochre/Equipment/WaterHeater.py index a4885eb..3c76243 100644 --- a/ochre/Equipment/WaterHeater.py +++ b/ochre/Equipment/WaterHeater.py @@ -310,12 +310,6 @@ def run_duty_cycle_control(self, duty_cycles): if mode == 'Upper On' and t_upper > self.temp_setpoint: mode = 'Lower On' - # 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 - return mode def solve_ideal_capacity(self): diff --git a/test/test_dwelling/test_dwelling.py b/test/test_dwelling/test_dwelling.py index d279ed6..d640044 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, 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, From b0dcc14ef679edea7c7cb4293e8e751e6fb44c82 Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Tue, 3 Sep 2024 14:09:01 -0600 Subject: [PATCH 06/18] replace `upate_internal_control` with `run_internal_control` --- ochre/Equipment/Battery.py | 4 +-- ochre/Equipment/EV.py | 4 +-- ochre/Equipment/Equipment.py | 6 ++-- ochre/Equipment/EventBasedLoad.py | 2 +- ochre/Equipment/Generator.py | 2 +- ochre/Equipment/HVAC.py | 10 +++--- ochre/Equipment/PV.py | 4 +-- ochre/Equipment/ScheduledLoad.py | 2 +- ochre/Equipment/ThermostaticLoad.py | 2 +- ochre/Equipment/WaterHeater.py | 8 ++--- test/test_equipment/test_battery.py | 12 +++---- test/test_equipment/test_equipment.py | 2 +- test/test_equipment/test_ev.py | 8 ++--- test/test_equipment/test_eventbased.py | 8 ++--- test/test_equipment/test_generator.py | 18 +++++----- test/test_equipment/test_hvac.py | 40 ++++++++++----------- test/test_equipment/test_pv.py | 8 ++--- test/test_equipment/test_scheduled.py | 10 +++--- test/test_equipment/test_waterheater.py | 46 ++++++++++++------------- 19 files changed, 98 insertions(+), 98 deletions(-) diff --git a/ochre/Equipment/Battery.py b/ochre/Equipment/Battery.py index 165f29a..b6f9b35 100644 --- a/ochre/Equipment/Battery.py +++ b/ochre/Equipment/Battery.py @@ -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 (-)"] diff --git a/ochre/Equipment/EV.py b/ochre/Equipment/EV.py index 37465b2..aa9efbd 100644 --- a/ochre/Equipment/EV.py +++ b/ochre/Equipment/EV.py @@ -265,7 +265,7 @@ def parse_control_signal(self, control_signal): 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): + def run_internal_control(self): self.unmet_load = 0 # update control parameters from schedule @@ -274,7 +274,7 @@ 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() + return 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 diff --git a/ochre/Equipment/Equipment.py b/ochre/Equipment/Equipment.py index e2e1cfa..1dca223 100644 --- a/ochre/Equipment/Equipment.py +++ b/ochre/Equipment/Equipment.py @@ -20,7 +20,7 @@ def __init__(self, zone_name=None, envelope_model=None, save_ebm_results=False, All equipment must have: - A set of modes (default is ['On', 'Off']) - Fuel variables (by default, is_electric=True, is_gas=False) - - A control algorithm to determine the mode (update_internal_control) + - A control algorithm to determine the mode (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 (parse_control_signal) @@ -95,7 +95,7 @@ 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): + def run_internal_control(self): # Returns the equipment mode; can return None if the mode doesn't change # Overwrite if internal control exists raise NotImplementedError() @@ -136,7 +136,7 @@ def update_model(self, control_signal=None): self.parse_control_signal(control_signal) # run equipment controller to determine mode - mode = self.update_internal_control() + mode = self.run_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 diff --git a/ochre/Equipment/EventBasedLoad.py b/ochre/Equipment/EventBasedLoad.py index 03284b4..aa8c8db 100644 --- a/ochre/Equipment/EventBasedLoad.py +++ b/ochre/Equipment/EventBasedLoad.py @@ -151,7 +151,7 @@ def parse_control_signal(self, control_signal): self.warn('Event is delayed beyond event end time. Ignoring event.') 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' diff --git a/ochre/Equipment/Generator.py b/ochre/Equipment/Generator.py index bf9cbbe..da13ff5 100644 --- a/ochre/Equipment/Generator.py +++ b/ochre/Equipment/Generator.py @@ -93,7 +93,7 @@ def parse_control_signal(self, control_signal): if power_setpoint is not None: self.current_schedule[f"{self.end_use} Electric Power (kW)"] = power_setpoint - 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: diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index 8bc705d..6ceb5f1 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -252,9 +252,9 @@ def parse_control_signal(self, control_signal): elif load_fraction != 1: raise OCHREException(f"{self.name} can't handle non-integer load fractions") - return self.update_internal_control() + return self.run_internal_control() - def update_internal_control(self): + def run_internal_control(self): # Update setpoint from schedule self.update_setpoint() @@ -1056,9 +1056,9 @@ def parse_control_signal(self, control_signal): return super().parse_control_signal(control_signal) - def update_internal_control(self): + def run_internal_control(self): if self.use_ideal_mode: - # Note: not calling super().update_internal_control + # Note: not calling super().run_internal_control # Update setpoint from schedule self.update_setpoint() @@ -1070,7 +1070,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 diff --git a/ochre/Equipment/PV.py b/ochre/Equipment/PV.py index 1004781..083c8ce 100644 --- a/ochre/Equipment/PV.py +++ b/ochre/Equipment/PV.py @@ -195,9 +195,9 @@ def parse_control_signal(self, control_signal): else: self.warn(f'Invalid priority type: {priority}') - def update_internal_control(self): + def run_internal_control(self): # Set P to maximum power from schedule - super().update_internal_control() + super().run_internal_control() # Update P and Q from setpoints if "PV P Setpoint (kW)" in self.current_schedule: diff --git a/ochre/Equipment/ScheduledLoad.py b/ochre/Equipment/ScheduledLoad.py index e8ddb9e..99bf9db 100644 --- a/ochre/Equipment/ScheduledLoad.py +++ b/ochre/Equipment/ScheduledLoad.py @@ -98,7 +98,7 @@ def parse_control_signal(self, control_signal): 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: diff --git a/ochre/Equipment/ThermostaticLoad.py b/ochre/Equipment/ThermostaticLoad.py index 0e2b322..7c54822 100644 --- a/ochre/Equipment/ThermostaticLoad.py +++ b/ochre/Equipment/ThermostaticLoad.py @@ -152,7 +152,7 @@ def run_thermostat_control(self): if t_lower > self.temp_setpoint: return 'Off' - def update_internal_control(self): + def run_internal_control(self): self.update_setpoint() if self.use_ideal_mode: diff --git a/ochre/Equipment/WaterHeater.py b/ochre/Equipment/WaterHeater.py index 3c76243..d22df17 100644 --- a/ochre/Equipment/WaterHeater.py +++ b/ochre/Equipment/WaterHeater.py @@ -170,7 +170,7 @@ def run_thermostat_control(self): if t_lower > self.temp_setpoint: return 'Off' - def update_internal_control(self): + def run_internal_control(self): self.update_setpoint() if self.use_ideal_mode: @@ -495,7 +495,7 @@ def run_thermostat_control(self, use_future_states=False): 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: @@ -503,7 +503,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) @@ -617,7 +617,7 @@ def __init__(self, **kwargs): # update initial state to top of deadband (for 1-node model) self.model.states[self.t_upper_idx] = self.temp_setpoint - def update_internal_control(self): + def run_internal_control(self): self.update_setpoint() self.model.states[self.t_upper_idx] = self.temp_setpoint diff --git a/test/test_equipment/test_battery.py b/test/test_equipment/test_battery.py index 3ea1179..8e694ef 100644 --- a/test/test_equipment/test_battery.py +++ b/test/test_equipment/test_battery.py @@ -50,30 +50,30 @@ def test_parse_control_signal(self): 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}) + mode = self.battery.run_internal_control({'net_power': -1}) self.assertEqual(mode, 'Off') self.assertAlmostEqual(self.battery.power_setpoint, 0) - mode = self.battery.update_internal_control({'net_power': -1, 'pv_power': -2}) + mode = self.battery.run_internal_control({'net_power': -1, 'pv_power': -2}) self.assertEqual(mode, 'On') self.assertAlmostEqual(self.battery.power_setpoint, 1) - mode = self.battery.update_internal_control({'net_power': -2, 'pv_power': -1}) + mode = self.battery.run_internal_control({'net_power': -2, 'pv_power': -1}) self.assertEqual(mode, 'On') 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}) + mode = self.battery.run_internal_control({'net_power': -2, 'pv_power': -1}) self.assertEqual(mode, 'On') 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}) + mode = self.battery.run_internal_control({'net_power': -1}) self.assertEqual(mode, 'Off') self.assertEqual(self.battery.power_setpoint, 0) diff --git a/test/test_equipment/test_equipment.py b/test/test_equipment/test_equipment.py index 15a1190..5c60b79 100644 --- a/test/test_equipment/test_equipment.py +++ b/test/test_equipment/test_equipment.py @@ -15,7 +15,7 @@ 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' diff --git a/test/test_equipment/test_ev.py b/test/test_equipment/test_ev.py index 91bcdd6..a0667d8 100644 --- a/test/test_equipment/test_ev.py +++ b/test/test_equipment/test_ev.py @@ -101,16 +101,16 @@ def test_parse_control_signal(self): 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({}) + mode = self.ev.run_internal_control({}) self.assertEqual(mode, 'Off') 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,7 +118,7 @@ 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({}) + mode = self.ev.run_internal_control({}) self.assertEqual(mode, 'Off') self.assertGreater(self.ev.unmet_load, 0) diff --git a/test/test_equipment/test_eventbased.py b/test/test_equipment/test_eventbased.py index e9a20a5..d57ec79 100644 --- a/test/test_equipment/test_eventbased.py +++ b/test/test_equipment/test_eventbased.py @@ -48,21 +48,21 @@ def test_parse_control_signal(self): self.assertEqual(self.e.event_start, self.e.current_time) self.assertEqual(mode, 'On') - 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({}) + mode = self.e.run_internal_control({}) self.assertEqual(mode, 'Off') self.assertEqual(self.e.event_index, current_index) self.e.current_time = self.e.event_start - mode = self.e.update_internal_control({}) + mode = self.e.run_internal_control({}) self.assertEqual(mode, 'On') self.assertEqual(self.e.event_index, current_index) self.e.current_time = self.e.event_end - mode = self.e.update_internal_control({}) + mode = self.e.run_internal_control({}) self.assertEqual(mode, 'Off') self.assertEqual(self.e.event_index, current_index + 1) self.assertNotEqual(self.e.event_start, first_event_start) diff --git a/test/test_equipment/test_generator.py b/test/test_equipment/test_generator.py index 1e1c854..fcbb71b 100644 --- a/test/test_equipment/test_generator.py +++ b/test/test_equipment/test_generator.py @@ -47,45 +47,45 @@ def test_parse_control_signal(self): 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({}) + mode = self.generator.run_internal_control({}) self.assertEqual(mode, 'Off') 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({}) + mode = self.generator.run_internal_control({}) self.assertEqual(mode, 'On') 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({}) + mode = self.generator.run_internal_control({}) self.assertEqual(mode, 'Off') - mode = self.generator.update_internal_control({'net_power': 2}) + mode = self.generator.run_internal_control({'net_power': 2}) self.assertEqual(mode, 'On') self.assertEqual(self.generator.power_setpoint, -2) - mode = self.generator.update_internal_control({'net_power': -1}) + mode = self.generator.run_internal_control({'net_power': -1}) self.assertEqual(mode, 'On') 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}) + mode = self.generator.run_internal_control({'net_power': 3}) self.assertEqual(mode, 'On') self.assertAlmostEqual(self.generator.power_setpoint, -2) - mode = self.generator.update_internal_control({'net_power': 0.9}) + mode = self.generator.run_internal_control({'net_power': 0.9}) self.assertEqual(mode, 'Off') self.assertAlmostEqual(self.generator.power_setpoint, 0) # test off self.generator.control_type = 'Off' - mode = self.generator.update_internal_control({}) + mode = self.generator.run_internal_control({}) self.assertEqual(mode, 'Off') def test_get_power_limits(self): diff --git a/test/test_equipment/test_hvac.py b/test/test_equipment/test_hvac.py index b0bfa61..d0003de 100644 --- a/test/test_equipment/test_hvac.py +++ b/test/test_equipment/test_hvac.py @@ -103,8 +103,8 @@ def test_parse_control_signal(self): self.assertEqual(self.hvac.temp_setpoint, 22) self.assertEqual(self.hvac.temp_deadband, 2) - 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): @@ -193,14 +193,14 @@ class IdealHeaterTestCase(unittest.TestCase): def setUp(self): self.hvac = Heater(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, '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): @@ -602,37 +602,37 @@ class ASHPHeaterTestCase(unittest.TestCase): def setUp(self): self.hvac = ASHPHeater(**init_args) - 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 @@ -688,25 +688,25 @@ class VariableSpeedASHPHeaterTestCase(unittest.TestCase): def setUp(self): 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 c1fcedf..9bd8da8 100644 --- a/test/test_equipment/test_pv.py +++ b/test/test_equipment/test_pv.py @@ -68,8 +68,8 @@ def test_parse_control_signal(self): 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({}) + def test_run_internal_control(self): + mode = self.pv.run_internal_control({}) self.assertEqual(mode, 'On') self.assertAlmostEqual(self.pv.p_set_point, -8.16, places=2) self.assertAlmostEqual(self.pv.q_set_point, 0) @@ -131,8 +131,8 @@ 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({}) + def test_run_internal_control(self): + mode = self.pv.run_internal_control({}) self.assertEqual(mode, 'On') 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 19e7be2..6889679 100644 --- a/test/test_equipment/test_scheduled.py +++ b/test/test_equipment/test_scheduled.py @@ -53,12 +53,12 @@ def test_parse_control_signal(self): self.assertEqual(mode, 'Off') self.assertAlmostEqual(self.equipment.p_set_point, 0) - def test_update_internal_control(self): - mode = self.equipment.update_internal_control({'plug_loads': 100}) + def test_run_internal_control(self): + mode = self.equipment.run_internal_control({'plug_loads': 100}) self.assertEqual(mode, 'On') self.assertAlmostEqual(self.equipment.p_set_point, 0.1) - mode = self.equipment.update_internal_control({'plug_loads': 0}) + mode = self.equipment.run_internal_control({'plug_loads': 0}) self.assertEqual(mode, 'Off') self.assertAlmostEqual(self.equipment.p_set_point, 0) @@ -99,8 +99,8 @@ 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({}) + def test_run_internal_control(self): + mode = self.equipment.run_internal_control({}) self.assertEqual(mode, 'On') 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 26077bb..94459be 100644 --- a/test/test_equipment/test_waterheater.py +++ b/test/test_equipment/test_waterheater.py @@ -149,46 +149,46 @@ def test_init(self): self.assertEqual(self.wh.h_lower_idx, 1) self.assertEqual(self.wh.h_upper_idx, 0) - def test_update_internal_control(self): + def test_run_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) @@ -201,21 +201,21 @@ def setUp(self): # def test_parse_control_signal(self): - 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') @@ -226,42 +226,42 @@ def setUp(self): # 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): @@ -357,12 +357,12 @@ def test_init(self): 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') From c5c9910839643e1c237481776cd03d3aa069fb1f Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Tue, 3 Sep 2024 15:36:13 -0600 Subject: [PATCH 07/18] tstat load updates --- ochre/Equipment/Equipment.py | 30 ++++---- ochre/Equipment/HVAC.py | 39 +---------- ochre/Equipment/ThermostaticLoad.py | 104 +++++++++++++--------------- ochre/Equipment/WaterHeater.py | 68 ------------------ 4 files changed, 68 insertions(+), 173 deletions(-) diff --git a/ochre/Equipment/Equipment.py b/ochre/Equipment/Equipment.py index 1dca223..60326da 100644 --- a/ochre/Equipment/Equipment.py +++ b/ochre/Equipment/Equipment.py @@ -130,6 +130,20 @@ 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_time_in_mode(self, mode): + 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: @@ -138,24 +152,16 @@ def update_model(self, control_signal=None): # run equipment controller to determine mode mode = self.run_internal_control() + # Keep mode if minimum time in mode limit isn't reached 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) + voltage = self.current_schedule.get("Voltage (-)", 1) if voltage == 0: - mode = 'Off' + mode = "Off" - 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 + self.update_time_in_mode(mode) # calculate electric and gas power and heat gains heat_data = self.calculate_power_and_heat() diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index 6ceb5f1..05e1160 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -200,26 +200,11 @@ def initialize_schedule(self, schedule=None, **kwargs): 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 - - 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: @@ -245,14 +230,7 @@ def parse_control_signal(self, control_signal): 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") - - return self.run_internal_control() + super().parse_control_signal(control_signal) def run_internal_control(self): # Update setpoint from schedule @@ -270,18 +248,7 @@ def run_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.is_heater: diff --git a/ochre/Equipment/ThermostaticLoad.py b/ochre/Equipment/ThermostaticLoad.py index 7c54822..86328f1 100644 --- a/ochre/Equipment/ThermostaticLoad.py +++ b/ochre/Equipment/ThermostaticLoad.py @@ -1,19 +1,13 @@ -import numpy as np import datetime as dt from ochre.utils import OCHREException -from ochre.utils.units import convert, kwh_to_therms from ochre.Equipment import Equipment -from ochre.Models import OneNodeWaterModel, TwoNodeWaterModel, StratifiedWaterModel, IdealWaterModel class ThermostaticLoad(Equipment): - optional_inputs = [ - "Water Heating Setpoint (C)", - "Water Heating Deadband (C)", - "Water Heating Max Power (kW)", - "Zone Temperature (C)", # Needed for Water tank model - ] + 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): """ @@ -41,6 +35,10 @@ def __init__(self, thermal_model=None, use_ideal_mode=None, prevent_overshoot=Tr self.thermal_model = thermal_model self.sub_simulators.append(self.thermal_model) + # Model parameters + self.t_control_idx = None # state index for thermostat control + self.h_control_idx = None # 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) @@ -49,13 +47,16 @@ def __init__(self, thermal_model=None, use_ideal_mode=None, prevent_overshoot=Tr # By default, prevent overshoot in tstat mode self.prevent_overshoot = prevent_overshoot - # Control parameters - # note: bottom of deadband is (setpoint_temp - deadband_temp) + # Setpoint and deadband parameters self.temp_setpoint = kwargs['Setpoint Temperature (C)'] - self.temp_setpoint_ext = None + 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 - # TODO: convert to deadband min and max temps - self.temp_deadband = kwargs.get('Deadband Temperature (C)', 5.56) # deadband range, in delta degC, i.e. Kelvin + self.temp_deadband_range = kwargs.get('Deadband Temperature (C)', 5.56) # deadband range, in delta degC, i.e. Kelvin + self.temp_deadband_low = None + self.temp_deadband_high = None + self.set_deadband_limits() + + # Other control parameters self.max_power = kwargs.get('Max Power (kW)') self.force_off = False @@ -63,6 +64,10 @@ def __init__(self, thermal_model=None, use_ideal_mode=None, prevent_overshoot=Tr self.capacity = 0 # heat output from main element, in W self.delivered_heat = 0 # total heat delivered to the model, in W + def set_deadband_limits(self): + self.temp_deadband_high = self.temp_setpoint + (1 - self.setpoint_deadband_position) * self.temp_deadband_range * self.heat_mult + self.temp_deadband_low = 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) @@ -79,7 +84,6 @@ def parse_control_signal(self, control_signal): if f"{self.end_use} Setpoint (C)" in self.current_schedule: self.current_schedule[f"{self.end_use} Setpoint (C)"] = ext_setpoint else: - # Note that this overrides the ramp rate self.temp_setpoint = ext_setpoint ext_db = control_signal.get("Deadband") @@ -87,7 +91,7 @@ def parse_control_signal(self, control_signal): 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 + self.temp_deadband_range = ext_db max_power = control_signal.get("Max Power") if max_power is not None: @@ -104,40 +108,34 @@ def parse_control_signal(self, control_signal): raise OCHREException(f"{self.name} can't handle non-integer load fractions") def update_setpoint(self): + update_deadband_temps = False + # 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.temp_setpoint - - # update setpoint with ramp rate - if self.setpoint_ramp_rate and self.temp_setpoint != t_set_new: + 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 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(t_set_new, self.temp_setpoint - delta_t), - self.temp_setpoint + delta_t, + self.temp_setpoint = min( + max(self.temp_setpoint, self.temp_setpoint_old - delta_t), + self.temp_setpoint_old + delta_t, ) - else: - self.temp_setpoint = 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)"] + 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): - # calculate ideal capacity based on achieving lower node setpoint temperature - # Run model with heater off, updates next_states - self.thermal_model.update_model() - off_states = self.thermal_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.temp_setpoint - h_desired = np.dot(set_states[:self.t_lower_idx + 1] - off_states[:self.t_lower_idx + 1], # in W - self.thermal_model.capacitances[:self.t_lower_idx + 1]) / self.time_res.total_seconds() + # This may not be necessary + raise NotImplementedError() - # return ideal capacity, maintain min/max bounds - return min(max(h_desired, 0), self.capacity_rated) + def update_capacity(self): + raise NotImplementedError() def run_thermostat_control(self): # use thermostat with deadband control @@ -147,30 +145,22 @@ def run_thermostat_control(self): # take average of lower node and node above t_lower = (self.thermal_model.states[self.t_lower_idx] + self.thermal_model.states[self.t_lower_idx - 1]) / 2 - if t_lower < self.temp_setpoint - self.temp_deadband: + if t_lower < self.temp_setpoint - self.temp_deadband_range: return 'On' if t_lower > self.temp_setpoint: return 'Off' def run_internal_control(self): + # Update setpoint from schedule self.update_setpoint() if self.use_ideal_mode: - if self.thermal_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.thermal_model.solve_for_input(self.thermal_model.t_1_idx, self.thermal_model.h_1_idx, self.temp_setpoint, - solve_as_output=False) - - # Save capacity, within bounds - self.capacity = min(max(h_desired, 0), self.capacity_rated) - else: - self.capacity = self.solve_ideal_capacity() - + # run ideal capacity calculation here + # FUTURE: capacity update is done twice per loop, could but updated to improve speed + self.capacity = self.update_capacity() return "On" if self.capacity > 0 else "Off" else: + # Run thermostat controller and set speed return self.run_thermostat_control() def generate_results(self): @@ -184,7 +174,7 @@ def generate_results(self): 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.temp_setpoint - results[f'{self.end_use} Deadband Lower Limit (C)'] = self.temp_setpoint - self.temp_deadband + results[f'{self.end_use} Deadband Lower Limit (C)'] = self.temp_setpoint - self.temp_deadband_range if self.save_ebm_results: results.update(self.make_equivalent_battery_model()) diff --git a/ochre/Equipment/WaterHeater.py b/ochre/Equipment/WaterHeater.py index d22df17..9b72638 100644 --- a/ochre/Equipment/WaterHeater.py +++ b/ochre/Equipment/WaterHeater.py @@ -62,7 +62,6 @@ def __init__(self, model_class=None, **kwargs): # Control parameters # note: bottom of deadband is (setpoint_temp - deadband_temp) self.temp_setpoint = kwargs['Setpoint Temperature (C)'] - self.temp_setpoint_ext = None self.temp_max = 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.temp_deadband = kwargs.get('Deadband Temperature (C)', 5.56) # deadband range, in delta degC, i.e. Kelvin @@ -75,73 +74,6 @@ def update_inputs(self, schedule_inputs=None): super().update_inputs(schedule_inputs) - 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 "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.temp_setpoint = 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.temp_deadband = 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") - - 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.temp_setpoint - if t_set_new > self.temp_max: - self.warn( - f"Setpoint cannot exceed {self.temp_max}C. Setting setpoint to maximum value." - ) - t_set_new = self.temp_max - - # update setpoint with ramp rate - if self.setpoint_ramp_rate and self.temp_setpoint != t_set_new: - delta_t = self.setpoint_ramp_rate * self.time_res.total_seconds() / 60 # in C - self.temp_setpoint = min(max(t_set_new, self.temp_setpoint - delta_t), - self.temp_setpoint + delta_t, - ) - else: - self.temp_setpoint = 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 From 31c360f046b209ec61d6ec2e31802a5a545e11b4 Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Tue, 3 Sep 2024 16:57:25 -0600 Subject: [PATCH 08/18] WH tstat integration --- ochre/Equipment/ThermostaticLoad.py | 37 ++++--- ochre/Equipment/WaterHeater.py | 163 +++++++++++++--------------- 2 files changed, 97 insertions(+), 103 deletions(-) diff --git a/ochre/Equipment/ThermostaticLoad.py b/ochre/Equipment/ThermostaticLoad.py index 86328f1..f6a7f1f 100644 --- a/ochre/Equipment/ThermostaticLoad.py +++ b/ochre/Equipment/ThermostaticLoad.py @@ -52,8 +52,8 @@ def __init__(self, thermal_model=None, use_ideal_mode=None, prevent_overshoot=Tr 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_low = None - self.temp_deadband_high = None + self.temp_deadband_on = None + self.temp_deadband_off = None self.set_deadband_limits() # Other control parameters @@ -65,8 +65,8 @@ def __init__(self, thermal_model=None, use_ideal_mode=None, prevent_overshoot=Tr self.delivered_heat = 0 # total heat delivered to the model, in W def set_deadband_limits(self): - self.temp_deadband_high = self.temp_setpoint + (1 - self.setpoint_deadband_position) * self.temp_deadband_range * self.heat_mult - self.temp_deadband_low = self.temp_setpoint - self.setpoint_deadband_position * self.temp_deadband_range * self.heat_mult + 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: @@ -131,24 +131,29 @@ def update_setpoint(self): self.max_power = self.current_schedule[f"{self.end_use} Max Power (kW)"] def solve_ideal_capacity(self): - # This may not be necessary - raise NotImplementedError() + # Solve thermal model for input heat injection to achieve setpoint + capacity = self.thermal_model.solve_for_input( + self.t_control_idx, + self.h_control_idx, + self.temp_setpoint, + solve_as_output=False, + ) + return capacity def update_capacity(self): - raise NotImplementedError() + return self.solve_ideal_capacity() def run_thermostat_control(self): # use thermostat with deadband control - if self.thermal_model.n_nodes <= 2: - t_lower = self.thermal_model.states[self.t_lower_idx] - else: - # take average of lower node and node above - t_lower = (self.thermal_model.states[self.t_lower_idx] + self.thermal_model.states[self.t_lower_idx - 1]) / 2 + t_control = self.thermal_model.states[self.t_control_idx] - if t_lower < self.temp_setpoint - self.temp_deadband_range: - return 'On' - if t_lower > self.temp_setpoint: - return 'Off' + if (t_control - self.temp_deadband_on) * self.heat_mult < 0: + return "On" + elif (t_control - self.temp_deadband_off) * self.heat_mult > 0: + return "Off" + else: + # maintains existing mode + return None def run_internal_control(self): # Update setpoint from schedule diff --git a/ochre/Equipment/WaterHeater.py b/ochre/Equipment/WaterHeater.py index 9b72638..c2dad70 100644 --- a/ochre/Equipment/WaterHeater.py +++ b/ochre/Equipment/WaterHeater.py @@ -17,6 +17,7 @@ class WaterHeater(ThermostaticLoad): name = 'Water Heater' end_use = 'Water Heating' default_capacity = 4500 # in W + default_deadband = 5.56 # in C optional_inputs = [ "Water Heating Setpoint (C)", "Water Heating Deadband (C)", @@ -46,26 +47,29 @@ def __init__(self, model_class=None, **kwargs): super().__init__(model=thermal_model, **kwargs) # 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 - - 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 + 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 + ) + self.t_control_idx = self.t_lower_idx + self.h_control_idx = self.h_lower_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 # Control parameters # note: bottom of deadband is (setpoint_temp - deadband_temp) - self.temp_setpoint = kwargs['Setpoint Temperature (C)'] self.temp_max = 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.temp_deadband = kwargs.get('Deadband Temperature (C)', 5.56) # deadband range, in delta degC, i.e. Kelvin - self.max_power = kwargs.get('Max Power (kW)') + # deadband range, in delta degC, i.e. Kelvin + self.temp_deadband = kwargs.get('Deadband Temperature (C)', self.default_deadband) def update_inputs(self, schedule_inputs=None): # Add zone temperature to schedule inputs for water tank @@ -77,59 +81,44 @@ def update_inputs(self, schedule_inputs=None): 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 + self.thermal_model.update_model() + off_states = self.thermal_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.temp_setpoint - 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() + n_states = self.t_lower_idx + 1 + h_desired = np.dot(set_states[:n_states] - off_states[:n_states], # in W + self.thermal_model.capacitances[:n_states]) / 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} + return h_desired 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] + if self.thermal_model.n_nodes <= 2: + t_lower = self.thermal_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 - - if t_lower < self.temp_setpoint - self.temp_deadband: - return 'On' - if t_lower > self.temp_setpoint: - return 'Off' - - def run_internal_control(self): - self.update_setpoint() - - if self.use_ideal_mode: - 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.temp_setpoint, - 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() + t_lower = (self.thermal_model.states[self.t_lower_idx] + self.thermal_model.states[self.t_lower_idx - 1]) / 2 - return [mode for mode, duty_cycle in self.duty_cycle_by_mode.items() if duty_cycle > 0][0] + if (t_lower - self.temp_deadband_on) * self.heat_mult < 0: + return "On" + elif (t_lower - self.temp_deadband_off) * self.heat_mult > 0: + return "Off" + else: + # maintains existing mode + return None + + def update_capacity(self): + if self.thermal_model.n_nodes == 1: + # calculate ideal capacity using tank model directly + # - more accurate than self.solve_ideal_capacity + return ThermostaticLoad.solve_ideal_capacity(self) else: - return self.run_thermostat_control() + return self.solve_ideal_capacity() 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) + heats_to_tank = np.zeros(self.thermal_model.n_nodes, dtype=float) if mode == 'Upper On': heats_to_tank[self.h_upper_idx] += self.capacity_rated * duty_cycle @@ -142,7 +131,7 @@ def add_heat_from_mode(self, mode, heats_to_tank=None, duty_cycle=1): def calculate_power_and_heat(self): # get heat injections from water heater if self.use_ideal_mode and self.mode != 'Off': - heats_to_tank = np.zeros(self.model.n_nodes, dtype=float) + heats_to_tank = np.zeros(self.thermal_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: @@ -169,7 +158,7 @@ def calculate_power_and_heat(self): # 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 {self.thermal_model.name: heats_to_tank} def finish_sub_update(self, sub): # add heat losses from model to sensible gains @@ -195,14 +184,14 @@ def generate_results(self): 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.temp_setpoint - self.temp_deadband - ref_temp), @@ -238,7 +227,7 @@ def run_duty_cycle_control(self, duty_cycles): if not self.use_ideal_mode: # If duty cycle forces WH on, may need to swap to lower element - t_upper = self.model.states[self.t_upper_idx] + t_upper = self.thermal_model.states[self.t_upper_idx] if mode == 'Upper On' and t_upper > self.temp_setpoint: mode = 'Lower On' @@ -247,15 +236,15 @@ def run_duty_cycle_control(self, duty_cycles): 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 + self.thermal_model.update_model() + off_states = self.thermal_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.temp_setpoint 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() + self.thermal_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() + self.thermal_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 @@ -265,12 +254,12 @@ def solve_ideal_capacity(self): 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] + t_upper = self.thermal_model.states[self.t_upper_idx] + if self.thermal_model.n_nodes <= 2: + t_lower = self.thermal_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 + t_lower = (self.thermal_model.states[self.t_lower_idx] + self.thermal_model.states[self.t_lower_idx - 1]) / 2 lower_threshold_temp = self.temp_setpoint - self.temp_deadband if t_upper < lower_threshold_temp or (self.mode == 'Upper On' and t_upper < self.temp_setpoint): @@ -340,14 +329,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 @@ -365,13 +354,13 @@ def solve_ideal_capacity(self): 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. @@ -380,7 +369,7 @@ def solve_ideal_capacity(self): 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 @@ -391,7 +380,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) @@ -409,7 +398,7 @@ 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 @@ -445,7 +434,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) @@ -547,13 +536,13 @@ def __init__(self, **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.temp_setpoint + self.thermal_model.states[self.t_upper_idx] = self.temp_setpoint def run_internal_control(self): self.update_setpoint() - self.model.states[self.t_upper_idx] = self.temp_setpoint + self.thermal_model.states[self.t_upper_idx] = self.temp_setpoint - self.heat_from_draw = -self.model.update_water_draw()[0] + self.heat_from_draw = -self.thermal_model.update_water_draw()[0] self.heat_from_draw = max(self.heat_from_draw, 0) return 'On' if self.heat_from_draw > 0 else 'Off' @@ -570,13 +559,13 @@ def calculate_power_and_heat(self): elif self.heat_from_draw > self.capacity_rated: # cannot meet setpoint temperature. Update outlet temp for 1 time step t_set = self.temp_setpoint - t_mains = self.model.current_schedule['Mains Temperature (C)'] + t_mains = self.thermal_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() + self.thermal_model.states[self.thermal_model.t_1_idx] = t_outlet + self.thermal_model.update_water_draw() # Reset tank model and update delivered heat - self.model.states[self.model.t_1_idx] = t_set + self.thermal_model.states[self.thermal_model.t_1_idx] = t_set self.delivered_heat = self.capacity_rated else: self.delivered_heat = self.heat_from_draw @@ -589,7 +578,7 @@ def calculate_power_and_heat(self): # 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])} + return {self.thermal_model.name: np.array([self.delivered_heat])} class GasTanklessWaterHeater(TanklessWaterHeater): From 14d72c2c74e28de5f1d0ca47c0e1aa69c3d6bfc3 Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Wed, 4 Sep 2024 13:40:19 -0600 Subject: [PATCH 09/18] WH updates --- ochre/Equipment/Equipment.py | 3 +++ ochre/Equipment/ThermostaticLoad.py | 18 --------------- ochre/Equipment/WaterHeater.py | 35 +++-------------------------- 3 files changed, 6 insertions(+), 50 deletions(-) diff --git a/ochre/Equipment/Equipment.py b/ochre/Equipment/Equipment.py index 60326da..6c4b8dc 100644 --- a/ochre/Equipment/Equipment.py +++ b/ochre/Equipment/Equipment.py @@ -218,6 +218,9 @@ def generate_results(self): if self.verbosity >= 6: results[f'{self.results_name} Mode'] = self.mode + if self.save_ebm_results: + results.update(self.make_equivalent_battery_model()) + return results def reset_time(self, start_time=None, mode=None, **kwargs): diff --git a/ochre/Equipment/ThermostaticLoad.py b/ochre/Equipment/ThermostaticLoad.py index f6a7f1f..4adeb42 100644 --- a/ochre/Equipment/ThermostaticLoad.py +++ b/ochre/Equipment/ThermostaticLoad.py @@ -167,21 +167,3 @@ def run_internal_control(self): else: # Run thermostat controller and set speed return self.run_thermostat_control() - - def generate_results(self): - results = super().generate_results() - - # Note: using end use, not equipment name, for all results - if self.verbosity >= 3: - results[f'{self.end_use} Delivered (W)'] = self.delivered_heat - if self.verbosity >= 6: - 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.temp_setpoint - results[f'{self.end_use} Deadband Lower Limit (C)'] = self.temp_setpoint - self.temp_deadband_range - - if self.save_ebm_results: - results.update(self.make_equivalent_battery_model()) - - return results diff --git a/ochre/Equipment/WaterHeater.py b/ochre/Equipment/WaterHeater.py index c2dad70..4684364 100644 --- a/ochre/Equipment/WaterHeater.py +++ b/ochre/Equipment/WaterHeater.py @@ -116,15 +116,15 @@ def update_capacity(self): else: return self.solve_ideal_capacity() - def add_heat_from_mode(self, mode, heats_to_tank=None, duty_cycle=1): + 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) if mode == 'Upper On': - heats_to_tank[self.h_upper_idx] += self.capacity_rated * duty_cycle + 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 * duty_cycle + heats_to_tank[self.h_lower_idx] += self.capacity_rated * pct_time_on return heats_to_tank @@ -177,8 +177,6 @@ def generate_results(self): 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 - if self.save_ebm_results: - results.update(self.make_equivalent_battery_model()) return results @@ -206,33 +204,6 @@ 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_mode: - # 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 - 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])) - - mode = super().run_duty_cycle_control(duty_cycles) - - if not self.use_ideal_mode: - # If duty cycle forces WH on, may need to swap to lower element - t_upper = self.thermal_model.states[self.t_upper_idx] - if mode == 'Upper On' and t_upper > self.temp_setpoint: - mode = 'Lower On' - - return mode - def solve_ideal_capacity(self): # calculate ideal capacity based on upper and lower node setpoint temperatures # Run model with heater off From 11e942cdb8b7ce892e5d4e226f4c3171643213ce Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Wed, 4 Sep 2024 15:59:33 -0600 Subject: [PATCH 10/18] removing Equipment.mode, replace with Equipment.on --- ochre/Equipment/Equipment.py | 71 +++++++++++++++------------ test/test_equipment/test_equipment.py | 12 ++--- 2 files changed, 45 insertions(+), 38 deletions(-) diff --git a/ochre/Equipment/Equipment.py b/ochre/Equipment/Equipment.py index 6c4b8dc..1f483dd 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, 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 (run_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 (parse_control_signal) + - 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,18 +62,16 @@ def __init__(self, zone_name=None, envelope_model=None, save_ebm_results=False, self.sensible_gain = 0 # in W self.latent_gain = 0 # in W - # Mode and controller parameters (assuming a duty cycle) + # On time and controller parameters # TODO: convert to self.on = bool - 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} + self.on = 1 # 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.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: @@ -96,11 +93,11 @@ def parse_control_signal(self, control_signal): raise OCHREException('Must define external control algorithm for {}'.format(self.name)) def run_internal_control(self): - # Returns the equipment mode; can return None if the mode doesn't change - # Overwrite if internal control exists + # Returns the equipment mode (0 or 1); can return None if the mode doesn't change raise NotImplementedError() def calculate_power_and_heat(self): + # Sets equipment power and thermal gains to zone raise NotImplementedError() def add_gains_to_zone(self): @@ -130,18 +127,24 @@ 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_time_in_mode(self, mode): - if mode is None or mode == self.mode: - self.time_in_mode += self.time_res + def update_mode_times(self, on): + # returns on time fraction + if on: + self.time_on += self.time_res * on + if self.time_off: + self.time_off = dt.timedelta(minutes=0) else: - if mode not in self.modes: + self.time_on += self.time_res * on + + else: + if on not in self.modes: raise OCHREException( "Can't set {} mode to {}. Valid modes are: {}".format( - self.name, mode, self.modes + self.name, on, self.modes ) ) - self.mode = mode - self.time_in_mode = self.time_res + self.mode = on + self.time_on = self.time_res self.mode_cycles[self.mode] += 1 def update_model(self, control_signal=None): @@ -149,19 +152,23 @@ def update_model(self, control_signal=None): if control_signal: self.parse_control_signal(control_signal) - # run equipment controller to determine mode - mode = self.run_internal_control() + # run equipment controller to determine on/off control + on = self.run_internal_control() - # Keep mode if minimum time in mode limit isn't reached - if mode is not None and self.time_in_mode < self.min_time_in_mode[self.mode]: - mode = self.mode + # Keep existing on fraction if not defined or if minimum time limit isn't reached + if on is None: + on = self.on + elif not on and self.time_on < self.min_on_time: + on = self.on + elif on and self.time_off < self.min_off_time: + on = self.on # Get voltage, if disconnected then set mode to off voltage = self.current_schedule.get("Voltage (-)", 1) if voltage == 0: - mode = "Off" + on = 0 - self.update_time_in_mode(mode) + self.on = self.update_mode_times(on) # calculate electric and gas power and heat gains heat_data = self.calculate_power_and_heat() @@ -230,6 +237,6 @@ def reset_time(self, start_time=None, mode=None, **kwargs): if mode is not None: self.mode = mode - self.time_in_mode = dt.timedelta(minutes=0) + self.time_on = dt.timedelta(minutes=0) self.mode_cycles = {mode: 0 for mode in self.modes} # self.tot_mode_counters = {mode: dt.timedelta(minutes=0) for mode in self.modes} diff --git a/test/test_equipment/test_equipment.py b/test/test_equipment/test_equipment.py index 5c60b79..4b55ebc 100644 --- a/test/test_equipment/test_equipment.py +++ b/test/test_equipment/test_equipment.py @@ -59,7 +59,7 @@ def test_update(self): 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.assertEqual(self.equipment.time_on, equip_init_args['time_res'] * 3) self.assertDictEqual(self.equipment.mode_cycles, {'On': 1, 'Off': 0}) self.assertEqual(self.equipment.electric_kw, 2) @@ -69,24 +69,24 @@ def test_update(self): 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.assertEqual(self.equipment.time_on, equip_init_args['time_res'] * 3) self.assertDictEqual(self.equipment.mode_cycles, {'On': 1, 'Off': 1}) 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.time_on = dt.timedelta(minutes=0) self.equipment.min_time_in_mode = {'On': dt.timedelta(minutes=2), 'Off': 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.time_on, equip_init_args['time_res']) - self.equipment.time_in_mode = dt.timedelta(minutes=2) + self.equipment.time_on = dt.timedelta(minutes=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.time_on, equip_init_args['time_res']) def test_simulate(self): results = self.equipment.simulate(duration=dt.timedelta(hours=1)) From a5c815121a2cd01b5ac73e8ea89422812dc8c9e6 Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Thu, 5 Sep 2024 09:56:04 -0600 Subject: [PATCH 11/18] move `update_mode_times` to `update_results` --- ochre/Equipment/Equipment.py | 63 +++++++++++++++++------------------- 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/ochre/Equipment/Equipment.py b/ochre/Equipment/Equipment.py index 1f483dd..ada8950 100644 --- a/ochre/Equipment/Equipment.py +++ b/ochre/Equipment/Equipment.py @@ -62,9 +62,9 @@ def __init__(self, zone_name=None, envelope_model=None, save_ebm_results=False, self.sensible_gain = 0 # in W self.latent_gain = 0 # in W - # On time and controller parameters - # TODO: convert to self.on = bool - self.on = 1 # fraction of time on (0-1) + # Mode and controller parameters + self.on = 0 # fraction of time on (0-1) + self.on_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 @@ -127,48 +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_mode_times(self, on): - # returns on time fraction - if on: - self.time_on += self.time_res * on - if self.time_off: + def update_mode_times(self): + # updates mode times + if self.on_new: + self.time_on += self.time_res * self.on_new + if not self.on: self.time_off = dt.timedelta(minutes=0) + # increase number of cycles if equipment was off and turns on + self.cycles += 1 else: - self.time_on += self.time_res * on + self.time_off += self.time_res + if self.on: + self.time_on = dt.timedelta(minutes=0) - else: - if on not in self.modes: - raise OCHREException( - "Can't set {} mode to {}. Valid modes are: {}".format( - self.name, on, self.modes - ) - ) - self.mode = on - self.time_on = 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.parse_control_signal(control_signal) - # run equipment controller to determine on/off control - on = self.run_internal_control() + # run equipment controller to determine on/off mode + self.on_new = self.run_internal_control() # Keep existing on fraction if not defined or if minimum time limit isn't reached - if on is None: - on = self.on - elif not on and self.time_on < self.min_on_time: - on = self.on - elif on and self.time_off < self.min_off_time: - on = self.on - - # Get voltage, if disconnected then set mode to off + if self.on_new is None: + self.on_new = self.on + elif not self.on_new and self.time_on < self.min_on_time: + self.on_new = self.on + elif self.on_new and self.time_off < self.min_off_time: + self.on_new = self.on + + # Get voltage, if disconnected then set to off voltage = self.current_schedule.get("Voltage (-)", 1) if voltage == 0: - on = 0 - - self.on = self.update_mode_times(on) + self.on_new = 0 # calculate electric and gas power and heat gains heat_data = self.calculate_power_and_heat() @@ -210,6 +202,12 @@ def make_equivalent_battery_model(self): # f'{self.results_name} EBM Discharge Efficiency (-)': 1, } + def update_results(self): + self.update_mode_times() + self.on = self.on_new + + return super().update_results() + def generate_results(self): results = super().generate_results() @@ -238,5 +236,4 @@ def reset_time(self, start_time=None, mode=None, **kwargs): self.mode = mode self.time_on = dt.timedelta(minutes=0) - self.mode_cycles = {mode: 0 for mode in self.modes} # self.tot_mode_counters = {mode: dt.timedelta(minutes=0) for mode in self.modes} From d8eca89e72d5b85851a202858d13e3dcc435bd49 Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Thu, 5 Sep 2024 11:13:02 -0600 Subject: [PATCH 12/18] updating Equipment.reset_time --- ochre/Equipment/Equipment.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/ochre/Equipment/Equipment.py b/ochre/Equipment/Equipment.py index ada8950..6f5472f 100644 --- a/ochre/Equipment/Equipment.py +++ b/ochre/Equipment/Equipment.py @@ -221,19 +221,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 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 = int(on_previous) + # 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.tot_mode_counters = {mode: dt.timedelta(minutes=0) for mode in self.modes} + self.time_off = dt.timedelta(minutes=0) + self.cycles = 0 From fbc756e3599308b38e052aa5549b815fcd8635dc Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Thu, 5 Sep 2024 11:30:51 -0600 Subject: [PATCH 13/18] replace equipment modes --- ochre/Analysis.py | 21 +++++--------- ochre/Equipment/Battery.py | 2 +- ochre/Equipment/EV.py | 2 +- ochre/Equipment/EventBasedLoad.py | 12 ++++---- ochre/Equipment/Generator.py | 4 +-- ochre/Equipment/HVAC.py | 8 +++--- ochre/Equipment/PV.py | 2 +- ochre/Equipment/ScheduledLoad.py | 4 +-- ochre/Equipment/WaterHeater.py | 4 +-- test/test_dwelling/test_dwelling.py | 8 +++--- test/test_equipment/test_battery.py | 16 +++++------ test/test_equipment/test_equipment.py | 37 ++++++++++++------------- test/test_equipment/test_ev.py | 10 +++---- test/test_equipment/test_eventbased.py | 16 +++++------ test/test_equipment/test_generator.py | 32 ++++++++++----------- test/test_equipment/test_hvac.py | 2 +- test/test_equipment/test_pv.py | 14 +++++----- test/test_equipment/test_scheduled.py | 14 +++++----- test/test_equipment/test_waterheater.py | 2 +- 19 files changed, 100 insertions(+), 110 deletions(-) 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/Equipment/Battery.py b/ochre/Equipment/Battery.py index b6f9b35..e2977f8 100644 --- a/ochre/Equipment/Battery.py +++ b/ochre/Equipment/Battery.py @@ -256,7 +256,7 @@ def run_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" + return 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 aa9efbd..d0e1813 100644 --- a/ochre/Equipment/EV.py +++ b/ochre/Equipment/EV.py @@ -278,7 +278,7 @@ def run_internal_control(self): 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: return super().calculate_power_and_heat() # force ac power within kw capacity and SOC limits, no discharge allowed diff --git a/ochre/Equipment/EventBasedLoad.py b/ochre/Equipment/EventBasedLoad.py index aa8c8db..3edad56 100644 --- a/ochre/Equipment/EventBasedLoad.py +++ b/ochre/Equipment/EventBasedLoad.py @@ -132,7 +132,7 @@ def parse_control_signal(self, control_signal): if 'Delay' in control_signal: delay = control_signal['Delay'] - if delay and self.mode == 'On': + if delay and self.on: self.warn('Ignoring delay signal, event has already started.') delay = False if isinstance(delay, (int, bool)): @@ -154,18 +154,18 @@ def parse_control_signal(self, control_signal): def run_internal_control(self): if self.current_time < self.event_start: # waiting for next event to start - return 'Off' + return 0 elif self.current_time < self.event_end: - if self.mode == 'Off': + if not self.on: self.start_event() - return 'On' + return 1 else: # event has ended, move to next event self.end_event() - return 'Off' + return 0 def calculate_power_and_heat(self): - if self.mode == 'On': + if self.on: 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 da13ff5..f3786d5 100644 --- a/ochre/Equipment/Generator.py +++ b/ochre/Equipment/Generator.py @@ -116,7 +116,7 @@ def run_internal_control(self): f"{self.end_use} Electric Power (kW)", 0 ) - return "On" if self.power_setpoint != 0 else "Off" + return 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 @@ -172,7 +172,7 @@ def calculate_efficiency(self, electric_kw=None, is_output_power=True): ) def calculate_power_and_heat(self): - if self.mode == "Off": + if not self.on: self.electric_kw = 0 else: # force ac power within limits diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index 05e1160..206c7ab 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -681,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: speed = 1 elif self.hvac_mult * (self.zone.temperature - self.temp_indoor_prev) < 0: speed = 2 @@ -689,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 @@ -706,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: speed = 1 else: speed = 2 @@ -857,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 and self.current_schedule['Ambient Dry Bulb (C)'] < self.crankcase_temp: self.electric_kw += self.crankcase_kw * self.space_fraction diff --git a/ochre/Equipment/PV.py b/ochre/Equipment/PV.py index 083c8ce..202ae75 100644 --- a/ochre/Equipment/PV.py +++ b/ochre/Equipment/PV.py @@ -205,7 +205,7 @@ def run_internal_control(self): 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) - return 'On' if self.p_set_point < 0 else 'Off' + return 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 99bf9db..082018e 100644 --- a/ochre/Equipment/ScheduledLoad.py +++ b/ochre/Equipment/ScheduledLoad.py @@ -113,10 +113,10 @@ def run_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' + return 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: 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/WaterHeater.py b/ochre/Equipment/WaterHeater.py index 4684364..59addc5 100644 --- a/ochre/Equipment/WaterHeater.py +++ b/ochre/Equipment/WaterHeater.py @@ -524,7 +524,7 @@ def calculate_power_and_heat(self): if self.max_power and power > self.max_power: self.heat_from_draw *= self.max_power / power - if self.mode == 'Off': + if not self.on: # do not update heat, force water heater off self.delivered_heat = 0 elif self.heat_from_draw > self.capacity_rated: @@ -571,7 +571,7 @@ def calculate_power_and_heat(self): # electric power is constant self.electric_kw = self.parasitic_power - # if self.mode == 'On': + # if self.on: # self.electric_kw = 65 / 1000 # hardcoded parasitic electric power # else: # self.electric_kw = 5 / 1000 # hardcoded electric power diff --git a/test/test_dwelling/test_dwelling.py b/test/test_dwelling/test_dwelling.py index d640044..cc9d668 100644 --- a/test/test_dwelling/test_dwelling.py +++ b/test/test_dwelling/test_dwelling.py @@ -165,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) @@ -177,7 +177,7 @@ 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) diff --git a/test/test_equipment/test_battery.py b/test/test_equipment/test_battery.py index 8e694ef..732a1c8 100644 --- a/test/test_equipment/test_battery.py +++ b/test/test_equipment/test_battery.py @@ -55,26 +55,26 @@ def test_run_internal_control(self): self.battery.control_type = 'Self-Consumption' self.battery.parameters['charge_from_solar'] = 1 mode = self.battery.run_internal_control({'net_power': -1}) - self.assertEqual(mode, 'Off') + self.assertEqual(mode, 0) self.assertAlmostEqual(self.battery.power_setpoint, 0) mode = self.battery.run_internal_control({'net_power': -1, 'pv_power': -2}) - self.assertEqual(mode, 'On') + self.assertEqual(mode, 1) self.assertAlmostEqual(self.battery.power_setpoint, 1) mode = self.battery.run_internal_control({'net_power': -2, 'pv_power': -1}) - self.assertEqual(mode, 'On') + self.assertEqual(mode, 1) self.assertAlmostEqual(self.battery.power_setpoint, 1) self.battery.parameters['charge_from_solar'] = 0 mode = self.battery.run_internal_control({'net_power': -2, 'pv_power': -1}) - self.assertEqual(mode, 'On') + self.assertEqual(mode, 1) self.assertAlmostEqual(self.battery.power_setpoint, 2) # test SOC limits self.battery.soc = self.battery.soc_max mode = self.battery.run_internal_control({'net_power': -1}) - self.assertEqual(mode, 'Off') + 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 = 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 = 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 = 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 4b55ebc..6eee70a 100644 --- a/test/test_equipment/test_equipment.py +++ b/test/test_equipment/test_equipment.py @@ -18,12 +18,12 @@ def __init__(self, max_p, **kwargs): def run_internal_control(self): # Turns on for 5 minutes, then off for 5 minutes if self.current_time.minute % 10 >= 5: - return 'Off' + return 0 else: - return 'On' + return 1 def calculate_power_and_heat(self): - if self.mode == 'On': + if self.on: 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.on, 1) self.assertEqual(self.equipment.time_on, equip_init_args['time_res'] * 3) - self.assertDictEqual(self.equipment.mode_cycles, {'On': 1, 'Off': 0}) 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.mode, 0) self.assertEqual(self.equipment.time_on, equip_init_args['time_res'] * 3) - self.assertDictEqual(self.equipment.mode_cycles, {'On': 1, 'Off': 1}) self.assertEqual(self.equipment.electric_kw, 0) # Test with minimum on/off times - self.equipment.mode = 'On' + self.equipment.on = 1 self.equipment.time_on = dt.timedelta(minutes=0) - self.equipment.min_time_in_mode = {'On': dt.timedelta(minutes=2), - 'Off': dt.timedelta(minutes=2)} + 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.on, 1) self.assertEqual(self.equipment.time_on, equip_init_args['time_res']) - self.equipment.time_on = 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.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,7 +107,7 @@ def test_generate_results(self): # high verbosity results = self.equipment.generate_results(9) - self.assertDictEqual(results, {'Test Equipment Mode': '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 a0667d8..b2bc08c 100644 --- a/test/test_equipment/test_ev.py +++ b/test/test_equipment/test_ev.py @@ -104,7 +104,7 @@ def test_parse_control_signal(self): def test_run_internal_control(self): # test outside of event mode = self.ev.run_internal_control({}) - self.assertEqual(mode, 'Off') + self.assertEqual(mode, 0) self.assertIsNone(self.ev.setpoint_power) # test event start @@ -119,11 +119,11 @@ def test_run_internal_control(self): self.ev.current_time = self.ev.event_end + dt.timedelta(minutes=2) self.ev.soc = 0.1 mode = self.ev.run_internal_control({}) - self.assertEqual(mode, 'Off') + 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 d57ec79..c23a62d 100644 --- a/test/test_equipment/test_eventbased.py +++ b/test/test_equipment/test_eventbased.py @@ -33,42 +33,42 @@ def test_parse_control_signal(self): 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.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.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.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_run_internal_control(self): first_event_start = self.e.event_start current_index = self.e.event_index mode = self.e.run_internal_control({}) - self.assertEqual(mode, 'Off') + self.assertEqual(mode, 0) self.assertEqual(self.e.event_index, current_index) self.e.current_time = self.e.event_start mode = self.e.run_internal_control({}) - self.assertEqual(mode, 'On') + self.assertEqual(mode, 1) self.assertEqual(self.e.event_index, current_index) self.e.current_time = self.e.event_end mode = self.e.run_internal_control({}) - self.assertEqual(mode, 'Off') + 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 = 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 fcbb71b..daefa36 100644 --- a/test/test_equipment/test_generator.py +++ b/test/test_equipment/test_generator.py @@ -23,27 +23,27 @@ def test_init(self): def test_parse_control_signal(self): # test setpoint control mode = self.generator.parse_control_signal({}, {'P Setpoint': -2}) - self.assertEqual(mode, 'On') + self.assertEqual(mode, 1 self.assertEqual(self.generator.power_setpoint, -2) mode = self.generator.parse_control_signal({}, {'P Setpoint': 0}) - self.assertEqual(mode, 'Off') + self.assertEqual(mode, 0) # test control type control_signal = {'Control Type': 'Schedule'} mode = self.generator.parse_control_signal({}, control_signal) - self.assertEqual(mode, 'Off') + self.assertEqual(mode, 0) self.assertEqual(self.generator.control_type, 'Schedule') control_signal = {'Control Type': 'Other'} mode = self.generator.parse_control_signal({}, control_signal) - self.assertEqual(mode, 'Off') + self.assertEqual(mode, 0) self.assertEqual(self.generator.control_type, 'Schedule') # test parameter update control_signal = {'Parameters': {'charge_start_hour': 0}} mode = self.generator.parse_control_signal({}, control_signal) - self.assertEqual(mode, 'On') + self.assertEqual(mode, 1) self.assertEqual(self.generator.control_type, 'Schedule') self.assertEqual(self.generator.power_setpoint, 1) @@ -51,42 +51,42 @@ def test_run_internal_control(self): # test schedule-based control self.generator.control_type = 'Schedule' mode = self.generator.run_internal_control({}) - self.assertEqual(mode, 'Off') + 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.run_internal_control({}) - self.assertEqual(mode, 'On') + 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.run_internal_control({}) - self.assertEqual(mode, 'Off') + self.assertEqual(mode, 0) mode = self.generator.run_internal_control({'net_power': 2}) - self.assertEqual(mode, 'On') + self.assertEqual(mode, 1) self.assertEqual(self.generator.power_setpoint, -2) mode = self.generator.run_internal_control({'net_power': -1}) - self.assertEqual(mode, 'On') + 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.run_internal_control({'net_power': 3}) - self.assertEqual(mode, 'On') + self.assertEqual(mode, 1) self.assertAlmostEqual(self.generator.power_setpoint, -2) mode = self.generator.run_internal_control({'net_power': 0.9}) - self.assertEqual(mode, 'Off') + self.assertEqual(mode, 0) self.assertAlmostEqual(self.generator.power_setpoint, 0) # test off self.generator.control_type = 'Off' mode = self.generator.run_internal_control({}) - self.assertEqual(mode, 'Off') + 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 d0003de..840ff24 100644 --- a/test/test_equipment/test_hvac.py +++ b/test/test_equipment/test_hvac.py @@ -185,7 +185,7 @@ 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): diff --git a/test/test_equipment/test_pv.py b/test/test_equipment/test_pv.py index 9bd8da8..5272e75 100644 --- a/test/test_equipment/test_pv.py +++ b/test/test_equipment/test_pv.py @@ -39,28 +39,28 @@ def test_init(self): def test_parse_control_signal(self): mode = self.pv.parse_control_signal({}, {'P Setpoint': -5, 'Q Setpoint': 1}) - self.assertEqual(mode, 'On') + self.assertEqual(mode, 1) self.assertAlmostEqual(self.pv.p_set_point, -5) self.assertAlmostEqual(self.pv.q_set_point, 1) mode = self.pv.parse_control_signal({}, {'P Setpoint': -20, 'Q Setpoint': 1}) - self.assertEqual(mode, 'On') + 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.parse_control_signal({}, {'P Curtailment (kW)': 1}) - self.assertEqual(mode, 'On') + self.assertEqual(mode, 1) self.assertAlmostEqual(self.pv.p_set_point, -7.25, places=2) # test PV curtailment mode = self.pv.parse_control_signal({}, {'P Curtailment (%)': 50}) - self.assertEqual(mode, 'On') + self.assertEqual(mode, 1) self.assertAlmostEqual(self.pv.p_set_point, -4.13, places=2) # test power factor mode = self.pv.parse_control_signal({}, {'Power Factor': -0.95}) - self.assertEqual(mode, 'On') + 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) @@ -70,7 +70,7 @@ def test_parse_control_signal(self): def test_run_internal_control(self): mode = self.pv.run_internal_control({}) - self.assertEqual(mode, 'On') + self.assertEqual(mode, 1) self.assertAlmostEqual(self.pv.p_set_point, -8.16, places=2) self.assertAlmostEqual(self.pv.q_set_point, 0) @@ -133,7 +133,7 @@ def test_init(self): def test_run_internal_control(self): mode = self.pv.run_internal_control({}) - self.assertEqual(mode, 'On') + 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 6889679..288dc51 100644 --- a/test/test_equipment/test_scheduled.py +++ b/test/test_equipment/test_scheduled.py @@ -42,28 +42,28 @@ def test_init(self): def test_parse_control_signal(self): mode = self.equipment.parse_control_signal({'plug_loads': 100}, {'Load Fraction': 1}) - self.assertEqual(mode, 'On') + self.assertEqual(mode, 1) self.assertAlmostEqual(self.equipment.p_set_point, 0.1) mode = self.equipment.parse_control_signal({'plug_loads': 200}, {'Load Fraction': 0.5}) - self.assertEqual(mode, 'On') + self.assertEqual(mode, 1) self.assertAlmostEqual(self.equipment.p_set_point, 0.2 * 0.5) mode = self.equipment.parse_control_signal({'plug_loads': 200}, {'Load Fraction': 0}) - self.assertEqual(mode, 'Off') + self.assertEqual(mode, 0) self.assertAlmostEqual(self.equipment.p_set_point, 0) def test_run_internal_control(self): mode = self.equipment.run_internal_control({'plug_loads': 100}) - self.assertEqual(mode, 'On') + self.assertEqual(mode, 1) self.assertAlmostEqual(self.equipment.p_set_point, 0.1) mode = self.equipment.run_internal_control({'plug_loads': 0}) - self.assertEqual(mode, 'Off') + 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 = 'O1 self.equipment.p_set_point = 2 self.equipment.calculate_power_and_heat({}) self.assertAlmostEqual(self.equipment.sensible_gain, 1000) @@ -101,7 +101,7 @@ def test_reset_time(self, start_time=None): def test_run_internal_control(self): mode = self.equipment.run_internal_control({}) - self.assertEqual(mode, 'On') + 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 94459be..eda113b 100644 --- a/test/test_equipment/test_waterheater.py +++ b/test/test_equipment/test_waterheater.py @@ -127,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) From 318c280720521b0bdccbc42b42036c0b4253b548 Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Fri, 6 Sep 2024 08:01:13 -0600 Subject: [PATCH 14/18] implement prevent_overshoot control --- ochre/Equipment/HVAC.py | 10 +-- ochre/Equipment/ThermostaticLoad.py | 108 +++++++++++++++++++++++----- ochre/Equipment/WaterHeater.py | 19 +++-- 3 files changed, 104 insertions(+), 33 deletions(-) diff --git a/ochre/Equipment/HVAC.py b/ochre/Equipment/HVAC.py index 206c7ab..477a9b5 100644 --- a/ochre/Equipment/HVAC.py +++ b/ochre/Equipment/HVAC.py @@ -277,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 @@ -300,7 +300,7 @@ def solve_ideal_capacity(self): def update_capacity(self): 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 @@ -681,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 not self.on: + if not self.on_frac: speed = 1 elif self.hvac_mult * (self.zone.temperature - self.temp_indoor_prev) < 0: speed = 2 @@ -706,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 not self.on: + if not self.on_frac: speed = 1 else: speed = 2 @@ -857,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 not self.on 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 diff --git a/ochre/Equipment/ThermostaticLoad.py b/ochre/Equipment/ThermostaticLoad.py index 4adeb42..ab4e259 100644 --- a/ochre/Equipment/ThermostaticLoad.py +++ b/ochre/Equipment/ThermostaticLoad.py @@ -1,6 +1,7 @@ import datetime as dt from ochre.utils import OCHREException +from ochre.utils.units import kwh_to_therms from ochre.Equipment import Equipment @@ -8,7 +9,7 @@ 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 @@ -46,6 +47,11 @@ def __init__(self, thermal_model=None, use_ideal_mode=None, prevent_overshoot=Tr # 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)'] @@ -60,10 +66,14 @@ def __init__(self, thermal_model=None, use_ideal_mode=None, prevent_overshoot=Tr self.max_power = kwargs.get('Max Power (kW)') self.force_off = False - # Thermal model parameters + # 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 @@ -108,6 +118,7 @@ def parse_control_signal(self, control_signal): 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 @@ -115,7 +126,7 @@ def update_setpoint(self): self.temp_setpoint = self.current_schedule[f"{self.end_use} Setpoint (C)"] # constrain setpoint based on max ramp rate - # TODO: create temp_setpoint_old and update in update_results. + # 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 @@ -130,27 +141,38 @@ def update_setpoint(self): 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): + def solve_ideal_capacity(self, setpoint=None): # Solve thermal model for input heat injection to achieve setpoint - capacity = self.thermal_model.solve_for_input( + if setpoint is None: + setpoint = self.temp_setpoint + + return self.thermal_model.solve_for_input( self.t_control_idx, self.h_control_idx, - self.temp_setpoint, + setpoint, solve_as_output=False, ) - return capacity - def update_capacity(self): - return self.solve_ideal_capacity() + 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 run_thermostat_control(self): # use thermostat with deadband control t_control = self.thermal_model.states[self.t_control_idx] if (t_control - self.temp_deadband_on) * self.heat_mult < 0: - return "On" + return 1 elif (t_control - self.temp_deadband_off) * self.heat_mult > 0: - return "Off" + return 0 else: # maintains existing mode return None @@ -159,11 +181,63 @@ 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: - # run ideal capacity calculation here - # FUTURE: capacity update is done twice per loop, could but updated to improve speed - self.capacity = self.update_capacity() - return "On" if self.capacity > 0 else "Off" + self.on_frac_new = self.run_ideal_control() + else: + # Run thermostat controller and set on fraction + self.on_frac_new = self.run_thermostat_control() + if self.on_frac_new is None: + self.on_frac_new = self.on_frac + self.capacity = self.on_frac_new * self.capacity_rated + + # 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) + + # if overshoot, run in ideal mode at deadband limit + t_control = self.thermal_model.new_states[self.t_control_idx] + if not self.on_frac_new and (t_control - self.temp_deadband_on) * self.heat_mult < 0: + self.on_frac_new = self.run_ideal_control(setpoint=self.temp_deadband_on) + self.on_at_end = True + elif self.on_frac_new and (t_control - self.temp_deadband_off) * self.heat_mult > 0: + self.on_frac_new = self.run_ideal_control(setpoint=self.temp_deadband_off) + self.on_at_end = False + + 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.h_control_idx: self.delivered_heat} + + 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: - # Run thermostat controller and set speed - return self.run_thermostat_control() + 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 59addc5..bd7276a 100644 --- a/ochre/Equipment/WaterHeater.py +++ b/ochre/Equipment/WaterHeater.py @@ -61,10 +61,6 @@ def __init__(self, model_class=None, **kwargs): self.t_control_idx = self.t_lower_idx self.h_control_idx = self.h_lower_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 - # Control parameters # note: bottom of deadband is (setpoint_temp - deadband_temp) self.temp_max = kwargs.get('Max Tank Temperature (C)', convert(140, 'degF', 'degC')) @@ -78,7 +74,7 @@ def update_inputs(self, schedule_inputs=None): super().update_inputs(schedule_inputs) - def solve_ideal_capacity(self): + def run_ideal_control(self): # calculate ideal capacity based on achieving lower node setpoint temperature # Run model with heater off, updates next_states self.thermal_model.update_model() @@ -109,12 +105,13 @@ def run_thermostat_control(self): return None def update_capacity(self): + # TODO: merge with solve_ideal_capacity? if self.thermal_model.n_nodes == 1: # calculate ideal capacity using tank model directly # - more accurate than self.solve_ideal_capacity - return ThermostaticLoad.solve_ideal_capacity(self) + return ThermostaticLoad.run_ideal_control(self) else: - return self.solve_ideal_capacity() + return self.run_ideal_control() def add_heat_from_mode(self, mode, heats_to_tank=None, pct_time_on=1): if heats_to_tank is None: @@ -204,7 +201,7 @@ class ElectricResistanceWaterHeater(WaterHeater): name = 'Electric Resistance Water Heater' modes = ['Upper On', 'Lower On', 'Off'] - def solve_ideal_capacity(self): + def run_ideal_control(self): # calculate ideal capacity based on upper and lower node setpoint temperatures # Run model with heater off self.thermal_model.update_model() @@ -317,10 +314,10 @@ def update_inputs(self, schedule_inputs=None): super().update_inputs(schedule_inputs) - 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 @@ -524,7 +521,7 @@ def calculate_power_and_heat(self): if self.max_power and power > self.max_power: self.heat_from_draw *= self.max_power / power - if not self.on: + if not self.on_frac: # do not update heat, force water heater off self.delivered_heat = 0 elif self.heat_from_draw > self.capacity_rated: From 8e428bf9bd6cf7ca3f014cbc3e63d6408829adac Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Fri, 6 Sep 2024 08:01:37 -0600 Subject: [PATCH 15/18] update on fraction name and internal control methods --- ochre/Equipment/Battery.py | 2 +- ochre/Equipment/EV.py | 4 +-- ochre/Equipment/Equipment.py | 40 +++++++++++++------------- ochre/Equipment/EventBasedLoad.py | 12 ++++---- ochre/Equipment/Generator.py | 8 +++--- ochre/Equipment/PV.py | 2 +- ochre/Equipment/ScheduledLoad.py | 4 +-- test/test_equipment/test_battery.py | 6 ++-- test/test_equipment/test_equipment.py | 12 ++++---- test/test_equipment/test_eventbased.py | 2 +- test/test_equipment/test_hvac.py | 12 ++++---- test/test_equipment/test_scheduled.py | 2 +- 12 files changed, 53 insertions(+), 53 deletions(-) diff --git a/ochre/Equipment/Battery.py b/ochre/Equipment/Battery.py index e2977f8..e3abe92 100644 --- a/ochre/Equipment/Battery.py +++ b/ochre/Equipment/Battery.py @@ -256,7 +256,7 @@ def run_internal_control(self): if self.power_setpoint < 0 and self.soc <= self.soc_min: self.power_setpoint = 0 - return 1 if self.power_setpoint != 0 else 0 + 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 d0e1813..4dc9656 100644 --- a/ochre/Equipment/EV.py +++ b/ochre/Equipment/EV.py @@ -274,11 +274,11 @@ def run_internal_control(self): if "EV Max SOC (-)" in self.current_schedule: self.soc_max_ctrl = self.current_schedule["EV Max SOC (-)"] - return super().run_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 not self.on: + if not self.on_frac_new: return super().calculate_power_and_heat() # force ac power within kw capacity and SOC limits, no discharge allowed diff --git a/ochre/Equipment/Equipment.py b/ochre/Equipment/Equipment.py index 6f5472f..1107042 100644 --- a/ochre/Equipment/Equipment.py +++ b/ochre/Equipment/Equipment.py @@ -63,8 +63,8 @@ def __init__(self, zone_name=None, envelope_model=None, save_ebm_results=False, self.latent_gain = 0 # in W # Mode and controller parameters - self.on = 0 # fraction of time on (0-1) - self.on_new = 0 # fraction of time on (0-1) + 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 @@ -93,8 +93,8 @@ def parse_control_signal(self, control_signal): raise OCHREException('Must define external control algorithm for {}'.format(self.name)) def run_internal_control(self): - # Returns the equipment mode (0 or 1); can return None if the mode doesn't change - raise NotImplementedError() + # 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 @@ -129,15 +129,15 @@ def run_zip(self, v, v0=1): def update_mode_times(self): # updates mode times - if self.on_new: - self.time_on += self.time_res * self.on_new - if not self.on: + 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: self.time_off += self.time_res - if self.on: + if self.on_frac: self.time_on = dt.timedelta(minutes=0) @@ -146,21 +146,21 @@ def update_model(self, control_signal=None): if control_signal: self.parse_control_signal(control_signal) - # run equipment controller to determine on/off mode - self.on_new = self.run_internal_control() + # 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_new is None: - self.on_new = self.on - elif not self.on_new and self.time_on < self.min_on_time: - self.on_new = self.on - elif self.on_new and self.time_off < self.min_off_time: - self.on_new = self.on + 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_new = 0 + self.on_frac_new = 0 # calculate electric and gas power and heat gains heat_data = self.calculate_power_and_heat() @@ -204,7 +204,7 @@ def make_equivalent_battery_model(self): def update_results(self): self.update_mode_times() - self.on = self.on_new + self.on_frac = self.on_frac_new return super().update_results() @@ -221,7 +221,7 @@ 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} On-Time Fraction (-)'] = self.on + results[f'{self.results_name} On-Time Fraction (-)'] = self.on_frac if self.save_ebm_results: results.update(self.make_equivalent_battery_model()) @@ -233,7 +233,7 @@ def reset_time(self, start_time=None, on_previous=False, **kwargs): super().reset_time(start_time=start_time, **kwargs) # set previous mode, defaults to off - self.on = int(on_previous) + self.on_frac = int(on_previous) # reset mode times and cycles if start_time is not None and start_time != self.start_time: diff --git a/ochre/Equipment/EventBasedLoad.py b/ochre/Equipment/EventBasedLoad.py index 3edad56..09c6d7d 100644 --- a/ochre/Equipment/EventBasedLoad.py +++ b/ochre/Equipment/EventBasedLoad.py @@ -132,7 +132,7 @@ def parse_control_signal(self, control_signal): if 'Delay' in control_signal: delay = control_signal['Delay'] - if delay and self.on: + if delay and self.on_frac: self.warn('Ignoring delay signal, event has already started.') delay = False if isinstance(delay, (int, bool)): @@ -154,18 +154,18 @@ def parse_control_signal(self, control_signal): def run_internal_control(self): if self.current_time < self.event_start: # waiting for next event to start - return 0 + self.on_frac_new = 0 elif self.current_time < self.event_end: - if not self.on: + if not self.on_frac: self.start_event() - return 1 + self.on_frac_new = 1 else: # event has ended, move to next event self.end_event() - return 0 + self.on_frac_new = 0 def calculate_power_and_heat(self): - if self.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 f3786d5..b0c4dca 100644 --- a/ochre/Equipment/Generator.py +++ b/ochre/Equipment/Generator.py @@ -116,7 +116,7 @@ def run_internal_control(self): f"{self.end_use} Electric Power (kW)", 0 ) - return 1 if self.power_setpoint != 0 else 0 + 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 @@ -172,12 +172,12 @@ def calculate_efficiency(self, electric_kw=None, is_output_power=True): ) def calculate_power_and_heat(self): - if not self.on: - 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/PV.py b/ochre/Equipment/PV.py index 202ae75..9e4c685 100644 --- a/ochre/Equipment/PV.py +++ b/ochre/Equipment/PV.py @@ -205,7 +205,7 @@ def run_internal_control(self): 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) - return 1 if self.p_set_point < 0 else 0 + 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 082018e..45b08f1 100644 --- a/ochre/Equipment/ScheduledLoad.py +++ b/ochre/Equipment/ScheduledLoad.py @@ -113,10 +113,10 @@ def run_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 1 if self.p_set_point + self.gas_set_point != 0 else 0 + 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.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/test/test_equipment/test_battery.py b/test/test_equipment/test_battery.py index 732a1c8..a9f7dab 100644 --- a/test/test_equipment/test_battery.py +++ b/test/test_equipment/test_battery.py @@ -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.on = 1 + 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.on = 1 + 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.on = 1 + 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 6eee70a..e65f757 100644 --- a/test/test_equipment/test_equipment.py +++ b/test/test_equipment/test_equipment.py @@ -18,12 +18,12 @@ def __init__(self, max_p, **kwargs): def run_internal_control(self): # Turns on for 5 minutes, then off for 5 minutes if self.current_time.minute % 10 >= 5: - return 0 + self.on_frac_new = 0 else: - return 1 + self.on_frac_new = 1 def calculate_power_and_heat(self): - if self.on: + if self.on_frac_new: self.electric_kw = min(self.current_time.minute, self.max_p) else: self.electric_kw = 0 @@ -57,7 +57,7 @@ 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.on, 1) + 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) @@ -71,13 +71,13 @@ def test_update(self): self.assertEqual(self.equipment.electric_kw, 0) # Test with minimum on/off times - self.equipment.on = 1 + 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.on, 1) + self.assertEqual(self.equipment.on_frac, 1) self.assertEqual(self.equipment.time_on, equip_init_args['time_res']) self.equipment.time_on = dt.timedelta(1tes=2) diff --git a/test/test_equipment/test_eventbased.py b/test/test_equipment/test_eventbased.py index c23a62d..d217eaa 100644 --- a/test/test_equipment/test_eventbased.py +++ b/test/test_equipment/test_eventbased.py @@ -68,7 +68,7 @@ def test_run_internal_control(self): self.assertNotEqual(self.e.event_start, first_event_start) def test_calculate_power_and_heat(self): - self.e.on = 1 + 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_hvac.py b/test/test_equipment/test_hvac.py index 840ff24..abcae45 100644 --- a/test/test_equipment/test_hvac.py +++ b/test/test_equipment/test_hvac.py @@ -205,15 +205,15 @@ def test_run_internal_control(self): 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): @@ -239,15 +239,15 @@ def setUp(self): 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) diff --git a/test/test_equipment/test_scheduled.py b/test/test_equipment/test_scheduled.py index 288dc51..9389e73 100644 --- a/test/test_equipment/test_scheduled.py +++ b/test/test_equipment/test_scheduled.py @@ -63,7 +63,7 @@ def test_run_internal_control(self): self.assertAlmostEqual(self.equipment.p_set_point, 0) def test_calculate_power_and_heat(self): - self.equipment.on = 'O1 + self.equipment.on_frac = 'O1 self.equipment.p_set_point = 2 self.equipment.calculate_power_and_heat({}) self.assertAlmostEqual(self.equipment.sensible_gain, 1000) From c7e2f6d04e97ebb6ce5f90e0f2aa188332278fc2 Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Tue, 10 Sep 2024 09:30:03 -0600 Subject: [PATCH 16/18] minor bug fix for negative EV max power --- ochre/Equipment/EV.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ochre/Equipment/EV.py b/ochre/Equipment/EV.py index 4dc9656..35d824f 100644 --- a/ochre/Equipment/EV.py +++ b/ochre/Equipment/EV.py @@ -284,7 +284,7 @@ def calculate_power_and_heat(self): # force ac power within kw capacity and SOC limits, no discharge allowed hours = self.time_res.total_seconds() / 3600 ac_power = (self.soc_max_ctrl - self.soc) * self.capacity / hours / EV_EFFICIENCY - ac_power = min(max(ac_power, 0), self.max_power_ctrl) + 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 From 8cf7eb2073d6cd60fd1cdda976b72b634079e5f5 Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Wed, 11 Sep 2024 07:17:16 -0600 Subject: [PATCH 17/18] added prevent_overshoot mode --- ochre/Equipment/Equipment.py | 4 +- ochre/Equipment/ThermostaticLoad.py | 71 +++++++++++++++++++++-------- 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/ochre/Equipment/Equipment.py b/ochre/Equipment/Equipment.py index 1107042..8c7ceb4 100644 --- a/ochre/Equipment/Equipment.py +++ b/ochre/Equipment/Equipment.py @@ -203,10 +203,12 @@ def make_equivalent_battery_model(self): } def update_results(self): + current_results = super().update_results() + self.update_mode_times() self.on_frac = self.on_frac_new - return super().update_results() + return current_results def generate_results(self): results = super().generate_results() diff --git a/ochre/Equipment/ThermostaticLoad.py b/ochre/Equipment/ThermostaticLoad.py index ab4e259..aa068ed 100644 --- a/ochre/Equipment/ThermostaticLoad.py +++ b/ochre/Equipment/ThermostaticLoad.py @@ -37,8 +37,8 @@ def __init__(self, thermal_model=None, use_ideal_mode=None, prevent_overshoot=Tr self.sub_simulators.append(self.thermal_model) # Model parameters - self.t_control_idx = None # state index for thermostat control - self.h_control_idx = None # input index for thermostat control + 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: @@ -165,18 +165,55 @@ def run_ideal_control(self, setpoint=None): # return on fraction return self.capacity / self.capacity_rated - def run_thermostat_control(self): - # use thermostat with deadband control - t_control = self.thermal_model.states[self.t_control_idx] + 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 - self.temp_deadband_on) * self.heat_mult < 0: + if (t_control - on_limit) * self.heat_mult < 0: + # turn on if below the on limit (for heaters) return 1 - elif (t_control - self.temp_deadband_off) * self.heat_mult > 0: + 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() @@ -185,11 +222,8 @@ def run_internal_control(self): if self.use_ideal_mode: self.on_frac_new = self.run_ideal_control() else: - # Run thermostat controller and set on fraction + # Run thermostat controller and set on fraction and output capacity self.on_frac_new = self.run_thermostat_control() - if self.on_frac_new is None: - self.on_frac_new = self.on_frac - self.capacity = self.on_frac_new * self.capacity_rated # check new states and prevent overshoot by running in ideal mode if self.prevent_overshoot: @@ -197,14 +231,11 @@ def run_internal_control(self): heat_data = self.calculate_power_and_heat() super().update_model(heat_data) - # if overshoot, run in ideal mode at deadband limit - t_control = self.thermal_model.new_states[self.t_control_idx] - if not self.on_frac_new and (t_control - self.temp_deadband_on) * self.heat_mult < 0: - self.on_frac_new = self.run_ideal_control(setpoint=self.temp_deadband_on) - self.on_at_end = True - elif self.on_frac_new and (t_control - self.temp_deadband_off) * self.heat_mult > 0: - self.on_frac_new = self.run_ideal_control(setpoint=self.temp_deadband_off) - self.on_at_end = False + # 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 @@ -218,7 +249,7 @@ def calculate_power_and_heat(self): self.delivered_heat *= self.max_power / power # get heat injection to the thermal model, based on capacity - heat_to_model = {self.h_control_idx: self.delivered_heat} + heat_to_model = self.get_heat_to_model() if self.is_gas: # by default, no sensible gains from gas equipment (assume vented) From ec040a2a71efe438d72dc630634455004acfb8e1 Mon Sep 17 00:00:00 2001 From: Michael Blonsky Date: Wed, 11 Sep 2024 07:17:39 -0600 Subject: [PATCH 18/18] ERWH and tankless updates for tstat implementation --- bin/run_equipment.py | 25 +-- ochre/Equipment/WaterHeater.py | 378 +++++++++++++++++++-------------- 2 files changed, 229 insertions(+), 174 deletions(-) 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/ochre/Equipment/WaterHeater.py b/ochre/Equipment/WaterHeater.py index bd7276a..898254e 100644 --- a/ochre/Equipment/WaterHeater.py +++ b/ochre/Equipment/WaterHeater.py @@ -18,6 +18,7 @@ class WaterHeater(ThermostaticLoad): 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)", @@ -44,28 +45,17 @@ def __init__(self, model_class=None, **kwargs): } thermal_model = model_class(**water_tank_args) - super().__init__(model=thermal_model, **kwargs) + super().__init__(thermal_model=thermal_model, **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 = ( + # 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 ) - self.t_control_idx = self.t_lower_idx - self.h_control_idx = self.h_lower_idx # Control parameters - # note: bottom of deadband is (setpoint_temp - deadband_temp) self.temp_max = kwargs.get('Max Tank Temperature (C)', convert(140, 'degF', 'degC')) - # deadband range, in delta degC, i.e. Kelvin - self.temp_deadband = kwargs.get('Deadband Temperature (C)', self.default_deadband) def update_inputs(self, schedule_inputs=None): # Add zone temperature to schedule inputs for water tank @@ -74,88 +64,52 @@ def update_inputs(self, schedule_inputs=None): super().update_inputs(schedule_inputs) - def run_ideal_control(self): - # calculate ideal capacity based on achieving lower node setpoint temperature - # Run model with heater off, updates next_states - self.thermal_model.update_model() - off_states = self.thermal_model.next_states + 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 - # calculate heat needed to reach setpoint - only use nodes at and above lower node - set_states = np.ones(len(off_states)) * self.temp_setpoint - n_states = self.t_lower_idx + 1 - h_desired = np.dot(set_states[:n_states] - off_states[:n_states], # in W - self.thermal_model.capacitances[:n_states]) / self.time_res.total_seconds() + # 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: + raise NotImplementedError() + next_states = self.thermal_model.next_states - return h_desired + # get ideal setpoint states + set_states = np.ones(len(next_states)) * setpoint - def run_thermostat_control(self): - # use thermostat with deadband control - if self.thermal_model.n_nodes <= 2: - t_lower = self.thermal_model.states[self.t_lower_idx] - else: - # take average of lower node and node above - t_lower = (self.thermal_model.states[self.t_lower_idx] + self.thermal_model.states[self.t_lower_idx - 1]) / 2 + # 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 - if (t_lower - self.temp_deadband_on) * self.heat_mult < 0: - return "On" - elif (t_lower - self.temp_deadband_off) * self.heat_mult > 0: - return "Off" - else: - # maintains existing mode - return None + # 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 update_capacity(self): - # TODO: merge with solve_ideal_capacity? + def solve_ideal_capacity(self, setpoint=None): if self.thermal_model.n_nodes == 1: # calculate ideal capacity using tank model directly - # - more accurate than self.solve_ideal_capacity - return ThermostaticLoad.run_ideal_control(self) - else: - return self.run_ideal_control() - - 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) - - 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 - - return heats_to_tank - - def calculate_power_and_heat(self): - # get heat injections from water heater - if self.use_ideal_mode and self.mode != 'Off': - heats_to_tank = np.zeros(self.thermal_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 + # more accurate than code below + return super().solve_ideal_capacity(setpoint) - 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 + # calculate heat needed to reach setpoint + # - only use nodes at and above control node + return self.solve_ideal_capacity_by_mode(setpoint) - self.latent_gain = 0 + 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_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.thermal_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 @@ -174,7 +128,6 @@ def generate_results(self): 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): @@ -201,43 +154,169 @@ class ElectricResistanceWaterHeater(WaterHeater): name = 'Electric Resistance Water Heater' modes = ['Upper On', 'Lower On', 'Off'] - def run_ideal_control(self): - # calculate ideal capacity based on upper and lower node setpoint temperatures - # Run model with heater off - self.thermal_model.update_model() - off_states = self.thermal_model.next_states + 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 + ) - # calculate heat needed to reach setpoint - only use nodes at and above upper/lower nodes - set_states = np.ones(len(off_states)) * self.temp_setpoint - h_total = np.dot(set_states[:self.t_lower_idx + 1] - off_states[:self.t_lower_idx + 1], # in W - self.thermal_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.thermal_model.capacitances[:self.t_upper_idx + 1]) / self.time_res.total_seconds() - h_lower = h_total - h_upper + # 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: + # 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] + ) - # 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} + return super().solve_deadband_mode(t_control, on_limit, off_limit) def run_thermostat_control(self): - # use thermostat with deadband control, upper element gets priority over lower element + if self.thermal_model.n_nodes == 1: + return super().run_thermostat_control() + + # 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] - if self.thermal_model.n_nodes <= 2: - t_lower = self.thermal_model.states[self.t_lower_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: - # take average of lower node and node above - t_lower = (self.thermal_model.states[self.t_lower_idx] + self.thermal_model.states[self.t_lower_idx - 1]) / 2 - - lower_threshold_temp = self.temp_setpoint - self.temp_deadband - if t_upper < lower_threshold_temp or (self.mode == 'Upper On' and t_upper < self.temp_setpoint): - return 'Upper On' - if t_lower < lower_threshold_temp: - return 'Lower On' - if self.mode == 'Upper On' and t_upper > self.temp_setpoint: - return 'Off' - if t_lower > self.temp_setpoint: - return 'Off' + # 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") + + 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) + + 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 + + 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): @@ -492,7 +571,6 @@ 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 @@ -501,52 +579,36 @@ def __init__(self, **kwargs): 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.thermal_model.states[self.t_upper_idx] = self.temp_setpoint + self.thermal_model.states[self.t_control_idx] = self.temp_setpoint - def run_internal_control(self): - self.update_setpoint() - self.thermal_model.states[self.t_upper_idx] = self.temp_setpoint + def update_setpoint(self): + super().update_setpoint() - self.heat_from_draw = -self.thermal_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 not self.on_frac: - # 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 + 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.heat_from_draw) + 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() - # Reset tank model and update delivered heat - self.thermal_model.states[self.thermal_model.t_1_idx] = t_set - self.delivered_heat = self.capacity_rated - else: - self.delivered_heat = self.heat_from_draw + return capacity - self.electric_kw = self.delivered_heat / self.efficiency / 1000 + def update_results(self): + current_results = super().update_results() - # for now, no extra heat gains for tankless water heater - # self.sensible_gain = self.delivered_heat * (1 / self.efficiency - 1) - self.sensible_gain = 0 + # Reset tank model to setpoint temperature + self.thermal_model.states[self.thermal_model.t_1_idx] = self.temp_setpoint - # send heat gain inputs to tank model - # note: heat losses from tank are added to sensible gains in update_results - return {self.thermal_model.name: np.array([self.delivered_heat])} + return current_results class GasTanklessWaterHeater(TanklessWaterHeater): @@ -562,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.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