diff --git a/hycon/controllers/__init__.py b/hycon/controllers/__init__.py index a10ac27c..722f7e75 100644 --- a/hycon/controllers/__init__.py +++ b/hycon/controllers/__init__.py @@ -17,3 +17,4 @@ WindFarmPowerDistributingController, WindFarmPowerTrackingController, ) +from hycon.controllers.thermal_plant_controller import ThermalPlantController diff --git a/hycon/controllers/hybrid_supervisory_controller.py b/hycon/controllers/hybrid_supervisory_controller.py index db51009d..b7682841 100644 --- a/hycon/controllers/hybrid_supervisory_controller.py +++ b/hycon/controllers/hybrid_supervisory_controller.py @@ -46,7 +46,8 @@ def __init__( "interconnect_limit must be a positive value (or -1, indicating no limit)." ) - def set_controller_parameters(self, component_controllers=[], curtailment_order=None): + def set_controller_parameters(self, component_controllers=[], curtailment_order=None, + minimum_power=None, maximum_power=None, forced_state=None): """ Set controller parameters for HybridSupervisoryControllerGeneric. @@ -56,6 +57,10 @@ def set_controller_parameters(self, component_controllers=[], curtailment_order= components in the simulation. curtailment_order: List of integers corresponding to the order in which to curtail components when the overall power reference exceeds the interconnection limit. + minimum_power: List of floats corresponding to the minimum power that each component + should be allowed to produce, even when curtailing to meet the interconnection + limit. Should be the same length as component_controllers, and ordered + correspondingly. """ # Check valid component_controllers @@ -90,6 +95,21 @@ def set_controller_parameters(self, component_controllers=[], curtailment_order= else: self.curtailment_order = curtailment_order + # Check valid minimum_power + if minimum_power is None: + # Default is reverse order of component_controllers + self.minimum_power = np.zeros_like(component_controllers) + elif len(minimum_power) != len(component_controllers): + raise ValueError("minimum_power must be the same length as component_controllers.") + elif not all([isinstance(c, (float, int)) and c >= 0 for c in minimum_power]): + raise ValueError( + "All entries in minimum_power must be non-negative floats or integers corresponding" + " to indices of component_controllers." + ) + else: + self.minimum_power = minimum_power + + def compute_controls(self, measurements_dict): """ Pass necessary information to each component controller, and apply power @@ -116,6 +136,9 @@ def compute_controls(self, measurements_dict): cc.cname ]["power_setpoint"], ) + cc.plant_parameters[cc.cname][ + "available_storage_for_charging" + ] = total_available_storage_for_charging # Get overall reference, and remove from measurements_dict to avoid confusion for # component controllers. @@ -149,28 +172,34 @@ def compute_controls(self, measurements_dict): # Loop over curtailment order in reverse to bring in power for each component until we hit # the interconnection limit, then curtail as needed according to the order. + # Take into account the minimum_power for each component, which indicates the minimum power + # that component should be allowed to produce. for cidx in self.curtailment_order[::-1]: cc = self.component_controllers[cidx] if cc.plant_parameters[cc.cname]["component_category"] == "generator": - power_reference_component = power_reference_with_storage - power_export_total + power_reference_component = max( + power_reference_with_storage - power_export_total - ( + sum( self.minimum_power[i] for i in self.curtailment_order if i < cidx) ), + self.minimum_power[cidx] + ) elif cc.plant_parameters[cc.cname]["component_category"] == "storage": if cc.plant_parameters[cc.cname].get("allow_grid_charging", True): - power_reference_component = power_reference_total - power_export_total - measurements_dict[cc.cname]["power_limit_lower"] = -np.inf - measurements_dict[cc.cname]["power_limit_upper"] = power_reference_component + power_reference_component = power_reference_total - power_export_total - ( + sum( self.minimum_power[i] for i in self.curtailment_order if i < cidx) + ) else: power_reference_component = max( - power_reference_total - power_export_total, + power_reference_total - power_export_total - + sum( self.minimum_power[i] for i in self.curtailment_order if i < cidx), -locally_generated_power_total, ) - measurements_dict[cc.cname][ - "power_limit_lower" - ] = -locally_generated_power_total - measurements_dict[cc.cname]["power_limit_upper"] = power_reference_component # Reduce or increase the available power to store locally_generated_power_total += measurements_dict[cc.cname]["power"] + power_reference_with_storage -= cc.plant_parameters[cc.cname].get( + "available_storage_for_charging", 0) + # Assign power_reference_component for use by lower level controller measurements_dict[cc.cname]["power_reference"] = power_reference_component @@ -178,4 +207,4 @@ def compute_controls(self, measurements_dict): power_export_total += measurements_dict[cc.cname]["power"] - return controls_dict + return controls_dict \ No newline at end of file diff --git a/hycon/controllers/thermal_plant_controller.py b/hycon/controllers/thermal_plant_controller.py new file mode 100644 index 00000000..9d9aa489 --- /dev/null +++ b/hycon/controllers/thermal_plant_controller.py @@ -0,0 +1,49 @@ +import numpy as np + +from hycon.controllers.controller_base import ControllerBase + +# Default power setpoint in kW (meant to ensure power maximization) +POWER_SETPOINT_DEFAULT = 1e9 + + +class ThermalPlantController(ControllerBase): + """ + Sets thermal plant power reference between turbines without + feedback on current power generation. + """ + + def __init__(self, interface, cname, controller_parameters={}, verbose=False): + super().__init__(interface, cname, verbose) + self.check_controller_parameters(controller_parameters) + self.set_controller_parameters(**controller_parameters) + + # def compute_controls(self, measurements_dict): + + # ref_in_lower_dict = ( + # "power_reference" in measurements_dict[self.cname] + # and measurements_dict[self.cname]["power_reference"] is not None + # ) + # ref_in_upper_dict = ( + # "power_reference" in measurements_dict + # and measurements_dict["power_reference"] is not None + # ) + # if ref_in_lower_dict and ref_in_upper_dict: + # raise KeyError( + # "Found 'power_reference' in both measurements_dict['" + # + self.cname + # + "'] and measurements_dict." + # ) + # elif ref_in_lower_dict: + # farm_power_reference = measurements_dict[self.cname]["power_reference"] + # elif ref_in_upper_dict: + # farm_power_reference = measurements_dict["power_reference"] + # else: + # farm_power_reference = POWER_SETPOINT_DEFAULT + + # return {"power_setpoint": farm_power_reference} + + def set_controller_parameters(self): + pass + + def compute_controls(self, measurements_dict): + return {self.cname: {"power_setpoint": measurements_dict[self.cname]["power_reference"]}} diff --git a/hycon/interfaces/hercules_interface.py b/hycon/interfaces/hercules_interface.py index f50997b6..10640242 100644 --- a/hycon/interfaces/hercules_interface.py +++ b/hycon/interfaces/hercules_interface.py @@ -21,7 +21,7 @@ hercules_solar_types = ["SolarPySAMPVWatts"] hercules_battery_types = ["BatteryLithiumIon", "BatterySimple"] hercules_hydrogen_types = ["ElectrolyzerPlant"] -hercules_thermal_types = ["HardCoalSteamTurbine", "OpenCycleGasTurbine"] +hercules_thermal_types = ["HardCoalSteamTurbine", "OpenCycleGasTurbine", "ThermalPlant"] class HerculesInterface(InterfaceBase): @@ -71,11 +71,18 @@ def __init__(self, h_dict): "allow_grid_charging": h_dict[c].get("allow_grid_power_consumption", True), "state_of_charge_max": h_dict[c].get("max_SOC", 1.0), "state_of_charge_min": h_dict[c].get("min_SOC", 0.0), + "roundtrip_efficiency": h_dict[c].get("roundtrip_efficiency", 1.0), } elif c_type in hercules_hydrogen_types: self.plant_parameters[c] = {"type": "hydrogen", "component_category": "load"} elif c_type in hercules_thermal_types: - self.plant_parameters[c] = {"type": "thermal", "component_category": "generator"} + self.plant_parameters[c] = { + "type": "thermal", + "component_category": "generator", + "P_min": h_dict[c]["min_stable_load_fraction"] * h_dict[c]["rated_capacity"], + "P_max": h_dict[c]["rated_capacity"], + "ramp_rate": h_dict[c]["ramp_rate_fraction"]*h_dict[c]["rated_capacity"]/60.0, + } else: raise ValueError(f"Component '{c}' has unrecognized type '{c_type}' for Hycon.") @@ -182,5 +189,8 @@ def send_controls( "power_setpoint" ) h_dict[c] = h_dict[c] | controls_dict[c] + else: + # Set a safe default power_setpoint for components without controllers + h_dict[c].setdefault("power_setpoint", 0.0) return h_dict