diff --git a/docs/sphinx/source/user_guide/index.rst b/docs/sphinx/source/user_guide/index.rst index 3a0c204ffa..3be8fb4a5b 100644 --- a/docs/sphinx/source/user_guide/index.rst +++ b/docs/sphinx/source/user_guide/index.rst @@ -15,6 +15,7 @@ User Guide clearsky bifacial forecasts + storage comparison_pvlib_matlab variables_style_rules singlediode diff --git a/docs/sphinx/source/user_guide/storage.rst b/docs/sphinx/source/user_guide/storage.rst new file mode 100644 index 0000000000..42d3622af0 --- /dev/null +++ b/docs/sphinx/source/user_guide/storage.rst @@ -0,0 +1,614 @@ +.. _storage: + +Storage +======= + +Storage is a way of transforming energy that is available at a given instant, +for use at a later time. The way in which this energy is stored can vary +depending on the storage technology. It can be potential energy, heat or +chemical, among others. Storage is literally everywhere, in small electronic +devices like mobile phones or laptops, to electric vehicles, to huge dams. + +While humanity shifts from fossil fuels to renewable energy, it faces the +challenges of integrating more energy sources that are not always stable nor +predictable. The energy transition requires not only to replace current +electricity generation plants to be renewable, but also to replace heating +systems and combustion engines in the whole transportation sector to be +electric. + +Unfortunately, electrical energy cannot easily be stored so, if electricity is +becoming the main way of generating and consuming energy, energy storage +systems need to be capable of storing the excess electrical energy that is +produced when the generation is higher than the demand. Storage sysems need to +be: + +- Efficient: in the round-trip conversion from electrical to other type of + energy, and back to electrical again. + +- Capable: to store large quantities of energy. + +- Durable: to last longer. + +There are many specific use cases in which storage can be beneficial. In all of +them the underlying effect is the same: to make the grid more stable and +predictable. + +Some use cases are not necessarily always coupled with PV. For instance, with +power peak shaving, storage can be fed from the grid without a renewable energy +source directly connected to the system. Other use cases, however, are tightly +coupled with PV and hence, are of high interest for this project. + +Power versus energy +------------------- + +Module and inverter models in pvlib compute the power generation (DC and AC +respectively). This means the computation happens as an instant, without taking +into account the previous state nor the duration of the calculated power. It +is, in general, the way most models work in pvlib, with the exception of some +cell temperature models. It also means that time, or timestamps associated to +the power values, are not taken into consideration for the calculations. + +When dealing with storage systems, state plays a fundamental role. Degradation +and parameters like the state of charge (SOC) greatly affect how the systems +operates. This means that power computation is not sufficient. Energy is what +really matters and, in order to compute energy, time needs to be well defined. + +Conventions +*********** + +In order to work with time series pvlib relies on pandas and pytz to handle +time and time zones. See "Time and time zones" section for a brief +introduction. + +Also, when dealing with storage systems and power flow, you need to take into +account the following conventions: + +- Timestamps are associated to the beginning of the interval, as opposed to + other pvlib series where timestamps are instantaneous + +- The time series frequency needs to be well defined in the time series + +- Values represent a constant power throughout the interval, in W (power flow + simplifies calculations if you want to be able to model different time step + lengths) + +- Positive values represent power provided by the storage system (i.e.: + discharging), hence negative values represent power into the storage system + (i.e.: charging) + +.. note:: The left-labelling of the bins can be in conflict with energy meter + series data provided by the electricity retailer companies, where the + timestamp represents the time of the reading (the end of the interval). + However, using labels at the beginning of the interval eases typical group + operations with time series like resampling, where Pandas will assume by + default that the label is at the beginning of the interval. + +As an example, here you can see 15-minutes-period time series representing 1000 +W power throughout all the periods during January 2022: + +.. ipython:: python + + from pandas import date_range + from pandas import Series + + index = date_range( + "2022-01", + "2022-02", + closed="left", + freq="15T", + tz="Europe/Madrid", + ) + power = Series(1000.0, index=index) + power.head(2) + + +Batteries +--------- + +You can model and run battery simulations with pvlib. + +Introduction +************ + +The simplest way is to start with some battery specifications from a datasheet: + +.. ipython:: python + + parameters = { + "brand": "Sonnen", + "model": "sonnenBatterie 10/5,5", + "width": 0.690, + "height": 1.840, + "depth": 0.270, + "weight": 98, + "chemistry": "LFP", + "mode": "AC", + "charge_efficiency": 0.96, + "discharge_efficiency": 0.96, + "min_soc_percent": 5, + "max_soc_percent": 95, + "dc_modules": 1, + "dc_modules_in_series": 1, + "dc_energy_wh": 5500, + "dc_nominal_voltage": 102.4, + "dc_max_power_w": 3400, + } + +You can then use this information to build a model that can be used to run +battery simulations. The simplest model is the "bag of coulombs" (BOC) model, +which is an extreme simplification of a battery that does not take into account +any type of losses or degradation: + +.. note:: The BOC model is not the recommended model, but it useful to + understand how to work with other models. + + +.. ipython:: python + + from pvlib.battery import fit_boc + + state = fit_boc(parameters) + type(state) + + +The returned ``state`` represents the initial state of the battery for the +chosen model. This state can be used to simulate the behavior of the battery +provided a power series for the target dispatch: + +.. ipython:: python + + import matplotlib.pyplot as plt + + index = date_range( + "2022-01", + periods=30, + closed="left", + freq="1H", + tz="Europe/Madrid", + ) + power = Series(0.0, index=index) + power[2:10] = -500 + power[15:25] = 500 + + +Once you have the initial state and the dispatch power series, running the +simulation is very simple: + +.. ipython:: python + + from pvlib.battery import boc + + final_state, results = boc(state, power) + + +The simulation returns the final state of the battery and the resulting series +of power and SOC: + +.. ipython:: python + + plt.step(power.index, results["Power"].values, where="post", label="Result") + plt.step(power.index, power.values, where="post", label="Target", linestyle='dashed') + plt.ylabel('Power (W)') + @savefig boc_power.png + plt.legend() + @suppress + plt.close() + + +You can see how the target dispatch series is not followed perfectly by the +battery model. This is expected since the battery may reach its maximum or +minimum state of charge and, at that point, the energy flow will be unable to +follow the target. For this battery, the maximum SOC and minimum SOC were set +to 90 % and 10 % respectively: + +.. ipython:: python + + @savefig boc_soc.png + results["SOC"].plot(ylabel="SOC (%)") + @suppress + plt.close() + +More advanced models +******************** + +You can use other, more advanced, battery models with pvlib. + +The SAM model is much more precise and can be simulated using the same API: + +.. ipython:: python + + from pvlib.battery import sam + from pvlib.battery import fit_sam + + state = fit_sam(parameters) + final_state, results = sam(state, power) + + +As you can see from the results bellow, they slightly differ from the BOC +model, but represent an estimation that can be much closer to reality, +specially when running simulations over extended periods of time and with many +cycles: + +.. ipython:: python + + plt.step(power.index, results["Power"].values, where="post", label="Result") + plt.step(power.index, power.values, where="post", label="Target", linestyle='dashed') + plt.ylabel('Power (W)') + @savefig sam_power.png + plt.legend() + @suppress + plt.close() + + +.. ipython:: python + + @savefig sam_soc.png + results["SOC"].plot(ylabel="SOC (%)") + @suppress + plt.close() + + +Power flow +---------- + +With pvlib you can simulate power flow for different scenarios and use cases. + +In order to start playing with these use cases, you can start off by creating a +model chain containing the results of the PV generation for a particular +location and configuration: + +.. ipython:: python + + from pvlib.iotools import get_pvgis_tmy + from pvlib.location import Location + from pvlib.modelchain import ModelChain + from pvlib.pvsystem import Array + from pvlib.pvsystem import FixedMount + from pvlib.pvsystem import PVSystem + from pvlib.pvsystem import retrieve_sam + from pvlib.temperature import TEMPERATURE_MODEL_PARAMETERS as tmodel + + def run_model_chain(): + name = 'Madrid' + latitude = 40.31672645215922 + longitude = -3.674695061062714 + altitude = 603 + timezone = 'Europe/Madrid' + module = retrieve_sam('SandiaMod')['Canadian_Solar_CS5P_220M___2009_'] + inverter = retrieve_sam('cecinverter')['Powercom__SLK_1500__240V_'] + weather = get_pvgis_tmy(latitude, longitude, map_variables=True)[0] + weather.index = date_range( + start=weather.index[0].replace(year=2021), + end=weather.index[-1].replace(year=2021), + freq="H", + ) + weather.index = weather.index.tz_convert(timezone) + weather.index.name = "Timestamp" + location = Location( + latitude, + longitude, + name=name, + altitude=altitude, + tz=timezone, + ) + mount = FixedMount(surface_tilt=latitude, surface_azimuth=180) + temperature_model_parameters = tmodel['sapm']['open_rack_glass_glass'] + array = Array( + mount=mount, + module_parameters=module, + modules_per_string=16, + strings=1, + temperature_model_parameters=temperature_model_parameters, + ) + system = PVSystem(arrays=[array], inverter_parameters=inverter) + mc = ModelChain(system, location) + mc.run_model(weather) + return mc + + mc = run_model_chain() + + +And a syntethic load profile for the experiment: + +.. ipython:: python + + from numpy import nan + from numpy.random import uniform + + def residential_load_profile(index): + load = Series(data=nan, index=index) + load[load.index.hour == 0] = 600 + load[load.index.hour == 4] = 400 + load[load.index.hour == 13] = 1100 + load[load.index.hour == 17] = 800 + load[load.index.hour == 21] = 1300 + load *= uniform(low=0.6, high=1.4, size=len(load)) + load = load.interpolate(method="spline", order=2) + load = load.bfill().ffill() + return load + + load = residential_load_profile(mc.results.ac.index) + + +Self consumption +**************** + +The self-consumption use case is defined with the following assumptions: + +- A PV system is connected to a load and to the grid + +- The PV system generation is well-known + +- The load profile is well-known + +- The grid can provide as much power as needed + +- Any ammount of excess energy can be fed into the grid + +- The load is provided with power from the system, when possible + +- When the system is unable to provide sufficient power to the load, the grid + will fill the load requirements + +- When the system produces more power than the required by the load, it will be + fed back into the grid + +- The grid will provide power to the system if required (i.e.: during night + hours) + +You can use the system and load profiles to solve the energy/power flow for the +self-consumption use case: + +.. ipython:: python + + from pvlib.powerflow import self_consumption + + self_consumption_flow = self_consumption(mc.results.ac, load) + self_consumption_flow.head() + + +The function will return the power flow series from system to load/grid and +from grid to load/system: + +.. ipython:: python + + @savefig power_flow_self_consumption_load.png + self_consumption_flow.groupby(self_consumption_flow.index.hour).mean()[["System to load", "Grid to load", "System to grid"]].plot.bar(stacked=True, xlabel="Hour", ylabel="Power (W)", title="Average power flow") + @suppress + plt.close() + + +Self consumption with AC-connected battery +****************************************** + +The self-consumption with AC-connected battery use case is defined with the +following assumptions: + +- A PV system is connected to a load, a battery and to the grid + +- The battery is AC-connected + +- The PV system generation is well-known + +- The load profile is well-known + +- The grid can provide as much power as needed + +- Any ammount of excess energy can be fed into the grid + +- The load is provided with power from the system, when possible + +- When the system is unable to provide sufficient power to the load, the + battery may try to fill the load requirements, if the dispatching activates + the discharge + +- When both the system and the battery are unable to provide sufficient power + to the load, the grid will fill the load requirements + +- When the system produces more power than the required by the load, it may be + fed to the battery, if the dispatching activates the charge + +- When the excess power from the system (after feeding the load) is not fed + into the battery, it will be fed into the grid + +- The battery can only charge from the system and discharge to the load (i.e.: + battery-to-grid and grid-to-battery power flow is always zero) + +- The grid will provide power to the system if required (i.e.: during night + hours) + +For this use case, you need to start with the self-consumption power flow +solution: + +.. ipython:: python + + from pvlib.powerflow import self_consumption + + self_consumption_flow = self_consumption(mc.results.ac, load) + self_consumption_flow.head() + + +Then you need to provide a dispatch series, which could easily be defined so +that the battery always charges from the excess energy by the system and always +discharges when the load requires energy from the grid: + +.. ipython:: python + + dispatch = self_consumption_flow["Grid to load"] - self_consumption_flow["System to grid"] + + +.. note:: Note how the positive values represent power provided by the storage + system (i.e.: discharging) while negative values represent power into the + storage system (i.e.: charging) + + +The last step is to use the self-consumption power flow solution and the +dispatch series to solve the new power flow scenario: + +.. ipython:: python + + from pvlib.powerflow import self_consumption_ac_battery + + battery = fit_sam(parameters) + state, ac_battery_flow = self_consumption_ac_battery(self_consumption_flow, dispatch, battery, sam) + + +The new power flow results now include the flow series from system to +load/battery/grid, from battery to load and from grid to load/system: + +.. ipython:: python + + @savefig ac_battery_flow.png + ac_battery_flow.groupby(ac_battery_flow.index.hour).mean()[["System to load", "Battery to load", "Grid to load", "System to battery", "System to grid"]].plot.bar(stacked=True, legend=True, xlabel="Hour", ylabel="Power (W)", title="Average power flow") + @suppress + plt.close() + + +Self consumption with DC-connected battery +****************************************** + +The self-consumption with DC-connected battery is a completely different use +case. As opposed to the AC-connected battery use case, you cannot just take +into account the system's AC ouput and the load. Instead, you need to note that +the battery is connected to a single inverter, and the inverter imposes some +restrictions on how the power can flow from PV and from the battery. + +Hence, for this use case, you need to consider the PV power output (DC +generation) and the inverter model as well in order to be able to calculate the +inverter's output power (AC). + +The restrictions are as follow: + +- PV and battery are DC-connected to the same single inverter + +- Battery can only charge from PV + +- The PV generation (DC) is well-known + +- A custom dispatch series must be defined, but there is no guarantee that it + will be followed + + - The battery cannot charge with higher power than PV can provide + + - The battery cannot discharge with higher power if the inverter cannot + handle the total power provided by PV and the battery + + - The battery will be charged to avoid clipping AC power + +For this use case, you can start with the same self-consumption power flow +solution and dispatch series as in the AC battery use case: + +.. ipython:: python + + self_consumption_flow = self_consumption(mc.results.ac, load) + dispatch = self_consumption_flow["Grid to load"] - self_consumption_flow["System to grid"] + + +Then, you can solve the DC-connected inverter power flow by using the +``multi_dc_battery`` inverter model: + +.. ipython:: python + + from pvlib.powerflow import multi_dc_battery + + battery_datasheet = { + "chemistry": "LFP", + "mode": "DC", + "charge_efficiency": 0.98, + "discharge_efficiency": 0.98, + "min_soc_percent": 5, + "max_soc_percent": 95, + "dc_modules": 1, + "dc_modules_in_series": 1, + "dc_energy_wh": 5500, + "dc_nominal_voltage": 102.4, + "dc_max_power_w": 3400, + } + dc_battery_solution = multi_dc_battery( + v_dc=[mc.results.dc["v_mp"]], + p_dc=[mc.results.dc["p_mp"]], + inverter=mc.system.inverter_parameters, + battery_dispatch=dispatch, + battery_parameters=fit_sam(battery_datasheet), + battery_model=sam, + ) + + +The last step is to use the resulting DC-connected inverter power flow solution +to solve the new self-consumption power flow scenario: + +.. ipython:: python + + from pvlib.powerflow import self_consumption_dc_battery + + dc_battery_flow = self_consumption_dc_battery(dc_battery_solution, load) + + +The new power flow results now include the flow series from system to +load/battery/grid, from battery to load and from grid to load/system: + +.. ipython:: python + + @savefig dc_battery_flow.png + dc_battery_flow.groupby(dc_battery_flow.index.hour).mean()[["PV to load", "Battery to load", "Grid to load", "PV to battery", "System to grid"]].plot.bar(stacked=True, legend=True, xlabel="Hour", ylabel="Power (W)", title="Average power flow") + @suppress + plt.close() + + +.. note:: Since the system was intentionally designed to have a high DC-AC + ratio (resulting in clipping losses), the DC-connected battery allows the + inverter to avoid some of those clipping losses by charging the battery + instead. Hence, the "system-to-grid" power is actually extra power that the + AC-connected battery would not be able to provide for the same system + configuration. + + +Dispatching strategies +********************** + +While the self-consumption-with-battery use case imposes many restrictions to +the power flow, it still allows some flexibility to decide when to charge and +discharge the battery. + +An example of a different dispatching strategy is to set a well-defined +schedule that determines when the energy should flow into or out of the +battery. This is a common use case when you want to tie the battery flow to the +grid energy rates (i.e.: avoid discharging when the energy rates are low). + +For example, if you wanted to simulate an AC-connected battery in a system +where discharging should be avoided between 21:00 and 00:00, you could do that +by simply: + +.. ipython:: python + + dispatch = self_consumption_flow["Grid to load"] - self_consumption_flow["System to grid"] + dispatch.loc[dispatch.index.hour >= 21] = 0 + state, flow = self_consumption_ac_battery(self_consumption_flow, dispatch, battery, sam) + + @savefig flow_self_consumption_ac_battery_restricted_dispatch_load.png + flow.groupby(flow.index.hour).mean()[["System to load", "Battery to load", "Grid to load"]].plot.bar(stacked=True, legend=True, xlabel="Hour", ylabel="Power (W)", title="Average power flow to load") + @suppress + plt.close() + + +Energy flow +----------- + +You can convert the power series into energy series very easily: + +.. ipython:: python + + from pvlib.battery import power_to_energy + + energy_flow = power_to_energy(flow) + +And just as easily, you can use Pandas built-in methods to aggregate the energy +flow and plot the results: + +.. ipython:: python + + hourly_energy_flow = energy_flow.groupby(energy_flow.index.hour).sum() + @savefig energy_flow_self_consumption_ac_battery_load.png + hourly_energy_flow[["System to load", "Battery to load", "Grid to load"]].plot.bar(stacked=True, legend=True, xlabel="Hour", ylabel="Energy (Wh)", title="Total energy flow to load") + @suppress + plt.close() diff --git a/pvlib/battery.py b/pvlib/battery.py new file mode 100644 index 0000000000..4ec3039881 --- /dev/null +++ b/pvlib/battery.py @@ -0,0 +1,265 @@ +""" +This module contains functions for modeling batteries. +""" +from pandas import DataFrame + +try: + from PySAM.BatteryStateful import default as sam_default + from PySAM.BatteryStateful import new as sam_new + from PySAM.BatteryTools import battery_model_sizing as sam_sizing +except ImportError: # pragma: no cover + + def missing_nrel_pysam(*args, **kwargs): + raise ImportError( + "NREL's PySAM package required! (`pip install nrel-pysam`)" + ) + + sam_default = missing_nrel_pysam + sam_new = missing_nrel_pysam + sam_sizing = missing_nrel_pysam + + +def offset_to_hours(offset): + """ + Convert a Pandas offset into hours. + + Parameters + ---------- + offset : pd.tseries.offsets.BaseOffset + The input offset to convert. + + Returns + ------- + numeric + The resulting period, in hours. + """ + if offset.name == "H": + return offset.n + if offset.name == "T": + return offset.n / 60 + raise ValueError("Unsupported offset {}".format(offset)) + + +def power_to_energy(power): + """ + Converts a power series to an energy series. + + Assuming Watts as the input power unit, the output energy unit will be + Watt Hours. + + Parameters + ---------- + power : Series + The input power series. [W] + + Returns + ------- + The converted energy Series. [Wh] + """ + return power * offset_to_hours(power.index.freq) + + +def fit_boc(model): + """ + Determine the BOC model matching the given characteristics. + + Parameters + ---------- + datasheet : dict + The datasheet parameters of the battery. + + Returns + ------- + dict + The BOC parameters. + + Notes + ----- + This function does not really perform a fitting procedure. Instead, it just + calculates the model parameters that match the provided information from a + datasheet. + """ + params = { + "soc_percent": 50, + } + model.update(params) + return model + + +def boc(model, dispatch): + """ + Run a battery simulation with a provided dispatch series. Positive power + represents the power provided by the battery (i.e.: discharging) while + negative power represents power provided to the battery (i.e.: charging). + + The provided dispatch series is the goal/target power, but the battery may + not be able to provide or store that much energy given its characteristics. + This function will calculate how much power will actually flow from/into + the battery. + + Uses a simple "bag of Coulombs" model. + + Parameters + ---------- + model : dict + The initial BOC parameters. + dispatch : Series + The target power series. [W] + + Returns + ------- + export : dict + The final BOC parameters. + results : DataFrame + The resulting: + + - Power flow. [W] + - SOC. [%] + """ + min_soc = model.get("min_soc_percent", 10) + max_soc = model.get("max_soc_percent", 90) + factor = offset_to_hours(dispatch.index.freq) + + states = [] + current_energy = model["dc_energy_wh"] * model["soc_percent"] / 100 + max_energy = model["dc_energy_wh"] * max_soc / 100 + min_energy = model["dc_energy_wh"] * min_soc / 100 + + dispatch = dispatch.copy() + discharge_efficiency = model.get("discharge_efficiency", 1.0) + charge_efficiency = model.get("charge_efficiency", 1.0) + dispatch.loc[dispatch < 0] *= charge_efficiency + dispatch.loc[dispatch > 0] /= discharge_efficiency + + for power in dispatch: + if power > 0: + power = min(power, model["dc_max_power_w"]) + energy = power * factor + available = current_energy - min_energy + energy = min(energy, available) + power = energy / factor * discharge_efficiency + else: + power = max(power, -model["dc_max_power_w"]) + energy = power * factor + available = current_energy - max_energy + energy = max(energy, available) + power = energy / factor / charge_efficiency + current_energy -= energy + soc = current_energy / model["dc_energy_wh"] * 100 + states.append((power, soc)) + + results = DataFrame(states, index=dispatch.index, columns=["Power", "SOC"]) + + final_state = model.copy() + final_state["soc_percent"] = results.iloc[-1]["SOC"] + + return (final_state, results) + + +def fit_sam(datasheet): + """ + Determine the SAM BatteryStateful model matching the given characteristics. + + Parameters + ---------- + datasheet : dict + The datasheet parameters of the battery. + + Returns + ------- + dict + The SAM BatteryStateful parameters. + + Notes + ----- + This function does not really perform a fitting procedure. Instead, it just + calculates the model parameters that match the provided information from a + datasheet. + """ + chemistry = { + "LFP": "LFPGraphite", + } + model = sam_default(chemistry[datasheet["chemistry"]]) + sam_sizing( + model=model, + desired_power=datasheet["dc_max_power_w"] / 1000, + desired_capacity=datasheet["dc_energy_wh"] / 1000, + desired_voltage=datasheet["dc_nominal_voltage"], + ) + model.ParamsCell.initial_SOC = 50 + model.ParamsCell.minimum_SOC = datasheet.get("min_soc_percent", 10) + model.ParamsCell.maximum_SOC = datasheet.get("max_soc_percent", 90) + export = model.export() + del export["Controls"] + result = {} + result["sam"] = export + result["charge_efficiency"] = datasheet.get("charge_efficiency", 1.0) + result["discharge_efficiency"] = datasheet.get("discharge_efficiency", 1.0) + return result + + +def sam(model, power): + """ + Run a battery simulation with a provided dispatch series. Positive power + represents the power provided by the battery (i.e.: discharging) while + negative power represents power provided to the battery (i.e.: charging). + + The provided dispatch series is the goal/target power, but the battery may + not be able to provide or store that much energy given its characteristics. + This function will calculate how much power will actually flow from/into + the battery. + + Uses SAM's BatteryStateful model. + + Parameters + ---------- + model : dict + The initial SAM BatteryStateful parameters. + dispatch : Series + The target dispatch power series. [W] + + Returns + ------- + export : dict + The final SAM BatteryStateful parameters. + results : DataFrame + The resulting: + + - Power flow. [W] + - SOC. [%] + """ + battery = sam_new() + battery.ParamsCell.assign(model["sam"].get("ParamsCell", {})) + battery.ParamsPack.assign(model["sam"].get("ParamsPack", {})) + battery.Controls.replace( + { + "control_mode": 1, + "dt_hr": offset_to_hours(power.index.freq), + "input_power": 0, + } + ) + battery.setup() + battery.StateCell.assign(model["sam"].get("StateCell", {})) + battery.StatePack.assign(model["sam"].get("StatePack", {})) + + battery_dispatch = power.copy() + discharge_efficiency = model.get("discharge_efficiency", 1.0) + charge_efficiency = model.get("charge_efficiency", 1.0) + battery_dispatch.loc[power < 0] *= charge_efficiency + battery_dispatch.loc[power > 0] /= discharge_efficiency + + states = [] + for p in battery_dispatch: + battery.Controls.input_power = p / 1000 + battery.execute(0) + states.append((battery.StatePack.P * 1000, battery.StatePack.SOC)) + + results = DataFrame(states, index=power.index, columns=["Power", "SOC"]) + results.loc[results["Power"] < 0, "Power"] /= charge_efficiency + results.loc[results["Power"] > 0, "Power"] *= discharge_efficiency + export = battery.export() + del export["Controls"] + + state = model.copy() + state["sam"] = export + return (state, results) diff --git a/pvlib/powerflow.py b/pvlib/powerflow.py new file mode 100644 index 0000000000..cfe288dc15 --- /dev/null +++ b/pvlib/powerflow.py @@ -0,0 +1,207 @@ +""" +This module contains functions for simulating power flow. +""" +import numpy as np +from pandas import DataFrame + +from pvlib.inverter import _sandia_eff + + +def self_consumption(generation, load): + """ + Calculate the power flow for a self-consumption use case. It assumes the + system is connected to the grid. + + Parameters + ---------- + generation : Series + The AC generation profile. [W] + load : Series + The load profile. [W] + + Returns + ------- + DataFrame + The resulting power flow provided by the system and the grid into the + system, grid and load. [W] + """ + df = DataFrame(index=generation.index) + df["Grid to system"] = -generation.loc[generation < 0] + df["Grid to system"] = df["Grid to system"].fillna(0.0) + df["Generation"] = generation.loc[generation > 0] + df["Generation"] = df["Generation"].fillna(0.0) + df["Load"] = load + df["System to load"] = df[["Generation", "Load"]].min(axis=1, skipna=False) + df.loc[df["System to load"] < 0, "System to load"] = 0.0 + df["System to grid"] = df["Generation"] - df["System to load"] + df["Grid to load"] = df["Load"] - df["System to load"] + df["Grid"] = df[["Grid to system", "Grid to load"]].sum( + axis=1, skipna=False + ) + return df + + +def self_consumption_ac_battery(df, dispatch, battery, model): + """ + Calculate the power flow for a self-consumption use case with an + AC-connected battery and a custom dispatch series. It assumes the system is + connected to the grid. + + Parameters + ---------- + df : DataFrame + The self-consumption power flow solution. [W] + dispatch : Series + The dispatch series to use. + battery : dict + The battery parameters. + model : str + The battery model to use. + + Returns + ------- + DataFrame + The resulting power flow provided by the system, the grid and the + battery into the system, grid, battery and load. [W] + """ + final_state, results = model(battery, dispatch) + df = df.copy() + df["System to battery"] = -results["Power"] + df.loc[df["System to battery"] < 0, "System to battery"] = 0.0 + df["System to battery"] = df[["System to battery", "System to grid"]].min( + axis=1 + ) + df["System to grid"] -= df["System to battery"] + df["Battery to load"] = results["Power"] + df.loc[df["Battery to load"] < 0, "Battery to load"] = 0.0 + df["Battery to load"] = df[["Battery to load", "Grid to load"]].min(axis=1) + df["Grid to load"] -= df["Battery to load"] + df["Grid"] = df[["Grid to system", "Grid to load"]].sum( + axis=1, skipna=False + ) + return final_state, df + + +def self_consumption_dc_battery(dc_solution, load): + """ + Calculate the power flow for a self-consumption use case with a + DC-connected battery. It assumes the system is connected to the grid. + + Parameters + ---------- + dc_solution : DataFrame + The DC-connected inverter power flow solution. [W] + load : Series + The load profile. [W] + + Returns + ------- + DataFrame + The resulting power flow provided by the system, the grid and the + battery into the system, grid, battery and load. [W] + """ + df = self_consumption(dc_solution["AC power"], load) + df["Battery"] = df["Generation"] * dc_solution["Battery factor"] + df["Battery to load"] = df[["Battery", "System to load"]].min(axis=1) + df["Battery to grid"] = df["Battery"] - df["Battery to load"] + df["PV to battery"] = -dc_solution["Battery power flow"] + df.loc[df["PV to battery"] < 0, "PV to battery"] = 0.0 + df["PV to load"] = df["System to load"] - df["Battery to load"] + return df + + +def multi_dc_battery( + v_dc, p_dc, inverter, battery_dispatch, battery_parameters, battery_model +): + """ + Calculate the power flow for a self-consumption use case with a + DC-connected battery. It assumes the system is connected to the grid. + + Parameters + ---------- + v_dc : numeric + DC voltage input to the inverter. [V] + p_dc : numeric + DC power input to the inverter. [W] + inverter : dict + Inverter parameters. + battery_dispatch : Series + Battery power dispatch series. [W] + battery_parameters : dict + Battery parameters. + battery_model : str + Battery model. + + Returns + ------- + DataFrame + The resulting inverter power flow. + """ + dispatch = battery_dispatch.copy() + + # Limit charging to the available DC power + power_dc = sum(p_dc) + max_charging = -power_dc + charging_mask = dispatch < 0 + dispatch[charging_mask] = np.max([dispatch, max_charging], axis=0)[ + charging_mask + ] + + # Limit discharging to the inverter's maximum output power (approximately) + # Note this can revert the dispatch and charge when there is too much DC + # power (prevents clipping) + max_discharging = inverter['Paco'] - power_dc + discharging_mask = dispatch > 0 + dispatch[discharging_mask] = np.min([dispatch, max_discharging], axis=0)[ + discharging_mask + ] + + # Calculate the actual battery power flow + final_state, battery_flow = battery_model(battery_parameters, dispatch) + charge = -battery_flow['Power'].copy() + charge.loc[charge < 0] = 0 + discharge = battery_flow['Power'].copy() + discharge.loc[discharge < 0] = 0 + + # Adjust the DC power + ratios = [sum(power) / sum(power_dc) for power in p_dc] + adjusted_p_dc = [ + power - ratio * charge for (power, ratio) in zip(p_dc, ratios) + ] + final_dc_power = sum(adjusted_p_dc) + discharge + + # PV-contributed AC power + pv_ac_power = 0.0 * final_dc_power + for vdc, pdc in zip(v_dc, adjusted_p_dc): + array_contribution = ( + pdc / final_dc_power * _sandia_eff(vdc, final_dc_power, inverter) + ) + array_contribution[np.isnan(array_contribution)] = 0.0 + pv_ac_power += array_contribution + + # Battery-contributed AC power + vdc = inverter["Vdcmax"] / 2 + pdc = discharge + battery_ac_power = ( + pdc / final_dc_power * _sandia_eff(vdc, final_dc_power, inverter) + ) + battery_ac_power[np.isnan(battery_ac_power)] = 0.0 + + # Total AC power + total_ac_power = pv_ac_power + battery_ac_power + + # Limit output power (Sandia limits) + clipping = total_ac_power - inverter["Paco"] + clipping[clipping < 0] = 0 + limited_ac_power = total_ac_power - clipping + battery_factor = battery_ac_power / limited_ac_power + min_ac_power = -1.0 * abs(inverter["Pnt"]) + below_limit = final_dc_power < inverter["Pso"] + limited_ac_power[below_limit] = min_ac_power + + result = DataFrame(index=dispatch.index) + result["Battery power flow"] = battery_flow["Power"] + result["AC power"] = limited_ac_power + result["Clipping"] = clipping + result["Battery factor"] = battery_factor + return result diff --git a/pvlib/tests/conftest.py b/pvlib/tests/conftest.py index b3e9fcd5a1..6a691f293f 100644 --- a/pvlib/tests/conftest.py +++ b/pvlib/tests/conftest.py @@ -1,12 +1,16 @@ -from pathlib import Path +import os import platform import warnings +from functools import lru_cache +from functools import wraps +from importlib.resources import files +from pathlib import Path import pandas as pd -import os -from pkg_resources import parse_version import pytest -from functools import wraps +from numpy import nan +from numpy.random import uniform +from pkg_resources import parse_version import pvlib from pvlib.location import Location @@ -493,3 +497,102 @@ def sapm_module_params(): 'IXXO': 3.18803, 'FD': 1} return parameters + + +@pytest.fixture(scope="function") +def datasheet_battery_params(): + """ + Define some datasheet battery parameters for testing. + + The scope of the fixture is set to ``'function'`` to allow tests to modify + parameters if required without affecting other tests. + """ + parameters = { + "brand": "BYD", + "model": "HVS 5.1", + "width": 0.585, + "height": 0.712, + "depth": 0.298, + "weight": 91, + "chemistry": "LFP", + "mode": "AC", + "charge_efficiency": 0.96, + "discharge_efficiency": 0.96, + "min_soc_percent": 10, + "max_soc_percent": 90, + "dc_modules": 2, + "dc_modules_in_series": 2, + "dc_energy_wh": 5120, + "dc_nominal_voltage": 204, + "dc_max_power_w": 5100, + } + return parameters + + +@pytest.fixture(scope="session") +def residential_load_profile_generator(): + """ + Get a sample residential hourly load profile for testing purposes. + + Returns + ------- + The load profile. + """ + def profile_generator(index): + load = pd.Series(data=nan, index=index) + load[load.index.hour == 0] = 600 + load[load.index.hour == 4] = 400 + load[load.index.hour == 13] = 1100 + load[load.index.hour == 17] = 800 + load[load.index.hour == 21] = 1300 + load *= uniform(low=0.6, high=1.4, size=len(load)) + load = load.interpolate(method="spline", order=2) + load = load.bfill().ffill() + return load + return profile_generator + + +@pytest.fixture(scope="session") +def residential_model_chain(): + """ + Get a sample residential hourly generation profile for testing purposes. + + Returns + ------- + The generation profile. + """ + name = 'Madrid' + latitude = 40.31672645215922 + longitude = -3.674695061062714 + altitude = 603 + timezone = 'Europe/Madrid' + module = pvlib.pvsystem.retrieve_sam('SandiaMod')['Canadian_Solar_CS5P_220M___2009_'] + inverter = pvlib.pvsystem.retrieve_sam('cecinverter')['Powercom__SLK_1500__240V_'] + weather = pvlib.iotools.get_pvgis_tmy(latitude, longitude, map_variables=True)[0] + weather.index = pd.date_range( + start=weather.index[0].replace(year=2021), + end=weather.index[-1].replace(year=2021), + freq="H", + ) + weather.index = weather.index.tz_convert(timezone) + weather.index.name = "Timestamp" + location = pvlib.location.Location( + latitude, + longitude, + name=name, + altitude=altitude, + tz=timezone, + ) + mount = pvlib.pvsystem.FixedMount(surface_tilt=latitude, surface_azimuth=180) + temperature_model_parameters = pvlib.temperature.TEMPERATURE_MODEL_PARAMETERS['sapm']['open_rack_glass_glass'] + array = pvlib.pvsystem.Array( + mount=mount, + module_parameters=module, + modules_per_string=16, + strings=1, + temperature_model_parameters=temperature_model_parameters, + ) + system = pvlib.pvsystem.PVSystem(arrays=[array], inverter_parameters=inverter) + mc = pvlib.modelchain.ModelChain(system, location) + mc.run_model(weather) + return mc diff --git a/pvlib/tests/test_battery.py b/pvlib/tests/test_battery.py new file mode 100644 index 0000000000..bca1a2b910 --- /dev/null +++ b/pvlib/tests/test_battery.py @@ -0,0 +1,347 @@ +from itertools import product + +import pytest +from pandas import Series +from pandas import date_range +from pvlib.tests.conftest import requires_pysam +from pytest import approx +from pytest import mark +from pytest import raises + +from pvlib.battery import boc +from pvlib.battery import fit_boc +from pvlib.battery import fit_sam +from pvlib.battery import power_to_energy +from pvlib.battery import sam + +all_models = mark.parametrize( + "fit,run", + [ + (fit_boc, boc), + pytest.param(fit_sam, sam, marks=requires_pysam), + ], +) + +all_efficiencies = mark.parametrize( + "charge_efficiency,discharge_efficiency", + list(product([1.0, 0.98, 0.95], repeat=2)), +) + + +@mark.parametrize( + "power_value,frequency,energy_value", + [ + (1000, "H", 1000), + (1000, "2H", 2000), + (1000, "15T", 250), + (1000, "60T", 1000), + ], +) +def test_power_to_energy(power_value, frequency, energy_value): + """ + The function should be able to convert power to energy for different power + series' frequencies. + """ + index = date_range( + start="2022-01-01", + periods=10, + freq=frequency, + tz="Europe/Madrid", + closed="left", + ) + power = Series(power_value, index=index) + energy = power_to_energy(power) + assert approx(energy) == energy_value + + +def test_power_to_energy_unsupported_frequency(): + """ + When the power series' frequency is unsupported, the function raises an + exception. + """ + index = date_range( + start="2022-01-01", + periods=10, + freq="1M", + tz="Europe/Madrid", + closed="left", + ) + power = Series(1000, index=index) + with raises(ValueError, match=r"Unsupported offset"): + power_to_energy(power) + + +def test_fit_boc(datasheet_battery_params): + """ + The function returns a dictionary with the BOC model parameters. + """ + model = fit_boc(datasheet_battery_params) + assert model["soc_percent"] == 50.0 + + +@requires_pysam +def test_fit_sam(datasheet_battery_params): + """ + The function returns a dictionary with a `"sam"` key that must be + assignable to a SAM BatteryStateful model. Parameters like the nominal + voltage and the battery energy must also be properly inherited. + """ + from PySAM.BatteryStateful import new + + model = fit_sam(datasheet_battery_params) + battery = new() + battery.assign(model["sam"]) + assert approx(battery.value("nominal_voltage")) == 204 + assert approx(battery.value("nominal_energy")) == 5.12 + + +@requires_pysam +def test_fit_sam_controls(datasheet_battery_params): + """ + The controls group should not be exported as part of the battery state when + creating a new SAM battery. + """ + model = fit_sam(datasheet_battery_params) + assert "Controls" not in set(model.keys()) + + +@requires_pysam +def test_sam_controls(datasheet_battery_params): + """ + The controls group should not be exported as part of the battery state when + running a simulation with the SAM model. + """ + index = date_range( + start="2022-01-01", + periods=100, + freq="1H", + tz="Europe/Madrid", + closed="left", + ) + power = Series(1000.0, index=index) + state = fit_sam(datasheet_battery_params) + state, _ = sam(state, power) + assert "Controls" not in set(state.keys()) + + +@all_models +def test_model_return_index(datasheet_battery_params, fit, run): + """ + The returned index must match the index of the given input power series. + """ + index = date_range( + start="2022-01-01", + periods=100, + freq="1H", + tz="Europe/Madrid", + closed="left", + ) + power = Series(1000.0, index=index) + state = fit(datasheet_battery_params) + _, result = run(state, power) + assert all(result.index == power.index) + + +@all_models +def test_model_offset_valueerror(datasheet_battery_params, fit, run): + """ + When the power series' frequency is unsupported, the models must raise an + exception. + """ + index = date_range( + start="2022-01-01", + periods=10, + freq="1M", + tz="Europe/Madrid", + closed="left", + ) + power = Series(1000, index=index) + state = fit(datasheet_battery_params) + with raises(ValueError, match=r"Unsupported offset"): + run(state, power) + + +@all_models +@all_efficiencies +def test_model_dispatch_power( + datasheet_battery_params, + fit, + run, + charge_efficiency, + discharge_efficiency, +): + """ + The dispatch power series represents the power flow as seen from the + outside. As long as the battery is capable to provide sufficient power and + sufficient energy, the resulting power flow should match the provided + dispatch series independently of the charging/discharging efficiencies. + """ + index = date_range( + start="2022-01-01", + periods=10, + freq="1H", + tz="Europe/Madrid", + closed="left", + ) + dispatch = Series(100.0, index=index) + state = fit(datasheet_battery_params) + _, result = run(state, dispatch) + assert approx(result["Power"], rel=0.005) == dispatch + + +@all_models +@all_efficiencies +def test_model_soc_value( + datasheet_battery_params, + fit, + run, + charge_efficiency, + discharge_efficiency, +): + """ + The SOC should be updated according to the power flow and battery capacity. + """ + index = date_range( + start="2022-01-01", + periods=20, + freq="1H", + tz="Europe/Madrid", + closed="left", + ) + step_percent = 1.5 + step_power = step_percent / 100 * datasheet_battery_params["dc_energy_wh"] + power = Series(step_power, index=index) + + state = fit(datasheet_battery_params) + _, result = run(state, power) + assert ( + approx(result["SOC"].diff().iloc[-10:].mean(), rel=0.05) + == -step_percent / datasheet_battery_params["discharge_efficiency"] + ) + + +@all_models +def test_model_charge_convergence(datasheet_battery_params, fit, run): + """ + Charging only should converge into almost no power flow and maximum SOC. + """ + index = date_range( + start="2022-01-01", + periods=100, + freq="1H", + tz="Europe/Madrid", + closed="left", + ) + power = Series(-1000, index=index) + state = fit(datasheet_battery_params) + _, result = run(state, power) + assert result["Power"].iloc[-1] == approx(0, abs=0.01) + assert result["SOC"].iloc[-1] == approx(90, rel=0.01) + + +@all_models +def test_model_discharge_convergence(datasheet_battery_params, fit, run): + """ + Discharging only should converge into almost no power flow and minimum SOC. + """ + index = date_range( + start="2022-01-01", + periods=100, + freq="1H", + tz="Europe/Madrid", + closed="left", + ) + power = Series(1000, index=index) + state = fit(datasheet_battery_params) + _, result = run(state, power) + assert result["Power"].iloc[-1] == approx(0, abs=0.01) + assert result["SOC"].iloc[-1] == approx(10, rel=0.01) + + +@all_models +def test_model_chain(datasheet_battery_params, fit, run): + """ + The returning state must be reusable. Simulating continuously for ``2n`` + steps should be the same as splitting the simulation in 2 for ``n`` steps. + """ + index = date_range( + start="2022-01-01", + periods=100, + freq="1H", + tz="Europe/Madrid", + closed="left", + ) + power = Series(2000.0, index=index) + power.iloc[::2] = -2000.0 + half_length = int(len(power) / 2) + + continuous_state = fit(datasheet_battery_params) + continuous_state, continuous_power = run(continuous_state, power) + + split_state = fit(datasheet_battery_params) + split_state, split_power_0 = run(split_state, power[:half_length]) + split_state, split_power_1 = run(split_state, power[half_length:]) + split_power = split_power_0.append(split_power_1) + + assert split_state == continuous_state + assert approx(split_power) == continuous_power + + +@all_models +def test_model_equivalent_periods(datasheet_battery_params, fit, run): + """ + The results of a simulation with a 1-hour period should match those of a + simulation with a 60-minutes period. + """ + battery = fit(datasheet_battery_params) + hourly_index = date_range( + start="2022-01-01", + periods=50, + freq="1H", + tz="Europe/Madrid", + closed="left", + ) + minutely_index = date_range( + start="2022-01-01", + periods=50, + freq="60T", + tz="Europe/Madrid", + closed="left", + ) + + _, hourly = run(battery, Series(20.0, index=hourly_index)) + _, minutely = run(battery, Series(20.0, index=minutely_index)) + + assert approx(hourly) == minutely + + +@all_models +def test_model_equivalent_power_timespan(datasheet_battery_params, fit, run): + """ + Simulating with the same constant input power over the same time span but + with different frequency should yield similar results. + """ + battery = fit(datasheet_battery_params) + half_index = date_range( + start="2022-01-01 00:00", + end="2022-01-02 00:00", + freq="30T", + tz="Europe/Madrid", + closed="left", + ) + double_index = date_range( + start="2022-01-01 00:00", + end="2022-01-02 00:00", + freq="60T", + tz="Europe/Madrid", + closed="left", + ) + + _, half = run(battery, Series(20.0, index=half_index)) + _, double = run(battery, Series(20.0, index=double_index)) + + assert approx(half.iloc[-1]["SOC"], rel=0.001) == double.iloc[-1]["SOC"] + assert ( + approx(power_to_energy(half["Power"]).sum(), rel=0.001) + == power_to_energy(double["Power"]).sum() + ) diff --git a/pvlib/tests/test_powerflow.py b/pvlib/tests/test_powerflow.py new file mode 100644 index 0000000000..e5e9def83e --- /dev/null +++ b/pvlib/tests/test_powerflow.py @@ -0,0 +1,363 @@ +from numpy import array +from numpy import nan +from numpy.random import uniform +from pandas import Series +from pandas import Timestamp +from pandas import date_range +from pytest import approx +from pytest import mark + +from pvlib.battery import boc +from pvlib.battery import fit_boc +from pvlib.powerflow import multi_dc_battery +from pvlib.powerflow import self_consumption +from pvlib.powerflow import self_consumption_ac_battery + + +def gen_hourly_index(periods): + return date_range( + "2022-01-01", + periods=periods, + freq="1H", + tz="Europe/Madrid", + closed="left", + ) + + +@mark.parametrize( + "generation,load,flow", + [ + ( + 42, + 20, + { + "Generation": 42, + "Load": 20, + "System to load": 20, + "System to grid": 22, + "Grid to load": 0, + "Grid to system": 0, + "Grid": 0, + }, + ), + ( + 42, + 42, + { + "Generation": 42, + "Load": 42, + "System to load": 42, + "System to grid": 0, + "Grid to load": 0, + "Grid to system": 0, + "Grid": 0, + }, + ), + ( + 42, + 50, + { + "Generation": 42, + "Load": 50, + "System to load": 42, + "System to grid": 0, + "Grid to load": 8, + "Grid to system": 0, + "Grid": 8, + }, + ), + ( + -3, + 0, + { + "Generation": 0, + "Load": 0, + "System to load": 0, + "System to grid": 0, + "Grid to load": 0, + "Grid to system": 3, + "Grid": 3, + }, + ), + ( + -3, + 42, + { + "Generation": 0, + "Load": 42, + "System to load": 0, + "System to grid": 0, + "Grid to load": 42, + "Grid to system": 3, + "Grid": 45, + }, + ), + ], + ids=[ + "Positive generation with lower load", + "Positive generation with same load", + "Positive generation with higher load", + "Negative generation with zero load", + "Negative generation with positive load", + ], +) +def test_self_consumption(generation, load, flow): + """ + Check multiple conditions with well-known cases: + + - Excess generation must flow into grid + - Load must be fed with the system when possible, otherwise from grid + - Grid must provide energy for the system when required (i.e.: night hours) + - Negative values from the input generation are removed from the output + generation and added to the grid-to-system flow + """ + result = ( + self_consumption( + generation=Series([generation]), + load=Series([load]), + ) + .iloc[0] + .to_dict() + ) + assert approx(result) == flow + + +def test_self_consumption_sum(): + """ + The sum of the flows with respect to the system, load and grid must be + balanced. + """ + flow = self_consumption( + generation=Series(uniform(0, 1, 1000)), + load=Series(uniform(0, 1, 1000)), + ) + assert ( + approx(flow["Generation"]) + == flow["System to load"] + flow["System to grid"] + ) + assert ( + approx(flow["Grid"]) == flow["Grid to load"] + flow["Grid to system"] + ) + assert ( + approx(flow["Load"]) == flow["System to load"] + flow["Grid to load"] + ) + assert ( + approx(flow["Load"] + flow["Grid to system"]) + == flow["System to load"] + flow["Grid"] + ) + + +def test_self_consumption_ac_battery_sum(datasheet_battery_params): + """ + The sum of the flows with respect to the system, load, grid and battery + must be balanced. + """ + self_consumption_flow = self_consumption( + generation=Series(uniform(0, 1, 1000), index=gen_hourly_index(1000)), + load=Series(uniform(0, 1, 1000), index=gen_hourly_index(1000)), + ) + dispatch = ( + self_consumption_flow["Grid to load"] + - self_consumption_flow["System to grid"] + ) + _, flow = self_consumption_ac_battery( + self_consumption_flow, + dispatch=dispatch, + battery=fit_boc(datasheet_battery_params), + model=boc, + ) + assert ( + approx(flow["Generation"]) + == flow["System to load"] + + flow["System to battery"] + + flow["System to grid"] + ) + assert ( + approx(flow["Grid"]) == flow["Grid to load"] + flow["Grid to system"] + ) + assert ( + approx(flow["Load"]) + == flow["System to load"] + + flow["Battery to load"] + + flow["Grid to load"] + ) + + +@mark.parametrize( + "charge_efficiency,discharge_efficiency,efficiency", + [ + (1.0, 1.0, 1.0), + (0.97, 1.0, 0.97), + (1.0, 0.95, 0.95), + (0.97, 0.95, 0.97 * 0.95), + ], +) +def test_self_consumption_ac_battery_losses( + datasheet_battery_params, + residential_model_chain, + residential_load_profile_generator, + charge_efficiency, + discharge_efficiency, + efficiency, +): + """ + AC-DC conversion losses must be taken into account. + + With the BOC model these losses are easy to track if we setup a simulation + in which we make sure to begin and end with an "empty" battery. + """ + datasheet_battery_params["charge_efficiency"] = charge_efficiency + datasheet_battery_params["discharge_efficiency"] = discharge_efficiency + battery = fit_boc(datasheet_battery_params) + generation = residential_model_chain.results.ac.copy() + generation.iloc[:1000] = 0.0 + generation.iloc[-1000:] = 0.0 + load = residential_load_profile_generator(generation.index) + self_consumption_flow = self_consumption( + generation=generation, + load=load, + ) + dispatch = ( + self_consumption_flow["Grid to load"] + - self_consumption_flow["System to grid"] + ) + _, lossy = self_consumption_ac_battery( + self_consumption_flow, + dispatch=dispatch, + battery=battery, + model=boc, + ) + lossy = lossy.iloc[1000:] + assert lossy["Battery to load"].sum() / lossy[ + "System to battery" + ].sum() == approx(efficiency) + + +def test_self_consumption_nan_load(): + """ + When the load is unknown (NaN), the calculated flow to load should also be + unknown. + """ + flow = self_consumption( + generation=Series([1, -2, 3, -4]), + load=Series([nan, nan, nan, nan]), + ) + assert flow["System to load"].isna().all() + assert flow["Grid to load"].isna().all() + + +@mark.parametrize( + "inputs,outputs", + [ + ( + {"pv_power": 800, "dispatch": -400}, + { + "Battery power flow": -400, + "AC power": 400, + "Clipping": 0, + "Battery factor": 0, + }, + ), + ( + {"pv_power": 200, "dispatch": -600}, + { + "Battery power flow": -200, + "AC power": 0, + "Clipping": 0, + "Battery factor": nan, + }, + ), + ( + {"pv_power": 1200, "dispatch": 400}, + { + "Battery power flow": -200, + "AC power": 1000, + "Clipping": 0, + "Battery factor": 0, + }, + ), + ( + {"pv_power": 2000, "dispatch": 400}, + { + "Battery power flow": -850, + "AC power": 1000, + "Clipping": 150, + "Battery factor": 0, + }, + ), + ( + {"pv_power": 100, "dispatch": 400}, + { + "Battery power flow": 400, + "AC power": 500, + "Clipping": 0, + "Battery factor": 0.8, + }, + ), + ( + {"pv_power": 400, "dispatch": 1000}, + { + "Battery power flow": 600, + "AC power": 1000, + "Clipping": 0, + "Battery factor": 0.6, + }, + ), + ], + ids=[ + "Charging is prioritized over AC conversion while enough PV power is available", + "Charging is limited by the available PV power", + "Clipping forces battery to charge, even when dispatch is set to discharge", + "Clipping cannot be avoided if the battery is unable to handle too much input power", + "Battery discharge can be combined with PV power to provide higher AC output power", + "Battery discharge is limited to the inverter's nominal power", + ], +) +def test_multi_dc_battery(inputs, outputs, datasheet_battery_params): + """ + Test well-known cases of a multi-input (PV) and DC-connected battery + inverter. + + - Assume an ideal inverter with 100 % DC-AC conversion efficiency + - Assume an ideal battery with "infinite" capacity and 100 % efficiency + - The inverter must try to follow the custom dispatch series as close as + possible + - Battery can only charge from PV + - The inverter is smart enough to charge the battery in order to avoid + clipping losses while still maintaining the MPP tracking + """ + datasheet_battery_params.update( + { + "dc_energy_wh": 100000, + "dc_max_power_w": 850, + "charge_efficiency": 1.0, + "discharge_efficiency": 1.0, + } + ) + inverter = { + 'Vac': '240', + 'Pso': 0.0, + 'Paco': 1000.0, + 'Pdco': 1000.0, + 'Vdco': 325.0, + 'C0': 0.0, + 'C1': 0.0, + 'C2': 0.0, + 'C3': 0.0, + 'Pnt': 0.5, + 'Vdcmax': 600.0, + 'Idcmax': 12.0, + 'Mppt_low': 100.0, + 'Mppt_high': 600.0, + } + dispatch = array([inputs["dispatch"]]) + dispatch = Series(data=dispatch, index=gen_hourly_index(len(dispatch))) + result = multi_dc_battery( + v_dc=[array([400] * len(dispatch))], + p_dc=[array([inputs["pv_power"]])], + inverter=inverter, + battery_dispatch=dispatch, + battery_parameters=fit_boc(datasheet_battery_params), + battery_model=boc, + ) + assert approx(result.iloc[0].to_dict(), nan_ok=True) == outputs diff --git a/setup.py b/setup.py index 2abd339723..9c0769227a 100755 --- a/setup.py +++ b/setup.py @@ -47,13 +47,13 @@ 'requests-mock', 'pytest-timeout', 'pytest-rerunfailures', 'pytest-remotedata'] EXTRAS_REQUIRE = { - 'optional': ['cython', 'ephem', 'netcdf4', 'nrel-pysam', 'numba', + 'optional': ['cython', 'ephem', 'netcdf4', 'nrel-pysam >= 3.0.1', 'numba', 'pvfactors', 'siphon', 'statsmodels', 'cftime >= 1.1.1'], 'doc': ['ipython', 'matplotlib', 'sphinx == 4.5.0', 'pydata-sphinx-theme == 0.8.1', 'sphinx-gallery', 'docutils == 0.15.2', 'pillow', 'netcdf4', 'siphon', - 'sphinx-toggleprompt >= 0.0.5', 'pvfactors'], + 'sphinx-toggleprompt >= 0.0.5', 'pvfactors', 'nrel-pysam >= 3.0.1'], 'test': TESTS_REQUIRE } EXTRAS_REQUIRE['all'] = sorted(set(sum(EXTRAS_REQUIRE.values(), [])))