├── .github └── workflows │ ├── syntax-test.yml │ └── unit-test.yml ├── .gitignore ├── README.md ├── dev ├── how-to-update-pypi-package.txt └── weatherForecast │ ├── 71t_data_20240906.csv │ ├── Development - Weather Forecast.ipynb │ └── Test Weather Forecast.ipynb ├── docs ├── fieldperformance1.jpg ├── input.md ├── overview.jpg └── parameter.md ├── doper ├── __init__.py ├── computetariff.py ├── controller.py ├── data │ ├── __init__.py │ ├── comDummy.py │ ├── midas.py │ ├── tariff.py │ ├── watttime.py │ └── weatherForecast.py ├── examples │ ├── __init__.py │ ├── example.py │ └── example_test.py ├── models │ ├── __init__.py │ ├── basemodel.py │ ├── battery.py │ ├── genset.py │ ├── loadControl.py │ └── network.py ├── optWrapper.py ├── plotting.py ├── resources │ ├── __init__.py │ └── pvlib │ │ ├── README.md │ │ ├── __init__.py │ │ └── forecast.py ├── solvers │ ├── clean_setup_solvers.sh │ ├── setup_solvers.bat │ └── setup_solvers.sh ├── utility.py └── wrapper.py ├── examples ├── DOPER Example - Battery Storage.ipynb ├── DOPER Example - CO2 Minimization.ipynb ├── DOPER Example - EV Fleet.ipynb ├── DOPER Example - Fuel Outage.ipynb ├── DOPER Example - Generator.ipynb ├── DOPER Example - Load Control.ipynb ├── DOPER Example - Power Flow.ipynb ├── DOPER Example - Real-time Price.ipynb ├── DOPER Template.ipynb ├── DOPER_Template.py ├── __init__.py ├── test code - bug fixes.py ├── test code - multinode.py ├── test code - singlenode.py └── test code.py ├── license.txt ├── requirements.txt ├── setup.py └── test ├── __init__.py ├── run_tests.sh ├── test_basemodel.py ├── test_basemodel_multinode.py ├── test_battery.py ├── test_battery_multinode.py ├── test_genset.py ├── test_genset_multinode.py ├── test_install.py ├── test_loadControl.py ├── test_loadControl_multinode.py └── test_powerflow.py /.github/workflows/syntax-test.yml: -------------------------------------------------------------------------------- 1 | name: Syntax 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.8"] 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v3 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install pylint 21 | pip install . 22 | - name: Analyzing the code with pylint 23 | run: | 24 | cd doper 25 | pylint $(find . -name "*.py" -not -path "*/interface/*") -------------------------------------------------------------------------------- /.github/workflows/unit-test.yml: -------------------------------------------------------------------------------- 1 | name: UnitTests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.8"] 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v3 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install pytest 21 | pip install . 22 | - name: Test with pytest 23 | run: | 24 | pytest -rx 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | 3 | # Python compiled files # 4 | ######################### 5 | *.pyc 6 | build/* 7 | dist/* 8 | *egg*/* 9 | 10 | # JSON files # 11 | ############## 12 | *.json 13 | 14 | # ipynb checkpoints # 15 | ##################### 16 | *checkpoint.ipynb 17 | **/.ipynb_checkpoints/* 18 | 19 | 20 | # solvers # 21 | ########### 22 | doper/solvers/Linux64/* 23 | doper/solvers/Windows64/* 24 | 25 | # test result files # 26 | ##################### 27 | /examples/test_results/* 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Distributed Optimal and Predictive Energy Resources (DOPER) 2 | 3 | ![Actions Status](https://github.com/LBNL-ETA/DOPER/workflows/Syntax/badge.svg) 4 | ![Actions Status](https://github.com/LBNL-ETA/DOPER/workflows/UnitTests/badge.svg) 5 | 6 | #### Predictive Control Solution for Distributed Energy Resources and Integrated Energy Systems 7 | ---------------------------------------------------------------------------------------- 8 | 9 | This package is an optimal control framework for behind-the-meter battery storage, Photovoltaic generation, and other Distributed Energy Resources. 10 | 11 | ## General 12 | The DOPER controller is implemented as a [Model Predictive Control](https://facades.lbl.gov/model-predictive-controls) (MPC), where an internal mathematical model is evaluated and solved to a global optimum, at each controller evaluation. The inputs are forecasts of weather data, Photovoltaic (PV) generation, and load for the upcoming 24 hours. The objective is to maximize the revenue (by minimizing the total energy cost) for the asset owner, while providing additional services to the grid. The grid services explored include time-varying pricing schemes and the response to critical periods in the grid. DOPER can optimally control the battery by charging it during periods with excess generation and discharging it during the critical afternoon hours (i.e. [Duck Curve](https://en.wikipedia.org/wiki/Duck_curve)). This active participation in the grid management helps to maximize the amount of renewables that can be connected to the grid. 13 | ![Overview](https://github.com/LBNL-ETA/DOPER/blob/master/docs/overview.jpg) 14 | The DOPER controller was evaluated in annual simulations and in a field test conducted at the Flexgrid test facility at LBNL. The following plot shows example results for a field test conducted at LBNL's [FLEXGRID](https://flexlab.lbl.gov/flexgrid) facility. The base load without energy storage is shown in turquoise, the computed optimal result in purple, and the measured load in yellow. 15 | ![Performance](https://github.com/LBNL-ETA/DOPER/blob/master/docs/fieldperformance1.jpg) 16 | Peak demand and total energy cost was significantly reduced. Annual simulations indicate cost savings of up to 35 percent, with a payback time of about 6 years. This is significantly shorter than the lifetime of typical batteries. 17 | 18 | Further information can be found in the full project report listed in the [Cite](https://github.com/LBNL-ETA/DOPER#cite) section. 19 | 20 | ## Getting Started 21 | The following link permits users to clone the source directory containing the [DOPER](https://github.com/LBNL-ETA/DOPER) package and then locally install with the `pip install .` command. 22 | 23 | Alternatively, DOPER can be directly installed with `pip install git+https://github.com/LBNL-ETA/DOPER`. 24 | 25 | Note that the [CBC](https://github.com/coin-or/Cbc) solver will be automatically installed and set as default solver for Linux and Windows systems. On MacOS please install the desired solver manually. For CBC please follow the installation instructions [here](https://github.com/coin-or/Cbc#binaries), and point the `solver_path` argument of DOPER to the `cbc` executable on your system. 26 | 27 | ## Use 28 | 29 | Standard usage of the DOPER library follows the sequence of steps outlined in the example here: 30 | 31 | #### 1. Instatiate Model & Define Objective 32 | First import DOPER sub-modules 33 | ```python 34 | from doper import DOPER, get_solver, standard_report 35 | from doper.models.basemodel import base_model, convert_model_dynamic, plot_dynamic 36 | from doper.models.battery import add_battery 37 | import doper.example as example 38 | ``` 39 | Then create an instance of a control model, which consists of a Pyomo Model and function describing the model's objective function. The control model takes system parameters and optimization (time-series) inputs as inputs and will be used by the DOPER wrapper to optimize the model. 40 | 41 | Here, we define the Pyomo model using the DOPER `base_model` method. We then use the DOPER battery model `add_battery` method to add battery constraints to our model. 42 | 43 | The objective function here simply includes electricity tariff energy and demand charges, as well as revenue from electricity exports. The objective function can be modified to suit your application's requirements. 44 | 45 | ```python 46 | from pyomo.environ import Objective, minimize 47 | 48 | def control_model(inputs, parameter): 49 | model = base_model(inputs, parameter) 50 | model = add_battery(model, inputs, parameter) 51 | 52 | def objective_function(model): 53 | return model.sum_energy_cost * parameter['objective']['weight_energy'] \ 54 | + model.sum_demand_cost * parameter['objective']['weight_demand'] \ 55 | - model.sum_export_revenue * parameter['objective']['weight_export']] 56 | model.objective = Objective(rule=objective_function, sense=minimize, doc='objective function') 57 | return model 58 | ``` 59 | 60 | #### 2. Define System Parameters 61 | The system `parameter` object is a dictionary containing generally static values describing the system -- in particular the portfolio of available DERs and their performance characteristics. The DOPER sublibrary `example` contains functions to generate default parameter files, as well as methods to add generic technologies, such as battery storage in the example below. 62 | 63 | ```python 64 | # load generic project parameter from example 65 | parameter = example.default_parameter() 66 | 67 | # add battery resources to existing parameter dict 68 | parameter = example.parameter_add_battery(parameter) 69 | ``` 70 | 71 | Please refer to documentation on [Defining Parameter Object](https://github.com/LBNL-ETA/DOPER/blob/master/docs/parameter.md) for details on all model options, settings, and DER technologies that can be defined within the `parameter` object. 72 | 73 | 74 | The `parameter` input contains the following entries: 75 | * `controller`: settings for the optimization horizon, timestep, and location of solvers 76 | * `objective`: weights that can be applied when constructing the optimization objective function 77 | * `system`: binary values indicating whether each DER or load asset is enabled or disabled 78 | * `site`: general characteristics of the site, interconnection constraints, and regulation requirements 79 | * `network`: for multi-node models, this optional field includes data to characterize the network topology, map loads and resources to each node, and characterize the lines connecting nodes 80 | * `tariff`: energy and power rates. Tariff time periods are provided in the separate time-series input 81 | * `batteries`: a list of battery dicts with technical characteristics of each battery resource. Note: this is necessary because we have enabled `battery` in the 'system' field. 82 | * `gensets`: a list of genset dicts with technical characteristics of each generator resource. Note: this is necessary because we have enabled `genset` in the 'system' field. 83 | * `load_control`: a list of load control dicts with technical characteristics of each load control resource. Note: this is necessary because we have enabled `load_control` in the 'system' field. 84 | 85 | #### 3. Define Optimization (Time-series) Input 86 | The optimization also needs timeseries data to indicate the values for time-variable model parameters (e.g. building load or PV generation). In application, these will often be linked to forecast models, but for this example, we simply load timeseries data from the `example_inputs` function. 87 | 88 | ```python 89 | data = example.ts_inputs(parameter, load='B90', scale_load=150, scale_pv=100) 90 | ``` 91 | 92 | The time-series input should be in the form of a pandas dataframe, indexed by timestamp, may include columns that contains the following data: 93 | * `load_demand`: system load profile [kW] 94 | * `oat`: outside air temperature [C] 95 | * `tariff_energy_map`: mapping of time-period to tariff TOU period 96 | * `tariff_power_map`: mapping of time-period to tariff TOU power demand period 97 | * `tariff_energy_export_map`: mapping of time-period to energy export price 98 | * `utility_rtp`: real-time price utility rate [$/kWh] (optional, default = 0) 99 | * `utility_rtp_export`: real-time price export rate [$/kWh] (optional, default = utility_rtp) 100 | * `grid_available`: binary indicating whether grid connection is available 101 | * `fuel_available`: binary indicating whether fuel import is available 102 | * `grid_co2_intensity`: current CO2 intensity of grid imports [kg/kWh] 103 | * `generation_pv`: output of all PV connected to system [kW] 104 | * `battery_N_avail`: binary indicating whether battery N is connected to system (e.g. for EVs) 105 | * `battery_N_demand`: external discharging load for battery N (e.g. for EVs) [kW] 106 | * `load_shed_potential_N`: volume of load sheddable under load control resource N [kW] 107 | * `external_gen`: generation available from generic external generation source [kW] 108 | 109 | The required columns will vary between single-node and multi-node models. Please refer to documentation on [Defining Time-series Input Object](https://github.com/LBNL-ETA/DOPER/blob/master/docs/input.md) for details on required and optional fields that may be passed to the model using the timeseries input object. 110 | 111 | #### 4. Optimize Model 112 | With these settings selected, we can create an instance of DOPER, using the `control_model`, `parameter`, and output instructions (`pyomo_to_pandas`) function. With the DOPER model instantiated, we can solve using the `.do_optimization` method. 113 | ```python 114 | # Define the path to the solver executable 115 | solver_path = get_solver('cbc') 116 | 117 | # Initialize DOPER 118 | smartDER = DOPER(model=control_model, 119 | parameter=parameter, 120 | solver_path=solver_path) 121 | 122 | # Conduct optimization 123 | res = smartDER.do_optimization(data) 124 | 125 | # Get results 126 | duration, objective, df, model, result, termination, parameter = res 127 | print(standard_report(res)) 128 | ``` 129 | 130 | `standard_report` reports high level metrics for the optimization. 131 | ```output 132 | Solver CBC 133 | Duration [s] 1.97 134 | Objective [$] 8380.34 3726.89 (Total Cost) 135 | Cost [$] 4875.04 (Energy) 3505.3 (Demand) 136 | Revenue [$] 0.0 (Export) 0.0 (Regulation) 137 | ``` 138 | 139 | #### 5. Requesting Custom Timeseries Ouputs 140 | Once DOPER solves the given Pyomo model, it will generate a pandas dataframe of timeseries parameter and variable data as part of its ouput. By default, a standard list of timeseries data will be generated. However, if one has specific instructions on which Pyomo values to pass to DOPER outputs, an optional argument `output_list` can be passed when declaring a new instance of DOPER. It consists of a `data` label to identify the variable within the optimzaiton model, `df_label` to specify the output column name, and the optional `index` argument if additional indices (besides time) are required. In the example below it can be seen that the model includes multiple batteries, indexed by the variable `battery`. Note that the `df_label` needs to include the string formatter `%s` to pass the custom index, e.g., battery index, to the output dataframe. 141 | 142 | The optional argument is structured as a list of dictionaries structured, like the following: 143 | ```python 144 | my_output_list = [ 145 | { 146 | 'data': '{var or param name within pyomo model}', 147 | 'df_label': '{Label for your output column}' 148 | }, 149 | { 150 | 'data': 'battery_charge_grid_power', 151 | 'df_label': 'Battery Charging Power (Battery %s) [kW]', 152 | 'index': 'batteries' 153 | } 154 | ] 155 | ``` 156 | Then use this list when initializing a DOPER instance 157 | ```python 158 | # Define the path to the solver executable 159 | solver_path = get_solver('cbc') 160 | 161 | # Initialize DOPER 162 | smartDER = DOPER(model=control_model, 163 | parameter=parameter, 164 | solver_path=solver_path, 165 | output_list=my_output_list) 166 | 167 | # Proceed with solving optimization as described in above step 168 | ``` 169 | 170 | 171 | ## Example 172 | To illustrate the DOPER functionality, example Jupyter notebooks can be found [here](https://github.com/LBNL-ETA/DOPER/blob/master/examples/). 173 | 174 | [Example 1](https://github.com/LBNL-ETA/DOPER/blob/master/examples/DOPER%20Example%20-%20Battery%20Storage.ipynb) shows the optimal dispatch of two stantionary batteries for a medium-sized office building with behind-the-meter photovoltaic system. 175 | 176 | [Example 2](https://github.com/LBNL-ETA/DOPER/blob/master/examples/DOPER%20Example%20-%20EV%20Fleet.ipynb) shows the optimal dispatch of a fleet of three electric vehicles for a medium-sized office building with behind-the-meter photovoltaic system. EV control uses the same technology model as stationary battery storage, but includes additional inputs defining the availability and external load from vehicle use. 177 | 178 | [Example 3](https://github.com/LBNL-ETA/DOPER/blob/master/examples/DOPER%20Example%20-%20Generator.ipynb) shows the optimal dispatch of two generators for a medium-sized office building with behind-the-meter photovoltaic system. The example illustrates the use of generator assets for both blue-sky and outage constrained operation. 179 | 180 | [Example 4](https://github.com/LBNL-ETA/DOPER/blob/master/examples/DOPER%20Example%20-%20Load%20Control.ipynb) shows the optimal dispatch of load control within a medium-sized office building with behind-the-meter photovoltaic system. The example illustrates the use of load shedding for both economic objectives, as well as to increase survivability during grid outage. 181 | 182 | 183 | ## License 184 | Distributed Optimal and Predictive Energy Resources (DOPER) Copyright (c) 2019, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy). All rights reserved. 185 | 186 | If you have questions about your rights to use or distribute this software, please contact Berkeley Lab's Intellectual Property Office at IPO@lbl.gov. 187 | 188 | NOTICE. This Software was developed under funding from the U.S. Department of Energy and the U.S. Government consequently retains certain rights. As such, the U.S. Government has been granted for itself and others acting on its behalf a paid-up, nonexclusive, irrevocable, worldwide license in the Software to reproduce, distribute copies to the public, prepare derivative works, and perform publicly and display publicly, and to permit other to do so. 189 | 190 | ## Cite 191 | To cite the DOPER package, please use: 192 | 193 | ```bibtex 194 | @article{gehbauer2021photovoltaic, 195 | title={Photovoltaic and Behind-the-Meter Battery Storage: Advanced Smart Inverter Controls and Field Demonstration}, 196 | author={Gehbauer, Christoph and Mueller, Joscha and Swenson, Tucker and Vrettos, Evangelos}, 197 | year={2021}, 198 | journal={California Energy Commission}, 199 | url={https://escholarship.org/uc/item/62w660v3} 200 | } 201 | ``` -------------------------------------------------------------------------------- /dev/how-to-update-pypi-package.txt: -------------------------------------------------------------------------------- 1 | Prerequisistes: 2 | 3 | Have twine (pip install twine) 4 | Make an account for PyPi website 5 | Be added to the project on PyPi 6 | 7 | Steps to updating DOPER Package: 8 | 9 | 1. Change the version number in doper/__init__.py file. 10 | For more on the setup.py file, check out this link: https://packaging.python.org/tutorials/packaging-projects#configuring-metadata) 11 | 12 | 2. In the bash shell, run "python setup.py sdist bdist_wheel" (this will create a build and dist folder which contains info on the packages that we will publish) 13 | 14 | 3. In the bash shell, run "tar tzf dist/DOPER-[VERSION NUMBER HERE].tar.gz" to check the contents of the package. If it looks normal, proceed. Just make sure nothing is egrgiously wrong. 15 | 16 | 4. In the bash shell, run "twine check dist/*" and make sure you pass the tests. 17 | 18 | 5. Finally, run 'twine upload dist/*', fill out your username and password when prompted, and we are done! 19 | -------------------------------------------------------------------------------- /docs/fieldperformance1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LBNL-ETA/DOPER/4ef83739d60984d2491ddf9eff6e83c9b91351d9/docs/fieldperformance1.jpg -------------------------------------------------------------------------------- /docs/input.md: -------------------------------------------------------------------------------- 1 | ## Defining timeseries input 2 | 3 | This input to the optimization must be in the form of a pandas dataframe, indexed by a datetime. For each optimization, the model requires a time-series data frame containing known or predict values for the optimization time horizon, including fields such as building loads, energy prices, and resource availability (e.g. PV output profiles) 4 | 5 | Depending on the configuration of your model, and the DER assets included, some fields within the timeseries data are required, while others are optional. Descriptions of each of these field types are outlined below for (1) Single node, and (2) Multi-Node models. 6 | 7 | --- 8 | 9 | #### 1. Single Node Input 10 | 11 | ##### 1.1. Required Fields 12 | 13 | The following fields must be included in the time-series data for single node models. Without these fields, the model with return an error when trying to optimize. The names of the items listed below must match exactly with columns in 14 | 15 | * `load_demand`: system load profile [kW] 16 | * `oat`: outside air temperature [C] 17 | * `tariff_energy_map`: mapping of time-period to tariff TOU period 18 | * `tariff_power_map`: mapping of time-period to tariff TOU power demand period 19 | * `tariff_energy_export_map`: mapping of time-period to energy export price 20 | * `generation_pv`: output of all PV connected to system [kW]. If no PV is present, set values to 0. 21 | 22 | ##### 1.2. Optional Fields 23 | 24 | The following fields may be included if certain features of the model (e.g. electricity or fuel outages) are to be included in the solution. If these fields are not found in the input, default values are used. 25 | 26 | * `grid_available`: binary indicating whether grid connection is available. If not provided, defaults to 1-grid always available 27 | * `fuel_available`: binary indicating whether fuel import is available. If not provided, defaults to 1-fuel import always available 28 | * `grid_co2_intensity`: current CO2 intensity of grid imports [kg/kWh]. If not provided, defaults to 0. If carbon values are included in the optimization objective, a warning will be raised 29 | 30 | ##### 1.3. DER-specific Fields 31 | 32 | The following fields may be included if certain DER assets and features are to be used during the model. Note, some items are optional and some required for a given DER asset type, as indicated below. 33 | 34 | * `battery_N_avail`: binary indicating whether battery N is connected to system. Optional input to model an EV as a battery. If missing, asset is treated as a stationary battery 35 | * `battery_N_demand`: external discharging load for battery N [kW]. Optional input to model an EV as a battery. If missing, asset is treated as a stationary battery 36 | * `load_shed_potential_X`: volume of load sheddable under load control resource with the name field set to 'X' [kW]. If load control is enabled for a model, the corresponding load_shed_potential field must be included in the input data. Number of load control assets must match the number of shed potential profiles. Missing items will generate an error. 37 | * `external_gen`: generation available from generic external generation source [kW]. If `parameter['system']['external_gen']` is set to `True`, then this field must be included in the input data 38 | 39 | --- 40 | 41 | #### 2. Multi-Node Input 42 | 43 | ##### 2.1. Required Fields 44 | 45 | The following fields must be included in the time-series data for multi-node models. Without these fields, the model with return an error when trying to optimize. The names of the items listed below must match exactly with columns in 46 | 47 | * `oat`: outside air temperature [C] 48 | * `tariff_energy_map`: mapping of time-period to tariff TOU period 49 | * `tariff_power_map`: mapping of time-period to tariff TOU power demand period 50 | * `tariff_energy_export_map`: mapping of time-period to energy export price 51 | 52 | ##### 2.2. Optional Fields 53 | 54 | The following fields may be included if certain features of the model (e.g. electricity or fuel outages) are to be included in the solution. If these fields are not found in the input, default values are used. 55 | 56 | * `grid_available`: binary indicating whether grid connection is available. If not provided, defaults to 1-grid always available 57 | * `fuel_available`: binary indicating whether fuel import is available. If not provided, defaults to 1-fuel import always available 58 | * `grid_co2_intensity`: current CO2 intensity of grid imports [kg/kWh]. If not provided, defaults to 0. If carbon values are included in the optimization objective, a warning will be raised 59 | 60 | ##### 2.3. Node-specifc Fields 61 | 62 | The following fields should be included for some or all nodes of a multi-node model. The name of these fields are user-selected. However, the names chosen for these fields must match those used in the corresponding sections of `parameter['network']['nodes']`. 63 | 64 | * `{node load_id}`: users can define a load profile assigned to a node. The nodal load_id used in the `parameter` file must be found in the timeseries data. Users can assign multiple load_id items to a single node. In such a case, all load_id items must be found in the timeseries data. 65 | * `{node pv_id}`: users can define a pv profile assigned to a node. The nodal pv_id used in the `parameter` file must be found in the timeseries data. Users can assign multiple pv_id items to a single node. In such a case, all pv_id items must be found in the timeseries data. 66 | 67 | ##### 2.3. DER-specific Fields 68 | 69 | The following fields may be included if certain DER assets and features are to be used during the model. Note, some items are optional and some required for a given DER asset type, as indicated below. 70 | 71 | * `battery_N_avail`: binary indicating whether battery N is connected to system. Optional input to model an EV as a battery. If missing, asset is treated as a stationary battery 72 | * `battery_N_demand`: external discharging load for battery N [kW]. Optional input to model an EV as a battery. If missing, asset is treated as a stationary battery 73 | * `load_shed_potential_X`: volume of load sheddable under load control resource with the name field set to 'X' [kW]. If load control is enabled for a model, the corresponding load_shed_potential field must be included in the input data. Number of load control assets must match the number of shed potential profiles. Missing items will generate an error. 74 | * `external_gen`: generation available from generic external generation source [kW]. If `parameter['system']['external_gen']` is set to `True`, then this field must be included in the input data 75 | 76 | #### 3. Other Cases 77 | 78 | ##### 3.1. Real-Time Price Tariff 79 | 80 | Users can generate a control model that utilizes a real-time (or time-variable) utility tariff by providing specific fields in the input data frame. The following fields can be used to do so: 81 | 82 | * `utility_rtp`: the utility price [$/kWh] of electricity imports. The default value of this field is 0, so if an RTP price profile is not defined, it will be neglected by the model. 83 | * `utility_rtp_export`: users may also define a separate RTP profile for export prices. If this input in not provided, export price will use the default value of `utility_rtp`. 84 | 85 | In order for RTP prices to be considered within the optimization, the following items must be included in the objective function of the model. Futhermore, any cost variables related to time-of-use (TOU) energy or demand charges should likely be omitted from the objective function to prevent double-counting of electricity costs. 86 | 87 | * `model.sum_rtp_cost`: this term includes the total costs of electricity import during the optimization horizon and should be scaled by `parameter['objective']['weight_rtp']`: the user-assigned weight to RTP energy costs. 88 | * `model.sum_rtp_export_revenuet`: this term includes the total revenuce of electricity import during the optimization horizon and should be subtracted from the total objective function. It should also be scaled by `parameter['objective']['weight_rtp']`: the user-assigned weight to RTP energy costs/revenues. -------------------------------------------------------------------------------- /docs/overview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LBNL-ETA/DOPER/4ef83739d60984d2491ddf9eff6e83c9b91351d9/docs/overview.jpg -------------------------------------------------------------------------------- /doper/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is the DOPER main module. 3 | """ 4 | 5 | 6 | from .utility import * 7 | from .wrapper import * 8 | from .computetariff import * 9 | from .data.tariff import get_tariff 10 | 11 | __version__ = "2.1.2" 12 | -------------------------------------------------------------------------------- /doper/computetariff.py: -------------------------------------------------------------------------------- 1 | # Distributed Optimal and Predictive Energy Resources (DOPER) Copyright (c) 2019 2 | # The Regents of the University of California, through Lawrence Berkeley 3 | # National Laboratory (subject to receipt of any required approvals 4 | # from the U.S. Dept. of Energy). All rights reserved. 5 | 6 | """"Distributed Optimal and Predictive Energy Resources 7 | Compute tariff module. 8 | """ 9 | 10 | # pylint: disable=invalid-name, too-many-arguments, redefined-outer-name 11 | 12 | def compute_periods(df, tariff, parameter, return_tariff=True, weekday_map=False, warnings=True): 13 | """compute tariff periods""" 14 | 15 | daytypes = True 16 | if not 'weekday' in tariff[tariff['seasons_map'][tariff['seasons'][0]]]['hours']: 17 | if warnings: 18 | print('WARNING: No daytype in tariff. Using weekday-only legancy implementaiton.') 19 | daytypes = False 20 | daytype_map = {0: 'weekday', 1: 'weekday', 2: 'weekday', 3: 'weekday', 4: 'weekday', 21 | 5: 'weekend', 6: 'weekend'} # Monday=0, Sunday=6 22 | if weekday_map: 23 | if warnings: 24 | print('WARNING: Using external daytype mapping.') 25 | daytypes = False 26 | 27 | tz_df = parameter['site']['input_timezone'] 28 | tz_local = parameter['site']['local_timezone'] 29 | # Shift to local time 30 | df.index = df.index.tz_localize(f'Etc/GMT{-1*tz_df:+d}') \ 31 | .tz_convert(tz_local) 32 | season = tariff['seasons_map'][tariff['seasons'][df.index[0].month]] 33 | # Generate tariff map for selected season 34 | tariff_map = {} 35 | tariff_map['energy'] = tariff[season]['energy'] 36 | tariff_map['demand'] = tariff[season]['demand'] 37 | tariff_map['demand_coincident'] = tariff[season]['demand_coincident'] 38 | parameter['tariff'].update(tariff_map) 39 | # Build table 40 | df['hour'] = df.index.hour 41 | if daytypes: 42 | df['tariff_energy_map'] = \ 43 | df.index.map(lambda x: tariff[season]['hours'][daytype_map[x.weekday()]][x.hour]) 44 | elif weekday_map: 45 | df['tariff_energy_map'] = \ 46 | df[['weekday','hour']].apply(lambda x: \ 47 | tariff[season]['hours'][daytype_map[x[0]]][x[1]], axis=1) 48 | else: 49 | df['tariff_energy_map'] = \ 50 | [tariff[season]['hours'][h] for h in df.index.hour] 51 | df['tariff_power_map'] = df['tariff_energy_map'] 52 | df['tariff_energy_export_map'] = 0 53 | df['tariff_regup'] = 0 54 | df['tariff_regdn'] = 0 55 | df.index = df.index.tz_convert(f'Etc/GMT{-1*tz_df:+d}') \ 56 | .tz_localize(None) 57 | if return_tariff: 58 | return df, parameter 59 | return df 60 | -------------------------------------------------------------------------------- /doper/controller.py: -------------------------------------------------------------------------------- 1 | # Distributed Optimal and Predictive Energy Resources (DOPER) Copyright (c) 2019 2 | # The Regents of the University of California, through Lawrence Berkeley 3 | # National Laboratory (subject to receipt of any required approvals 4 | # from the U.S. Dept. of Energy). All rights reserved. 5 | 6 | """"Distributed Optimal and Predictive Energy Resources 7 | Controller module. 8 | """ 9 | 10 | # pylint: disable=import-error, redefined-outer-name, invalid-name 11 | 12 | import pandas as pd 13 | from pyomo.environ import Objective, minimize 14 | 15 | from .wrapper import DOPER 16 | from .utility import get_solver, standard_report 17 | from .basemodel import base_model, convert_base_model 18 | from .batterymodel import add_battery, convert_battery 19 | from .example import example_parameter_evfleet, example_inputs_evfleet2 20 | 21 | def control_model(inputs, parameter): 22 | """control model""" 23 | 24 | model = base_model(inputs, parameter) 25 | model = add_battery(model, inputs, parameter) 26 | 27 | if 'weight_degradation' in parameter['objective']: 28 | print('WARNING: No "degradation" in objective function.') 29 | def objective_function(model): 30 | """objective function""" 31 | 32 | return model.sum_energy_cost * parameter['objective']['weight_energy'] \ 33 | + model.sum_demand_cost * parameter['objective']['weight_demand'] \ 34 | + model.sum_export_revenue * parameter['objective']['weight_export'] \ 35 | + model.sum_regulation_revenue * parameter['objective']['weight_regulation'] 36 | model.objective = Objective(rule=objective_function, sense=minimize, doc='objective function') 37 | return model 38 | 39 | def pyomo_to_pandas(model, parameter): 40 | """convert pyomo to pandas""" 41 | 42 | df = convert_base_model(model, parameter) 43 | df = pd.concat([df, convert_battery(model, parameter)], axis=1) 44 | return df 45 | 46 | if __name__ == '__main__': 47 | parameter = example_parameter_evfleet() 48 | data = example_inputs_evfleet2(parameter) 49 | del parameter['objective']['weight_degradation'] 50 | 51 | smartDER = DOPER(model=control_model, 52 | parameter=parameter, 53 | solver_path=get_solver('cbc', solver_dir='solvers')) 54 | res = smartDER.do_optimization(data, tee=False) 55 | duration, objective, df, model, result, termination, parameter = res 56 | print(standard_report(res)) 57 | -------------------------------------------------------------------------------- /doper/data/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is the DOPER data module. 3 | """ 4 | -------------------------------------------------------------------------------- /doper/data/comDummy.py: -------------------------------------------------------------------------------- 1 | import time 2 | import json 3 | from fmlc.baseclasses import eFMU 4 | 5 | class communication_dummy(eFMU): 6 | def __init__(self): 7 | self.input = {'input-data': None, 'config': None, 'timeout': None} 8 | self.output = {'output-data': None, 'duration': None} 9 | 10 | def compute(self): 11 | st = time.time() 12 | 13 | self.output['output-data'] = {} 14 | config = json.loads(self.input['config']) 15 | if isinstance(config, dict): 16 | self.output['output-data'].update(config) 17 | else: 18 | self.output['output-data'] = config 19 | 20 | self.output['duration'] = time.time() - st 21 | return 'Done.' 22 | -------------------------------------------------------------------------------- /doper/data/midas.py: -------------------------------------------------------------------------------- 1 | # Advanced Fenestration Controller (AFC) Copyright (c) 2023, The 2 | # Regents of the University of California, through Lawrence Berkeley 3 | # National Laboratory (subject to receipt of any required approvals 4 | # from the U.S. Dept. of Energy). All rights reserved. 5 | 6 | """"Advanced Fenestration Controller 7 | Midas module. 8 | 9 | Examples taken from: 10 | https://github.com/morganmshep/MIDAS-Python-Repository/tree/main 11 | """ 12 | 13 | # pylint: disable=redefined-outer-name, invalid-name, too-many-arguments, duplicate-code 14 | 15 | import os 16 | import json 17 | import base64 18 | import requests 19 | import pandas as pd 20 | 21 | TIMEOUT = 5 # seconds 22 | 23 | urls = {} 24 | urls['register_url'] = 'https://midasapi.energy.ca.gov/api/registration' 25 | urls['login_url'] = 'https://midasapi.energy.ca.gov/api/token' 26 | urls['rin_url'] = 'https://midasapi.energy.ca.gov/api/valuedata?signaltype=' 27 | urls['value_url'] = 'https://midasapi.energy.ca.gov/api/valuedata?id=' 28 | 29 | def check_response(response): 30 | """check if request returned successfully""" 31 | if response.status_code == 200: 32 | return response 33 | print(f'Error: {response.status_code}') 34 | with open('response_error.html', 'w', encoding='utf8') as f: 35 | f.write(response.text) 36 | return None 37 | 38 | def str_to_64(s): 39 | """convert str to 64bit""" 40 | encodedBytes = base64.b64encode(s.encode("utf-8")) 41 | return str(encodedBytes, "utf-8") 42 | 43 | class Midas: 44 | """client for Midas emission forecasting""" 45 | def __init__(self, registered=True, username='freddo', password='the_frog!', 46 | email='freddo@frog.org', org='freds world'): 47 | 48 | self.registered = registered 49 | self.username = username 50 | self.password = password 51 | self.email = email 52 | self.org = org 53 | 54 | self.token = None 55 | 56 | # Do registration if not already registered 57 | if not self.registered: 58 | self.register() 59 | 60 | def register(self): 61 | """register new user""" 62 | registration_info = {"organization": str_to_64(self.org), 63 | "username": str_to_64(self.username), 64 | "password": str_to_64(self.password), 65 | "emailaddress": str_to_64(self.email), 66 | "fullname": str_to_64(self.username)} 67 | headers = {"Content-Type": "application/json"} 68 | response = requests.post(urls['register_url'], 69 | data=json.dumps(registration_info), 70 | headers=headers, 71 | timeout=TIMEOUT) 72 | if check_response(response): 73 | print(response.text) 74 | else: 75 | raise ValueError('The registration was not successful. Check the response_error.html') 76 | 77 | def login(self): 78 | """login to Midas""" 79 | credentials = f'{self.username}:{self.password}' 80 | credentials_encodedBytes = base64.b64encode(credentials.encode("utf-8")) 81 | headers = {b'Authorization': b'BASIC ' + credentials_encodedBytes} 82 | response = requests.get(urls['login_url'], 83 | headers=headers, 84 | timeout=TIMEOUT) 85 | if check_response(response): 86 | self.token = response.headers['Token'] 87 | 88 | def get_rins(self, signaltype=0): 89 | """list all Rate Identification Numbers (RINs) 90 | 91 | Inputs: 92 | signaltype (int): 0-all, 1-Tariffs, 2-GHG, 3-Flex Alerts. 93 | """ 94 | if not self.token: 95 | self.login() 96 | headers = {'accept': 'application/json', 'Authorization': "Bearer " + self.token} 97 | url = f'{urls["rin_url"]}{str(signaltype)}' 98 | response = requests.get(url, headers=headers, timeout=TIMEOUT) 99 | if check_response(response): 100 | return json.loads(response.text) 101 | return None 102 | 103 | def get_values(self, rateID='USCA-FLEX-FXFC-0000', queryType='alldata'): 104 | """get Rate Identification Number (RIN) values 105 | 106 | Inputs: 107 | rateID (str): The rate ID, see ret_rins function. 108 | queryType (str): alldata or realtime. 109 | """ 110 | headers = {'accept': 'application/json', 'Authorization': "Bearer " + self.token} 111 | url = f'{urls["value_url"]}{rateID}&querytype={queryType}' 112 | response = requests.get(url, headers=headers, timeout=TIMEOUT) 113 | if check_response(response): 114 | return json.loads(response.text) 115 | return None 116 | 117 | if __name__ == "__main__": 118 | # setup credentials 119 | creds = {'registered': True} 120 | if os.path.exists('creds_midas.json'): 121 | with open('creds_midas.json', encoding='utf8') as f: 122 | creds.update(json.loads(f.read())) 123 | 124 | # initialize client 125 | client = Midas(**creds) 126 | 127 | # get regions 128 | data = client.get_rins(signaltype=0) 129 | data = pd.DataFrame(data) 130 | data.to_csv('all_rins.csv') 131 | print(data) 132 | 133 | # get values 134 | data = client.get_values(rateID='USCA-FLEX-FXFC-0000') # flexalert forecast 135 | print(pd.DataFrame(data['ValueInformation'])) 136 | 137 | data = client.get_values(rateID='USCA-SGIP-SGFC-PGE') # pg&e emission forecast 138 | print(pd.DataFrame(data['ValueInformation'])) 139 | -------------------------------------------------------------------------------- /doper/data/watttime.py: -------------------------------------------------------------------------------- 1 | # Advanced Fenestration Controller (AFC) Copyright (c) 2023, The 2 | # Regents of the University of California, through Lawrence Berkeley 3 | # National Laboratory (subject to receipt of any required approvals 4 | # from the U.S. Dept. of Energy). All rights reserved. 5 | 6 | """"Advanced Fenestration Controller 7 | Watttime module. 8 | """ 9 | 10 | # pylint: disable=redefined-outer-name, invalid-name, too-many-arguments, duplicate-code 11 | 12 | import os 13 | import json 14 | import requests 15 | import pandas as pd 16 | from requests.auth import HTTPBasicAuth 17 | 18 | TIMEOUT = 5 # seconds 19 | 20 | urls = {} 21 | urls['register_url'] = 'https://api2.watttime.org/v2/register' 22 | urls['login_url'] = 'https://api2.watttime.org/v2/login' 23 | urls['password_url'] = 'https://api2.watttime.org/v2/password' 24 | urls['region_url'] = 'https://api2.watttime.org/v2/ba-from-loc' 25 | urls['list_url'] = 'https://api2.watttime.org/v2/ba-access' 26 | urls['index_url'] = 'https://api2.watttime.org/index' 27 | urls['data_url'] = 'https://api2.watttime.org/v2/data' 28 | urls['historical_url'] = 'https://api2.watttime.org/v2/historical' 29 | urls['forecast_url'] = 'https://api2.watttime.org/v2/forecast' 30 | 31 | def check_response(response): 32 | """check if request returned successfully""" 33 | if response.status_code == 200: 34 | return response 35 | print(f'Error: {response.status_code}') 36 | with open('response_error.html', 'w', encoding='utf8') as f: 37 | f.write(response.text) 38 | return None 39 | 40 | class Watttime: 41 | """client for Watttime emission forecasting""" 42 | def __init__(self, registered=True, username='freddo', password='the_frog!', 43 | email='freddo@frog.org', org='freds world'): 44 | 45 | self.registered = registered 46 | self.username = username 47 | self.password = password 48 | self.email = email 49 | self.org = org 50 | 51 | self.token = None 52 | 53 | # Do registration if not already registered 54 | if not self.registered: 55 | self.register() 56 | 57 | def register(self): 58 | """register new user""" 59 | params = {'username': self.username, 60 | 'password': self.password, 61 | 'email': self.email, 62 | 'org': self.org} 63 | response = requests.post(urls['register_url'], json=params, timeout=TIMEOUT) 64 | if check_response(response): 65 | print(response.text) 66 | else: 67 | raise ValueError('The registration was not successful. Check the response_error.html') 68 | 69 | def login(self): 70 | """login to Watttime""" 71 | response = requests.get(urls['login_url'], 72 | auth=HTTPBasicAuth(self.username, self.password), 73 | timeout=TIMEOUT) 74 | if check_response(response): 75 | self.token = response.json()['token'] 76 | 77 | def get_regions(self, all_regions=False): 78 | """list all regions 79 | 80 | Inputs: 81 | all_regions (bool): Flag to query all available regions. 82 | """ 83 | if not self.token: 84 | self.login() 85 | headers = {'Authorization': f'Bearer {self.token}'} 86 | params = {'all': str(all_regions)} 87 | response = requests.get(urls['list_url'], headers=headers, params=params, timeout=TIMEOUT) 88 | if check_response(response): 89 | return json.loads(response.text) 90 | return None 91 | 92 | def get_emission_historic(self, ba='CAISO_NORTH', 93 | cur_dir=os.path.dirname(os.path.realpath('__file__'))): 94 | """get historic emissions 95 | 96 | Inputs: 97 | ba (str): Balancing authority. 98 | cur_dir (str): Directory to store downloaded zip file. 99 | """ 100 | if not self.token: 101 | self.login() 102 | headers = {'Authorization': f'Bearer {self.token}'} 103 | params = {'ba': str(ba)} 104 | response = requests.get(urls['historical_url'], headers=headers, 105 | params=params, timeout=TIMEOUT) 106 | if check_response(response): 107 | file_path = os.path.join(cur_dir, f'{ba}_historical.zip') 108 | with open(file_path, 'wb') as f: 109 | f.write(response.content) 110 | 111 | def get_emission_forecast(self, ba='CAISO_NORTH', extended_forecast=False): 112 | """get emission forecast 113 | 114 | Inputs: 115 | ba (str): Balancing authority. 116 | extended_forecast (bool): Provides an extended 72 hour instead of 24 hour forecast. 117 | """ 118 | if not self.token: 119 | self.login() 120 | headers = {'Authorization': f'Bearer {self.token}'} 121 | params = {'ba': str(ba), 122 | 'extended_forecast': str(extended_forecast)} 123 | response = requests.get(urls['forecast_url'], headers=headers, 124 | params=params, timeout=TIMEOUT) 125 | if check_response(response): 126 | return json.loads(response.text) 127 | return None 128 | 129 | if __name__ == "__main__": 130 | # setup credentials 131 | creds = {'registered': True} 132 | if os.path.exists('creds_watttime.json'): 133 | with open('creds_watttime.json', encoding='utf8') as f: 134 | creds.update(json.loads(f.read())) 135 | 136 | # initialize client 137 | client = Watttime(**creds) 138 | 139 | # get regions 140 | data = client.get_regions(all_regions=False) 141 | print(data) 142 | 143 | # get historical 144 | client.get_emission_historic() 145 | 146 | # get forecast 147 | data = client.get_emission_forecast() 148 | if data: 149 | print(pd.DataFrame(data['forecast'])) 150 | -------------------------------------------------------------------------------- /doper/data/weatherForecast.py: -------------------------------------------------------------------------------- 1 | # Distributed Optimal and Predictive Energy Resources (DOPER) Copyright (c) 2019 2 | # The Regents of the University of California, through Lawrence Berkeley 3 | # National Laboratory (subject to receipt of any required approvals 4 | # from the U.S. Dept. of Energy). All rights reserved. 5 | 6 | """"Distributed Optimal and Predictive Energy Resources 7 | Weather forecast module. 8 | """ 9 | 10 | import io 11 | import re 12 | import os 13 | import sys 14 | import time 15 | import json 16 | import pygrib 17 | import requests 18 | import warnings 19 | import traceback 20 | import numpy as np 21 | import pandas as pd 22 | import urllib.request 23 | import datetime as dtm 24 | 25 | warnings.filterwarnings('ignore', message='The forecast module algorithms and features are highly experimental.') 26 | warnings.filterwarnings('ignore', message="The HRRR class was deprecated in pvlib 0.9.1 and will be removed in a future release.") 27 | 28 | try: 29 | root = os.path.dirname(os.path.abspath(__file__)) 30 | from ..resources.pvlib.forecast import HRRR 31 | except: 32 | root = os.getcwd() 33 | sys.path.append(os.path.join(root, '..', 'doper')) 34 | from resources.pvlib.forecast import HRRR 35 | 36 | from fmlc.baseclasses import eFMU 37 | 38 | datetime_mask = "20[0-9][0-9]-[0-1][0-9]-[0-3][0-9] [0-2][0-9]:[0-5][0-9]:[0-5][0-9]" 39 | 40 | FC_TO_PVLIV_MAP = { 41 | '9:Total Cloud Cover:% (instant):lambert:atmosphere:level 0 -': 'Total_cloud_cover_entire_atmosphere', 42 | '7:2 metre temperature:K (instant):lambert:heightAboveGround:level 2 m': 'Temperature_height_above_ground', 43 | 'wind_speed_u': 0, 44 | 'wind_speed_v': 0, 45 | 'Low_cloud_cover_low_cloud': 0, 46 | 'Medium_cloud_cover_middle_cloud': 0, 47 | 'High_cloud_cover_high_cloud': 0, 48 | 'Pressure_surface': 0, 49 | 'Wind_speed_gust_surface': 0 50 | } 51 | 52 | def download_latest_hrrr(lat, lon, dt, hour, tmp_dir='', 53 | debug=False, store_file=False): 54 | ''' 55 | Documentation and API: https://nomads.ncep.noaa.gov/gribfilter.php?ds=hrrr_2d 56 | Full HRRR files: https://nomads.ncep.noaa.gov/pub/data/nccf/com/hrrr/prod/ 57 | Historic HRRR files: https://www.ncei.noaa.gov/data/rapid-refresh/access/ 58 | ''' 59 | 60 | # make url for download (from API) 61 | url = f'https://nomads.ncep.noaa.gov/cgi-bin/filter_hrrr_2d.pl?dir=%2F' 62 | fname = f'hrrr.t{dt.strftime("%H")}z.wrfsfcf{hour:02}.grib2' 63 | url += f'hrrr.{dt.strftime("%Y%m%d")}%2Fconus&file={fname}' 64 | url += f'&var_TCDC=on&var_TMP=on&all_lev=on&subregion=&' 65 | # url += f'&var_TCDC=on&var_TMP=on&lev_2_m_above_ground=on&subregion=&' 66 | url += f'toplat={int(lat+1)}&leftlon={int(lon-1)}&rightlon={int(lon+1)}&bottomlat={int(lat-1)}' 67 | 68 | # download forecast 69 | fname = os.path.join(tmp_dir, fname) 70 | try: 71 | if store_file: 72 | urllib.request.urlretrieve(url, fname) 73 | else: 74 | fname = requests.get(url).content 75 | return fname 76 | except Exception as e: 77 | if debug: 78 | print(url) 79 | print(e) 80 | return None 81 | 82 | def get_nearest_data(lat, lon, fname): 83 | 84 | # open file 85 | grib = pygrib.open(fname) 86 | 87 | # get grib locations 88 | lat_grib = grib[1].latlons()[0] 89 | lon_grib = grib[1].latlons()[1] 90 | 91 | # calculate distances 92 | abslat = np.abs(lat_grib - lat) 93 | abslon = np.abs(lon_grib - lon) 94 | c = np.maximum(abslon, abslat) 95 | 96 | # select nearest 97 | x, y = np.where(c == np.min(c)) 98 | x, y = x[0], y[0] 99 | 100 | # get data 101 | res = {'lat': lat_grib[x, y], 102 | 'lon': lon_grib[x, y], 103 | 'x': x, 'y': y} 104 | for g in grib: 105 | name = str(g).split(':fcst time')[0] 106 | res[name] = g.values[x, y] 107 | 108 | return res 109 | 110 | def get_hrrr_forecast(lat, lon, dt, tz='America/Los_Angeles', max_hour=16, 111 | tmp_dir='', debug=False, store_file=False, forecast_age=2): 112 | """ 113 | Utility function to dowlnoad NOAA's HRRR forecast data. 114 | 115 | Inputs 116 | ------ 117 | lat (float): latitude of location. 118 | lon (float): longitude of location. 119 | dt (pd.Timestamp): current date time. 120 | tz (str): time zone of location. 121 | max_hour (int): forecast horizon. 122 | tmp_dir (str): temporary directory for HRRR downloads. 123 | debug (bool): debug flag. 124 | store_file (bool): store HRRR downloads. 125 | forecast_age (int): age of HRRR forecast, in hours. 126 | """ 127 | 128 | # convert timestep to hourly 129 | dt = dt.replace(minute=0, second=0, microsecond=0, nanosecond=0).tz_localize(None) 130 | 131 | # convert local time to utc 132 | dt_utc = dt.tz_localize(tz).tz_convert('UTC').tz_localize(None) 133 | # NOTE: use forecast from X hours ago since NOAA is usually behind 134 | dt_utc = dt_utc - pd.DateOffset(hours=forecast_age) 135 | 136 | # bug in pygrib 2.1.5 does not allow object as input 137 | store_file = True 138 | 139 | res = {} 140 | for h in range(forecast_age, max_hour+forecast_age+1): 141 | st = time.time() 142 | 143 | # get latest hrrr file 144 | fcObj = download_latest_hrrr(lat, lon, dt_utc, h, 145 | tmp_dir=tmp_dir, 146 | debug=debug, 147 | store_file=store_file) 148 | 149 | if fcObj: 150 | # make readable (pygrib 2.1.5 should support but doesn't) 151 | if not store_file: 152 | binary_io = io.BytesIO(fcObj) 153 | buffer_io = io.BufferedReader(binary_io) 154 | 155 | # determine nearest gridpoint 156 | r = get_nearest_data(lat, lon, fcObj) 157 | 158 | # FIXME: deleting file manually due to pygrib 2.1.5 bug 159 | try: 160 | if not debug: 161 | os.remove(fcObj) 162 | except: 163 | pass 164 | else: 165 | # no forecast received 166 | r = {} 167 | 168 | r['duration'] = time.time()-st 169 | 170 | # add to output 171 | res[dt_utc+pd.DateOffset(hours=h)] = r 172 | 173 | # make dataframe 174 | res = pd.DataFrame(res).transpose() 175 | res.index = pd.to_datetime(res.index).tz_localize('UTC').tz_convert(tz).tz_localize(None) 176 | 177 | return res 178 | 179 | class weather_forecaster(eFMU): 180 | ''' 181 | This class gathers the weather forecasts at one station on a specified frequency. It uses pvlib to 182 | reference NOAA's HRRR forecast model, and returns the temperature and solar irradiation values. It 183 | requires a configuration file that specifies the station and sampling frequency. 184 | ''' 185 | 186 | def __init__(self): 187 | ''' 188 | Reads the config information and initializes the forecaster. 189 | 190 | Input 191 | ----- 192 | config (dict): The configuration file. Example fiven in "get_default_config". 193 | ''' 194 | self.input = {'input-data': None, 'config': None, 'timeout': None} 195 | self.output = {'output-data':None, 'duration':None} 196 | 197 | self.forecaster = None 198 | 199 | def check_data(self, data, ranges): 200 | for k, r in ranges.items(): 201 | if k in data.columns: 202 | if not (data[k].min() >= r[0]): 203 | self.msg += f'ERROR: Entry "{k}" is out of range {data[k].min()} >= {r[0]}.\n' 204 | if not (data[k].max() <= r[1]): 205 | self.msg += f'ERROR: Entry "{k}" is out of range {data[k].max()} <= {r[1]}.\n' 206 | else: 207 | self.msg += f'ERROR: Entry "{k}" is missing.\n' 208 | 209 | def compute(self, now=None): 210 | ''' 211 | Gathers forecasts for the specified station. Returns either the forecast and error messages. 212 | 213 | Input 214 | ----- 215 | now (str): String representation of the local time the forecast is requested for. None (defualt) 216 | falls back to using the user's current clock time. 217 | 218 | Return 219 | ------ 220 | data (pd.DataFrame): The forecast as data frame with date time as index. Empty data frame on error. 221 | msg (str): Error messages or empty string when no errors. 222 | ''' 223 | 224 | self.msg = '' 225 | st = time.time() 226 | 227 | # initialize 228 | self.config = self.input['config'] 229 | 230 | # prepare inputs 231 | tz = self.config['tz'] 232 | if now == None: 233 | now = pd.to_datetime(time.time(), unit='s') 234 | now = now.replace(minute=0, second=0, microsecond=0, nanosecond=0) 235 | now = now.tz_localize('UTC').tz_convert(tz) 236 | start_time = pd.to_datetime(now) 237 | final_time = start_time + pd.Timedelta(hours=self.config['horizon']) 238 | 239 | # get forecast 240 | self.forecast = pd.DataFrame() 241 | try: 242 | if self.config['source'] == 'noaa_hrrr': 243 | if not self.forecaster: 244 | 245 | # setup forecaster 246 | self.forecaster = get_hrrr_forecast 247 | 248 | # setup pvlib processor 249 | self.pvlib_processor = HRRR() 250 | self.pvlib_processor.set_location(start_time.tz, 251 | self.config['lat'], 252 | self.config['lon']) 253 | 254 | # tmp dir 255 | if not os.path.exists(self.config['tmp_dir']): 256 | os.mkdir(self.config['tmp_dir']) 257 | 258 | # get forecast 259 | self.forecast = self.forecaster(self.config['lat'], 260 | self.config['lon'], 261 | start_time, 262 | tz=tz, 263 | max_hour=self.config['horizon'], 264 | tmp_dir=self.config['tmp_dir'], 265 | debug=self.config['debug']) 266 | 267 | elif self.config['source'] == 'json': 268 | 269 | # read forecast form json 270 | self.forecast = pd.read_json(io.StringIO(self.input['input-data'])).sort_index() 271 | 272 | else: 273 | 274 | # method not implemented 275 | self.msg += f'ERROR: Source option "{self.config["source"]}" not valid.\n' 276 | 277 | # check index 278 | for i, ix in enumerate(self.forecast.index): 279 | if not bool(re.match(datetime_mask, str(ix))): 280 | self.msg += f'ERROR: External forecast date format incorrect "{ix}" at position {i}.\n' 281 | 282 | # check and convert to numeric 283 | for c in self.forecast.columns: 284 | self.forecast[c] = pd.to_numeric(self.forecast[c], errors='coerce') 285 | if self.forecast.isnull().values.any(): 286 | self.msg += f'ERROR: NaNs in forecast at: {self.forecast.index[self.forecast.isnull().any(axis=1)]}.\n' 287 | 288 | # check index 289 | if self.msg == '': 290 | self.forecast.index = pd.to_datetime(self.forecast.index, format='%Y-%m-%d %H:%M:%S') 291 | if not (len(self.forecast)-1) == self.config['horizon']: 292 | self.msg += f'ERROR: Forecast length {len(self.forecast)-1} is not horizon {self.config["horizon"]}.\n' 293 | if not self.forecast.index[0] == start_time.tz_localize(None): 294 | self.msg += f'ERROR: Forecast start "{self.forecast.index[0]}" not ' \ 295 | + f'start_time "{start_time.tz_localize(None)}".\n' 296 | if not self.forecast.index[-1] == final_time.tz_localize(None): 297 | self.msg += f'ERROR: Forecast final "{self.forecast.index[-1]}" not ' \ 298 | + f'final_time "{final_time.tz_localize(None)}".\n' 299 | if self.forecast.resample('1h').asfreq().isnull().values.any(): 300 | self.msg += f'ERROR: Missing timestamp in forecast.\n' 301 | 302 | except Exception as e: 303 | self.msg += f'ERROR: {e}\n\n{traceback.format_exc()}\n' 304 | self.forecast = pd.DataFrame() 305 | 306 | # process data 307 | self.data = pd.DataFrame() 308 | if self.msg == '': 309 | try: 310 | # check forecast 311 | self.check_data(self.forecast, self.config['forecast_cols']) 312 | 313 | # process 314 | if self.msg == '': 315 | # direct pvlib form forecast 316 | direct = {k: v for k, v in FC_TO_PVLIV_MAP.items() if isinstance(v, str)} 317 | self.pvlib_fc = self.forecast[direct.keys()].copy(deep=True).rename(columns=direct) 318 | # computed from forecast 319 | computed = {k: v for k, v in FC_TO_PVLIV_MAP.items() if not isinstance(v, str)} 320 | for k, v in computed.items(): 321 | self.pvlib_fc[k] = v 322 | self.pvlib_fc.index = self.pvlib_fc.index.tz_localize(tz) 323 | # duplicate last beacuse of bug in pvlib 324 | self.pvlib_fc.loc[self.pvlib_fc.index[-1]+pd.DateOffset(hours=1), :] = self.pvlib_fc.iloc[-1] 325 | self.data = self.pvlib_processor.process_data(self.pvlib_fc) 326 | self.data = self.data.loc[self.pvlib_fc.index[:-1]] 327 | self.data.index = self.data.index.tz_localize(None) 328 | self.data = self.data[self.config['output_cols'].keys()] 329 | except Exception as e: 330 | self.msg += f'ERROR: {e}.\n\n{traceback.format_exc()}\n' 331 | self.data = pd.DataFrame() 332 | 333 | # check data 334 | if self.msg == '' and self.config['output_cols']: 335 | self.check_data(self.data, self.config['output_cols']) 336 | 337 | # return 338 | self.init = False 339 | if self.config['json_return']: 340 | self.output['output-data'] = self.data.to_json() 341 | else: 342 | self.output['output-data'] = self.data 343 | self.output['duration'] = time.time() - st 344 | 345 | if self.msg == '': 346 | return 'Done.' 347 | return self.msg 348 | 349 | def get_default_config(): 350 | config = {} 351 | # config['name'] = 'Berkeley' 352 | config['lat'] = 37.8715 353 | config['lon'] = -122.2501 354 | config['tz'] = 'US/Pacific' 355 | config['horizon'] = 16 356 | config['tmp_dir'] = 'tmp' 357 | config['debug'] = False 358 | config['source'] = 'noaa_hrrr' 359 | config['json_return'] = True 360 | config['forecast_cols'] = { 361 | '9:Total Cloud Cover:% (instant):lambert:atmosphere:level 0 -': [0, 100], 362 | '7:2 metre temperature:K (instant):lambert:heightAboveGround:level 2 m': [200, 400] 363 | } 364 | config['output_cols'] = {'temp_air': [-50, 50], 365 | 'ghi': [0, 1000], 366 | 'dni': [0, 1500], 367 | 'dhi': [0, 1000]} 368 | return config 369 | 370 | if __name__ == '__main__': 371 | 372 | # get config 373 | config = get_default_config() 374 | 375 | # initialize 376 | forecaster = weather_forecaster() 377 | forecaster.input['config'] = config 378 | 379 | # for defcon setup 380 | if len(sys.argv) == 2: 381 | forecaster.input['config']['source'] = 'json' 382 | forecaster.input['input-data'] = pd.read_csv(sys.argv[1], index_col=0).to_json() 383 | 384 | # get forecast 385 | msg = forecaster.compute(now=None) 386 | res = pd.read_json(io.StringIO(forecaster.output['output-data'])) 387 | 388 | # check for errors 389 | if msg != 'Done.': 390 | print(msg) 391 | else: 392 | print(res.round(1)) 393 | -------------------------------------------------------------------------------- /doper/examples/__init__.py: -------------------------------------------------------------------------------- 1 | # Distributed Optimal and Predictive Energy Resources (DOPER) Copyright (c) 2019 2 | # The Regents of the University of California, through Lawrence Berkeley 3 | # National Laboratory (subject to receipt of any required approvals 4 | # from the U.S. Dept. of Energy). All rights reserved. 5 | 6 | """"Distributed Optimal and Predictive Energy Resources 7 | Examples module. 8 | """ 9 | 10 | from .example import * 11 | -------------------------------------------------------------------------------- /doper/models/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is the DOPER models module. 3 | """ 4 | -------------------------------------------------------------------------------- /doper/models/genset.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | ''' 3 | INTERNAL USE ONLY 4 | Module of DOPER package (v1.0) 5 | cgehbauer@lbl.gov 6 | 7 | ''' 8 | import os 9 | import sys 10 | import logging 11 | import numpy as np 12 | import pandas as pd 13 | import matplotlib.pyplot as plt 14 | from pyomo.environ import ConcreteModel, Set, Param, Var, Constraint, Binary 15 | 16 | def get_root(f=None): 17 | try: 18 | if not f: 19 | f = __file__ 20 | root = os.path.dirname(os.path.abspath(f)) 21 | except: 22 | root = os.getcwd() 23 | return root 24 | root = get_root() 25 | 26 | from ..utility import pandas_to_dict, pyomo_read_parameter, plot_streams, get_root, extract_properties 27 | 28 | 29 | def add_genset(model, inputs, parameter): 30 | 31 | # Check that gensets are enabled 32 | assert parameter['system']['genset'] is True, \ 33 | "Gensets are not enabled in system configuration" 34 | 35 | # list of required genset parameters 36 | gensetParams = ['capacity', 'backupOnly', 'efficiency', 'fuel', 'omVar', 'maxRampUp', 'maxRampDown', 'timeToStart', 'regulation'] 37 | # check that all gensets have required parameters 38 | for gg in range(0, len(parameter['gensets'])): 39 | for pp in gensetParams: 40 | assert pp in parameter['gensets'][gg].keys(), \ 41 | f'Genset {gg+1} missing required parameter: {pp}' 42 | 43 | # extract list of fuels and gensets from parameter input 44 | fuelListInput = [fuel['name'] for fuel in parameter['fuels']] 45 | gensetListInput = [genset['name'] for genset in parameter['gensets']] 46 | 47 | # Sets 48 | model.gensets = Set(initialize=gensetListInput, doc='gensets in the system') 49 | model.fuels = Set(initialize=fuelListInput, doc='fuel types available') 50 | 51 | # Parameters 52 | model.genset_capacities = Param(model.gensets, initialize=extract_properties(parameter, 'gensets', 'capacity', gensetListInput), \ 53 | doc='genset capacities [kWh]') 54 | model.genset_backupOnly = Param(model.gensets, initialize=extract_properties(parameter, 'gensets', 'backupOnly', gensetListInput), \ 55 | doc='genset backup only [1/0]') 56 | model.genset_effs = Param(model.gensets, initialize=extract_properties(parameter, 'gensets', 'efficiency', gensetListInput), \ 57 | doc='genset efficiencies [-]') 58 | model.genset_omVarRates = Param(model.gensets, initialize=extract_properties(parameter, 'gensets', 'omVar', gensetListInput), \ 59 | doc='varaible O&M rates [$/kWh]') 60 | model.genset_maxRampUp = Param(model.gensets, initialize=extract_properties(parameter, 'gensets', 'maxRampUp', gensetListInput), \ 61 | doc='max ramp rate up [-/hr]') 62 | model.genset_maxRampDown = Param(model.gensets, initialize=extract_properties(parameter, 'gensets', 'maxRampDown', gensetListInput), \ 63 | doc='max ramp rate down [-/hr]') 64 | model.genset_timeToStart = Param(model.gensets, initialize=extract_properties(parameter, 'gensets', 'timeToStart', gensetListInput), \ 65 | doc='min time to start [ht]]') 66 | model.genset_regulation = Param(model.gensets, initialize=extract_properties(parameter, 'gensets', 'regulation', gensetListInput), \ 67 | doc='able to participate in reg [1/0]') 68 | try: 69 | # try to extract max S capacity 70 | model.genset_max_s = Param(model.gensets, initialize=extract_properties(parameter, 'gensets', 'maxS', gensetListInput), \ 71 | doc='genset max apparent power [kVA]') 72 | except: 73 | # if missing, just use max P 74 | model.genset_max_s = Param(model.gensets, initialize=extract_properties(parameter, 'gensets', 'capacity', gensetListInput), \ 75 | doc='genset max apparent power [kVA]') 76 | 77 | model.genset_fuels = Param(model.gensets, model.fuels, default=0, mutable=True, \ 78 | doc='genset fuel type') 79 | model.fuel_prices = Param(model.fuels, default=0, mutable=True, \ 80 | doc='fuel prices') 81 | model.fuel_co2 = Param(model.fuels, default=0, mutable=True, \ 82 | doc='fuel CO2 intentisties') 83 | model.fuel_reserves = Param(model.fuels, default=0, mutable=True, \ 84 | doc='fuel reserves in kWh') 85 | 86 | # construct 2-d fuel type table 87 | # bool indicating what fuel type each genset uses 88 | genset_fuels = extract_properties(parameter, 'gensets', 'fuel', gensetListInput) 89 | for gg in model.gensets: 90 | for ff in model.fuels: 91 | if genset_fuels[gg] == ff: 92 | model.genset_fuels[gg,ff] = 1 93 | 94 | # construct fuel price list 95 | for fuelDict in parameter['fuels']: 96 | ff = fuelDict['name'] 97 | model.fuel_prices[ff] = fuelDict['rate'] / fuelDict['conversion'] 98 | model.fuel_co2[ff] = fuelDict['co2'] / fuelDict['conversion'] 99 | if 'reserves' in fuelDict.keys(): 100 | model.fuel_reserves[ff] = fuelDict['reserves']* fuelDict['conversion'] 101 | else: 102 | model.fuel_reserves[ff] = 0 103 | logging.info('fuel reserves missing, default value = 0') 104 | 105 | #initialize param mapping genset to node. values are updated below for multinode models 106 | model.genset_node_location = Param(model.gensets, model.nodes, default=0, mutable=True, \ 107 | doc='genset node location') 108 | 109 | # populate battery_node_location based on data in parameter dict 110 | if not model.multiNode: 111 | # for single-node models, all genset output is at single node 112 | for nn in model.nodes: 113 | for gg in model.gensets: 114 | model.genset_node_location[gg, nn] = 1 115 | 116 | else: 117 | # for multinode models, create parameter mapping genset to node based on location in parameter 118 | for node in parameter['network']['nodes']: 119 | 120 | # extract node name 121 | nn = node['node_id'] 122 | 123 | # first check if node has ders key 124 | if 'ders' in node.keys(): 125 | 126 | # then check that ders is not None 127 | if node['ders'] is not None: 128 | 129 | # then check if genset in ders 130 | if 'genset' in node['ders'].keys(): 131 | 132 | # extract genset input from parameter 133 | gensetInput = node['ders']['genset'] 134 | 135 | # loop through genset index to see if it's at that node 136 | for gg in model.gensets: 137 | 138 | # check if param value is str 139 | if type(gensetInput) == str: 140 | if str(gg) == gensetInput: 141 | # if match found, set location to 1 in map param 142 | model.genset_node_location[gg, nn] = 1 143 | 144 | # check if param value is list 145 | if type(gensetInput) == list: 146 | # check if current genset from set is in input genset LIST 147 | if str(gg) in gensetInput: 148 | # if match found, set location to 1 in map param 149 | model.genset_node_location[gg, nn] = 1 150 | 151 | 152 | 153 | # Variables 154 | # model.sum_genset_power = Var(model.ts, bounds=(0, None), doc='total power output from gensets [kW]') 155 | # def genset_power_bounds(model, ts, genset): 156 | # return (0, model.bat_power_discharge[battery]) 157 | model.genset_power = Var(model.ts, model.gensets, \ 158 | bounds=(0, None), doc='genset power output [kW]') 159 | model.genset_fuel_consumption = Var(model.ts, model.gensets, model.fuels, \ 160 | bounds=(0, None), doc='genset fuel consumption rate [kW]') 161 | model.genset_fuel_consumption_profile = Var(model.ts, model.fuels, \ 162 | bounds=(0, None), doc='genset fuel consumption timeseries [kW]') 163 | model.genset_fuel_import_profile = Var(model.ts, model.fuels, \ 164 | bounds=(0, None), doc='genset fuel imported from utility timeseries [kW]') 165 | model.genset_fuel_from_reserves_profile = Var(model.ts, model.fuels, \ 166 | bounds=(0, None), doc='genset fuel consumed from reserves timeseries [kW]') 167 | model.genset_fuel_consumption_volume = Var(model.fuels, \ 168 | bounds=(0, None), doc='genset fuel consumption over horizon [kWh]') 169 | model.genset_fuel_cost_total = Var(model.fuels, \ 170 | bounds=(0, None), doc='fuel cost over horizon by fuel type[$]') 171 | # model.fuel_cost_total = Var(bounds=(0, None), doc='total fuel cost over horizon [$]') 172 | 173 | 174 | # genset output below max capacity 175 | def genset_max_output(model, ts, genset): 176 | return model.genset_power[ts, genset] <= (model.genset_backupOnly[genset] * (1-model.grid_available[ts]) \ 177 | + (1 - model.genset_backupOnly[genset])) * model.genset_capacities[genset] 178 | model.constraint_genset_max_output = Constraint(model.ts, model.gensets, \ 179 | rule=genset_max_output, \ 180 | doc='constraint max genset output power') 181 | 182 | 183 | 184 | # fuel consumption based on efficiency 185 | def genset_fuel_consumption(model, ts, genset, fuel): 186 | return model.genset_fuel_consumption[ts, genset, fuel] == model.genset_power[ts, genset] \ 187 | * model.genset_effs[genset] * model.genset_fuels[genset, fuel] 188 | model.constraint_genset_fuel_consumption = Constraint(model.ts, model.gensets, model.fuels, \ 189 | rule=genset_fuel_consumption, \ 190 | doc='constraint genset fuel consumption') 191 | 192 | # total fuel consumption by fuel type by timestep 193 | def total_genset_fuel_consumption_profile(model, ts, fuel): 194 | return model.genset_fuel_consumption_profile[ts, fuel] == sum(model.genset_fuel_consumption[ts, genset, fuel] \ 195 | for genset in model.gensets) 196 | model.constraint_total_genset_fuel_consumption_profile = Constraint(model.ts, model.fuels, \ 197 | rule=total_genset_fuel_consumption_profile, \ 198 | doc='constraint total genset fuel consumption profile') 199 | 200 | # total fuel consumption by fuel type 201 | def total_genset_fuel_consumption(model, fuel): 202 | return model.genset_fuel_consumption_volume[fuel] == sum(model.genset_fuel_consumption[ts, genset, fuel] \ 203 | for ts in model.ts for genset in model.gensets) 204 | model.constraint_total_genset_fuel_consumption = Constraint(model.fuels, \ 205 | rule=total_genset_fuel_consumption, \ 206 | doc='constraint total genset fuel consumption') 207 | 208 | # total cost based on cost of fuels 209 | def total_genset_fuel_cost(model): 210 | return model.fuel_cost_total == sum(model.genset_fuel_consumption_volume[fuel] * model.fuel_prices[fuel] for fuel in model.fuels) 211 | model.constraint_total_genset_fuel_cost = Constraint(rule=total_genset_fuel_cost, \ 212 | doc='constraint total genset fuel cost') 213 | 214 | # total CO2 emssions from genset fuel consumption 215 | def total_genset_co2(model): 216 | return model.sum_genset_co2 == sum(model.genset_fuel_consumption_volume[fuel] * model.fuel_co2[fuel] for fuel in model.fuels) 217 | model.constraint_total_genset_co2 = Constraint(rule=total_genset_co2, \ 218 | doc='constraint total genset co2 emissions') 219 | 220 | # total CO2 emssions profile from genset fuel consumption 221 | def genset_co2_profile(model, ts): 222 | return model.co2_profile_genset[ts] == sum(model.genset_fuel_consumption[ts, genset, fuel] * model.fuel_co2[fuel] for fuel in model.fuels \ 223 | for genset in model.gensets) 224 | model.constraint_genset_co2_profile = Constraint(model.ts, rule=genset_co2_profile, \ 225 | doc='constraint total genset co2 emissions') 226 | 227 | 228 | # Fuel Outage Equations 229 | def total_genset_fuel_consumption_source(model, ts, fuel): 230 | return model.genset_fuel_consumption_profile[ts, fuel] == model.genset_fuel_import_profile[ts, fuel] + model.genset_fuel_from_reserves_profile[ts, fuel] 231 | model.constraint_total_genset_fuel_consumption_source = Constraint(model.ts, model.fuels, \ 232 | rule=total_genset_fuel_consumption_source, \ 233 | doc='constraint total genset fuel consumption source') 234 | # fuel imports are zero if not available 235 | def genset_fuel_import_limit(model, ts, fuel): 236 | return model.genset_fuel_import_profile[ts, fuel] <= 1e9 * model.fuel_available[ts] 237 | model.constraint_genset_fuel_import_limit = Constraint(model.ts, model.fuels, \ 238 | rule=genset_fuel_import_limit, \ 239 | doc='constraint total genset fuel import limit') 240 | # fuel from reserves disabled if available from utility import 241 | def genset_fuel_reserves_limit(model, ts, fuel): 242 | return model.genset_fuel_from_reserves_profile[ts, fuel] <= 1e9 * (1 - model.fuel_available[ts]) 243 | model.constraint_genset_fuel_reserves_limit = Constraint(model.ts, model.fuels, \ 244 | rule=genset_fuel_reserves_limit, \ 245 | doc='constraint total genset fuel reserves limit') 246 | # total fuel reserves consumed must be less than reserves on hand 247 | def total_genset_reserves_volume(model, ts, fuel): 248 | return model.fuel_reserves[fuel] >= sum(model.genset_fuel_from_reserves_profile[ts, fuel] \ 249 | for ts in model.ts) 250 | model.constraint_total_genset_reserves_volume = Constraint(model.ts, model.fuels, \ 251 | rule=total_genset_reserves_volume, \ 252 | doc='constraint total genset fuel consumption from reserves') 253 | 254 | # aggregate genset power output by node, works by default for single-node models 255 | def total_genset_output(model, ts, gensets, nodes): 256 | return model.sum_genset_power[ts, nodes] == sum((model.genset_power[ts, genset] * model.genset_node_location[genset, nodes]) for genset in model.gensets) 257 | model.constraint_total_genset_output = Constraint(model.ts, model.gensets, model.nodes, \ 258 | rule=total_genset_output, \ 259 | doc='constraint total genset output power') 260 | 261 | # sum site-wide genset output 262 | def site_total_genset_output(model, ts, nodes): 263 | return model.sum_genset_power_site[ts] == sum(model.sum_genset_power[ts, node] for node in model.nodes) 264 | model.constraint_site_total_genset_output = Constraint(model.ts, model.nodes, \ 265 | rule=site_total_genset_output, \ 266 | doc='site total genset output power') 267 | 268 | return model 269 | 270 | 271 | # Converter 272 | def convert_gesnet(model, parameter): 273 | pass -------------------------------------------------------------------------------- /doper/models/loadControl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | ''' 3 | INTERNAL USE ONLY 4 | Module of DOPER package (v1.0) 5 | cgehbauer@lbl.gov 6 | 7 | ''' 8 | import os 9 | import sys 10 | import numpy as np 11 | import pandas as pd 12 | import matplotlib.pyplot as plt 13 | from pyomo.environ import ConcreteModel, Set, Param, Var, Constraint, Binary 14 | 15 | def get_root(f=None): 16 | try: 17 | if not f: 18 | f = __file__ 19 | root = os.path.dirname(os.path.abspath(f)) 20 | except: 21 | root = os.getcwd() 22 | return root 23 | root = get_root() 24 | 25 | from ..utility import pandas_to_dict, pyomo_read_parameter, plot_streams, get_root, extract_properties 26 | 27 | def add_loadControl(model, inputs, parameter): 28 | 29 | # Check that load control enabled 30 | assert parameter['system']['load_control'] is True, \ 31 | "Load Control is not enabled in system configuration" 32 | 33 | # list of required genset parameters 34 | loadParams = ['name', 'cost', 'outageOnly'] 35 | # check that all load circuits have required parameters 36 | for cc in range(0, len(parameter['load_control'])): 37 | 38 | # if no name for load circuit provided, overwrite with number 39 | if 'name' not in parameter['load_control'][cc].keys(): 40 | parameter['load_control'][cc]['name'] = f'{cc+1}' 41 | 42 | 43 | for pp in loadParams: 44 | assert pp in parameter['load_control'][cc].keys(), \ 45 | f'Load circuit {cc+1} missing required parameter: {pp}' 46 | 47 | # assert that inputs has load column for given circuit 48 | target_circuit_name = parameter['load_control'][cc]['name'] 49 | target_col_name = f'load_shed_potential_{target_circuit_name}' 50 | assert target_col_name in inputs.columns, \ 51 | f'Load profile for circuit {target_circuit_name} missing from input data' 52 | 53 | 54 | # Sets 55 | load_circuits = [circuit['name'] for circuit in parameter['load_control']] 56 | model.load_circuits = Set(initialize=load_circuits, doc='load circuits in the system') 57 | 58 | # Parameters 59 | model.load_cost = Param(model.load_circuits, initialize=extract_properties(parameter, 'load_control', 'cost', load_circuits), \ 60 | doc='load control shed cost [$/kWh]') 61 | model.load_outageOnly = Param(model.load_circuits, initialize=extract_properties(parameter, 'load_control', 'outageOnly', load_circuits), \ 62 | doc='load control backup only [1/0]') 63 | 64 | # read in load_shed_potential ts-data by circuit name 65 | # data should exist as a column with name 'load_shed_potential_{c}', where c is name of load control circuit 66 | model.load_shed_potential = Param(model.ts, model.load_circuits, \ 67 | initialize= \ 68 | pandas_to_dict(inputs[[f'load_shed_potential_{c}' for c in model.load_circuits]] ,\ 69 | columns=model.load_circuits, convertTs=True), \ 70 | doc='load she potential per circuit [kW]') 71 | 72 | 73 | # construct mapping of loadshed assest to node location 74 | model.loadshed_node_location = Param(model.load_circuits, model.nodes, default=0, mutable=True, \ 75 | doc='load shed asset to node location map') 76 | 77 | if not model.multiNode: 78 | # for single-node models, all load shed is applied at single node 79 | for nn in model.nodes: 80 | for cc in model.load_circuits: 81 | model.loadshed_node_location[cc, nn] = 1 82 | 83 | else: 84 | # for multinode models, create parameter mapping load shed assets to node based on location in parameter 85 | for node in parameter['network']['nodes']: 86 | 87 | # extract node name 88 | nn = node['node_id'] 89 | 90 | # first check if node has ders key 91 | if 'ders' in node.keys(): 92 | 93 | # then check that ders is not None 94 | if node['ders'] is not None: 95 | 96 | # then check if load_control in ders 97 | if 'load_control' in node['ders'].keys(): 98 | 99 | # extract load_control input from parameter 100 | loadControlInput = node['ders']['load_control'] 101 | 102 | # loop through genset index to see if it's at that node 103 | for cc in model.load_circuits: 104 | 105 | # check if param value is str 106 | if type(loadControlInput) == str: 107 | if str(cc) == loadControlInput: 108 | # if match found, set location to 1 in map param 109 | model.loadshed_node_location[cc, nn] = 1 110 | 111 | # check if param value is list 112 | if type(loadControlInput) == list: 113 | # check if current genset from set is in input genset LIST 114 | if str(cc) in loadControlInput: 115 | # if match found, set location to 1 in map param 116 | model.loadshed_node_location[cc, nn] = 1 117 | 118 | 119 | 120 | 121 | # # Variables 122 | model.load_circuits_on = Var(model.ts, model.load_circuits, \ 123 | domain=Binary, doc='binary var indicating if load circuit is on [1/0]') 124 | model.load_shed_circuit = Var(model.ts, model.load_circuits, bounds=(0, None), \ 125 | doc='load shed amount due to load control use for each load circuit [kW]') 126 | model.der_shed_load = Var(model.ts, model.load_circuits, bounds=(0, None), \ 127 | doc='load shed derivative due to load control use for each load circuit [-]') 128 | # vars defined in basemodel 129 | # model.load_shed = Var(model.ts, model.nodes, bounds=(0, None), doc='load shed amount due to load control use [kW]') 130 | # model.load_shed_cost_total = Var(bounds=(0, None), doc='total load shed cost over horizon [$]') 131 | 132 | 133 | 134 | ## Constraints 135 | 136 | # load circuit must be on for outage-only if grid available 137 | def outage_shed_constraint(model, ts, load_circuit): 138 | return model.load_circuits_on[ts, load_circuit] >= model.load_outageOnly[load_circuit] * model.grid_available[ts] 139 | model.constraint_outage_shed = Constraint(model.ts, model.load_circuits, \ 140 | rule=outage_shed_constraint, \ 141 | doc='constraint load shed for outage only') 142 | 143 | # load shed volume by circuit 144 | def circuit_shed_load(model, ts, load_circuit): 145 | return model.load_shed_circuit[ts, load_circuit] == model.load_shed_potential[ts, load_circuit] * (1 - model.load_circuits_on[ts, load_circuit]) 146 | model.constraint_circuit_shed_load = Constraint(model.ts, model.load_circuits, \ 147 | rule=circuit_shed_load, \ 148 | doc='constraint load shed volume by circuit') 149 | 150 | # total load shed across all circuits 151 | def total_shed_load(model, ts): 152 | return model.load_shed_site[ts] == sum(model.load_shed_potential[ts, circuit] * (1 - model.load_circuits_on[ts, circuit]) \ 153 | for circuit in model.load_circuits) 154 | model.constraint_total_shed_load= Constraint(model.ts, \ 155 | rule=total_shed_load, \ 156 | doc='constraint total load shed profile for all nodes/circuits') 157 | 158 | # load shed cost is the sum of load shed cost by time each circuit is off 159 | def total_shed_cost(model): 160 | return model.load_shed_cost_total == sum((model.load_cost[circuit] * model.load_shed_circuit[ts, circuit]) / model.timestep_scale[ts] \ 161 | for circuit in model.load_circuits for ts in model.ts) 162 | model.constraint_total_shed_cost = Constraint(rule=total_shed_cost, \ 163 | doc='constraint net total shed cost') 164 | 165 | 166 | # map utilized load shed to node. Eqn works for single-node models by default 167 | def node_load_shed(model, ts, nodes): 168 | return model.load_shed[ts, nodes] == sum((model.load_shed_circuit[ts, load_circuit] * model.loadshed_node_location[load_circuit, nodes]) for load_circuit in model.load_circuits) 169 | model.constraint_node_load_shed = Constraint(model.ts, model.nodes, \ 170 | rule=node_load_shed, \ 171 | doc='constraint nodel load shed power') 172 | # dampen curtail actuation (derivative) for falling edge 173 | def der_shed_load(model, ts, load_circuit): 174 | if ts == model.ts.at(1): return model.der_shed_load[ts, load_circuit] == 0 175 | else: return model.der_shed_load[ts, load_circuit] >= model.load_circuits_on[ts-model.timestep[ts], load_circuit] - model.load_circuits_on[ts, load_circuit] 176 | model.constraint_der_shed_load = Constraint(model.ts, model.load_circuits, rule=der_shed_load, 177 | doc='constraint load shed derivative by circuit') 178 | # load shed actuation activation, sum of all shed events 179 | def total_der_shed_load(model): 180 | return model.load_shed_der_total == sum(model.der_shed_load[ts, circuit] for circuit in model.load_circuits for ts in model.ts) 181 | model.constraint_total_der_shed_load = Constraint(rule=total_der_shed_load, doc='constraint total shed events') 182 | 183 | return model -------------------------------------------------------------------------------- /doper/optWrapper.py: -------------------------------------------------------------------------------- 1 | # Distributed Optimal and Predictive Energy Resources (DOPER) Copyright (c) 2019 2 | # The Regents of the University of California, through Lawrence Berkeley 3 | # National Laboratory (subject to receipt of any required approvals 4 | # from the U.S. Dept. of Energy). All rights reserved. 5 | 6 | """"Distributed Optimal and Predictive Energy Resources 7 | Controller wrapper module. 8 | """ 9 | 10 | # pylint: disable=invalid-name, import-error, too-many-instance-attributes 11 | # pylint: disable=redefined-outer-name, broad-exception-caught, unused-argument 12 | # pylint: disable=too-many-statements, unused-import, unused-variable 13 | 14 | 15 | import os 16 | import logging 17 | import pandas as pd 18 | 19 | 20 | from fmlc import eFMU 21 | from pyomo.environ import Objective, minimize 22 | 23 | # import optimization modules 24 | from .wrapper import DOPER 25 | from .utility import get_solver, get_root, standard_report 26 | from .basemodel import base_model, default_output_list 27 | from .battery import add_battery 28 | from .genset import add_genset 29 | from .loadControl import add_loadControl 30 | from .example import (example_inputs, example_parameter_add_genset, 31 | example_inputs_offgrid, example_inputs_planned_outage, 32 | example_parameter_add_battery, example_parameter_add_loadcontrol, 33 | example_inputs_load_shed, example_inputs_fueloutage, 34 | example_inputs_variable_co2) 35 | 36 | class OptimizationWrapper(eFMU): 37 | """wrapper class for DOPER""" 38 | 39 | def __init__(self): 40 | self.input = { 41 | 'timeseries-data': None, #json-ized df of timeseries input data 42 | 'parameter-data': None, # dict of model/setting parameters 43 | 'output-list': None, # list of dict of output instructions for custom ouputs 44 | } 45 | 46 | self.output = { 47 | 'opt-summary': None, 48 | 'output-data': None, 49 | } 50 | 51 | # initialize input attributes 52 | self.tsInputs = None 53 | self.modelParams = None 54 | self.userOutputs = None 55 | 56 | # initialize internal attributes 57 | self.model = None 58 | self.results = None 59 | self.outputList = None 60 | self.solverPath = None 61 | self.modelConstructor = None 62 | 63 | # initialize output attributes 64 | self.duration = None 65 | self.objective = None 66 | self.termination = None 67 | self.tsResults = None 68 | self.modelPyomo = None 69 | self.resultPyomo = None 70 | 71 | self.tsResultsJson = None 72 | self.optSummary = None 73 | 74 | self.msg = None 75 | self.errorFlag = True 76 | 77 | def construct_model_function(self, inputs, parameter): 78 | ''' 79 | method returns a function for constructing an optimization model, 80 | used by Doper wrapper to run optimization. 81 | 82 | dynamically constructs pyomo model based on content of parameters['system'] 83 | 84 | constructs objective for all current costs, using the weight values 85 | in parameter['objective'] 86 | 87 | Parameters 88 | ---------- 89 | inputs : pandas df 90 | timeseries data input for optimization model. 91 | parameter : dict 92 | dict defining system and parameters. 93 | 94 | Returns 95 | ------- 96 | func 97 | function to construct optimization model 98 | 99 | ''' 100 | 101 | def control_model(inputs, parameter): 102 | # construct pyomo model based on assets included in parameters 103 | model = base_model(inputs, parameter) 104 | if parameter['system']['battery']: 105 | model = add_battery(model, inputs, parameter) 106 | if parameter['system']['genset']: 107 | model = add_genset(model, inputs, parameter) 108 | if parameter['system']['load_control']: 109 | model = add_loadControl(model, inputs, parameter) 110 | 111 | # construct objective function based on weights include in parameters 112 | def objective_function(model): 113 | return model.sum_energy_cost * parameter['objective']['weight_energy'] \ 114 | + model.sum_demand_cost * parameter['objective']['weight_demand'] \ 115 | + model.sum_export_revenue * parameter['objective']['weight_export'] \ 116 | + model.sum_regulation_revenue \ 117 | * parameter['objective']['weight_regulation'] \ 118 | + model.fuel_cost_total * parameter['objective']['weight_fuel'] \ 119 | + model.load_shed_cost_total * parameter['objective']['weight_load_shed'] \ 120 | + model.co2_total * parameter['objective']['weight_co2'] \ 121 | 122 | model.objective = Objective(rule=objective_function, sense=minimize, 123 | doc='objective function') 124 | return model 125 | return control_model 126 | 127 | def compute(self): 128 | """main compute function.""" 129 | 130 | # initialize msg to return 131 | self.msg = None 132 | self.errorFlag = False 133 | 134 | # unpack inputs 135 | self.tsInputs = self.input['timeseries-data'] 136 | self.modelParams = self.input['parameter-data'] 137 | self.userOutputs = self.input['output-list'] 138 | 139 | # initialize model objects to empty values 140 | self.model = None 141 | self.results = None 142 | self.duration = None 143 | self.objective = None 144 | self.termination = None 145 | self.tsResults = None 146 | self.modelPyomo = None 147 | self.resultPyomo = None 148 | self.tsResultsJson = None 149 | self.optSummary = { 150 | 'duration': -1, 151 | 'objective': 0, 152 | 'termination': 'failed' 153 | } 154 | 155 | # try to convert tsInput from json to df 156 | try: 157 | self.tsInputs = pd.read_json(self.tsInputs) 158 | except Exception as e: 159 | self.msg += 'ERROR: Input processing failed' + str(e) 160 | self.tsInputs = None 161 | self.errorFlag = True 162 | 163 | # check if required inputs have been passed 164 | if(self.tsInputs is None or self.modelParams is None): 165 | self.msg += 'Error: required inputs missing' 166 | self.errorFlag = True 167 | else: 168 | try: 169 | # define model and objective func using construct_model_function method 170 | self.modelConstructor = self.construct_model_function(self.tsInputs, 171 | self.modelParams) 172 | except Exception as e: 173 | self.msg += 'ERROR: model contructor failed ' + str(e) 174 | self.errorFlag = True 175 | 176 | # use user-specfied inputs, if provied 177 | if self.userOutputs is not None: 178 | self.outputList = self.userOutputs 179 | else: 180 | # otherwise, load default output list 181 | self.outputList = default_output_list(self.modelParams) 182 | 183 | # try to run optimization 184 | if not self.errorFlag: 185 | try: 186 | # Define the path to the solver executable 187 | self.solverPath = get_solver(self.modelParams['solver']['solver_name'], 188 | solver_dir=self.modelParams['solver']['solver_path']) 189 | 190 | # Initialize DOPER 191 | self.model = DOPER(model=self.modelConstructor, 192 | parameter=self.modelParams, 193 | solver_path=self.solverPath , 194 | output_list=self.outputList) 195 | 196 | # Conduct optimization 197 | self.results = self.model.do_optimization(self.tsInputs) 198 | 199 | # Get results 200 | duration, objective, tsResults, modelPyomo, \ 201 | resultPyomo, termination, parameter = self.results 202 | 203 | # extract results and store as attributes 204 | self.duration = duration 205 | self.objective = objective 206 | self.termination = str(termination) 207 | 208 | self.tsResults = tsResults 209 | self.modelPyomo = modelPyomo 210 | self.resultPyomo = resultPyomo 211 | 212 | # process results for self.output 213 | self.tsResultsJson = self.tsResults.to_json() 214 | self.optSummary = { 215 | 'duration': self.duration, 216 | 'objective': self.objective, 217 | 'termination': self.termination 218 | } 219 | except Exception as e: 220 | self.msg += 'ERROR: Optimization failed' + str(e) 221 | 222 | # pack optimization outputs into self.output 223 | self.output['opt-summary'] = self.optSummary 224 | self.output['output-data'] = self.tsResultsJson 225 | 226 | # if no msg has been define, default to Done 227 | if self.msg is None: 228 | self.msg = 'Done.' 229 | 230 | # Return status message 231 | return self.msg 232 | 233 | if __name__ == '__main__': 234 | 235 | # set logging 236 | logging.basicConfig( 237 | format='%(asctime)s %(levelname)-8s %(message)s', 238 | level=logging.INFO, 239 | datefmt='%Y-%m-%d %H:%M:%S') 240 | 241 | # define parameter from example module 242 | parameter = None 243 | # parameter = example_parameter_evfleet() 244 | parameter = example_parameter_add_genset(parameter) 245 | # parameter = example_parameter_add_loadcontrol(parameter) 246 | parameter = example_parameter_add_battery(parameter) 247 | 248 | # add solver name and path to parameter 249 | parameter['solver'] = { 250 | 'solver_name': 'cbc', 251 | 'solver_path': os.path.join(get_root(), 'solvers') 252 | } 253 | 254 | # define timeseries data from example module 255 | tsData = example_inputs(parameter, load='B90', scale_load=150, scale_pv=100) 256 | 257 | # convert df to json 258 | tsData = tsData.to_json() 259 | 260 | # define custom outputs to add 261 | myOutputs = [{ 262 | 'name': 'batSOC', 263 | 'data': 'battery_energy', 264 | 'index': 'batteries', 265 | 'df_label': 'Energy in Battery %s' 266 | }] 267 | 268 | inputs = { 269 | 'timeseries-data': tsData, #json-ized df of timeseries input data 270 | 'parameter-data': parameter, # dict of model/setting parameters 271 | 'output-list': myOutputs, 272 | } 273 | 274 | # instantiate forecast framework wrapper 275 | newWrapper = OptimizationWrapper() 276 | 277 | # pass inputs 278 | newWrapper.input = inputs 279 | 280 | # run compute method to train and predict 281 | newWrapper.compute() 282 | 283 | print(newWrapper.msg) 284 | print(standard_report(newWrapper.results)) 285 | -------------------------------------------------------------------------------- /doper/plotting.py: -------------------------------------------------------------------------------- 1 | # Distributed Optimal and Predictive Energy Resources (DOPER) Copyright (c) 2019 2 | # The Regents of the University of California, through Lawrence Berkeley 3 | # National Laboratory (subject to receipt of any required approvals 4 | # from the U.S. Dept. of Energy). All rights reserved. 5 | 6 | """"Distributed Optimal and Predictive Energy Resources 7 | Plotting module. 8 | """ 9 | 10 | # pylint: disable=invalid-name, too-many-locals, too-many-arguments, dangerous-default-value 11 | # pylint: disable=undefined-variable, unused-argument 12 | 13 | import numpy as np 14 | import matplotlib.pyplot as plt 15 | 16 | def plot_dynamic_nodes(df, parameter, plotFile = None): 17 | ''' 18 | A standard plotting template to present results. 19 | 20 | Input 21 | ----- 22 | df (pandas.DataFrame): The resulting dataframe with the optimization result. 23 | plot (bool): Flag to plot or return the figure. (default=True) 24 | plot_times (bool): Flag if time separation should be plotted. (default=True) 25 | tight (bool): Flag to use tight_layout. (default=True) 26 | 27 | Returns 28 | ------- 29 | None if plot == True. 30 | else: 31 | fig (matplotlib figure): Figure of the plot. 32 | axs (numpy.ndarray of matplotlib.axes._subplots.AxesSubplot): Axis of the plot. 33 | ''' 34 | 35 | # number of plot rows is equal to the number of nodes 36 | n = len(parameter['network']['nodes']) 37 | fig, axs = plt.subplots(nrows=n,ncols=2, figsize=(24, 3*n), sharex=True, sharey=True) 38 | 39 | # loop through nodes 40 | for nn, node in enumerate(parameter['network']['nodes']): 41 | 42 | # get node name in order to extract data cols from df 43 | nodeName = node['node_id'] 44 | 45 | # define node-specific col names 46 | importCol = f'Node grid import [kW] {nodeName}' 47 | exportCol = f'Node grid export [kW] {nodeName}' 48 | loadServedCol = f'Node load served [kW] {nodeName}' 49 | pvGenCol = f'Node pv gen [kW] {nodeName}' 50 | injPowerCol = f'Node power injected [kW] {nodeName}' 51 | absPowerCol = f'Node power absorbed [kW] {nodeName}' 52 | gensetCol = f'Node genset gen [kW] {nodeName}' 53 | # batChargeCol = f'Node grid import [kW] {nodeName}' 54 | # batDischargeCol = f'Node grid import [kW] {nodeName}' 55 | # loadShedCol = f'Node grid import [kW] {nodeName}' 56 | 57 | # create energy provision plot 58 | 59 | # list of provision columns in results df 60 | provision_cols = [importCol, absPowerCol] 61 | consumption_cols = [exportCol, loadServedCol, injPowerCol] 62 | if parameter['system']['pv']: 63 | provision_cols += [pvGenCol] 64 | if parameter['system']['genset']: 65 | provision_cols += [gensetCol] 66 | # if parameter['system']['battery']: 67 | # provision_cols += ['Battery Discharging Power [kW]'] 68 | # consumption_cols += ['Battery Charging Power [kW]'] 69 | # if parameter['system']['load_control']: 70 | # provision_cols += ['Total Shed Load [kW]'] 71 | 72 | df[provision_cols].plot.area(ax=axs[nn, 0], \ 73 | title='Energy Provision').legend(loc='upper right') 74 | df[consumption_cols].plot.area(ax=axs[nn, 1], \ 75 | title='Energy Consumption').legend(loc='upper right') 76 | 77 | if plotFile: 78 | plt.savefig(plotFile, dpi=300) 79 | return fig, axs 80 | 81 | def plot_pv_only(df, plot=True, plotFile = None, tight=True, plot_reg=None, times=[8,12,18,22]): 82 | ''' 83 | A standard plotting template to present results. 84 | 85 | Input 86 | ----- 87 | df (pandas.DataFrame): The resulting dataframe with the optimization result. 88 | plot (bool): Flag to plot or return the figure. (default=True) 89 | plot_times (bool): Flag if time separation should be plotted. (default=True) 90 | tight (bool): Flag to use tight_layout. (default=True) 91 | 92 | Returns 93 | ------- 94 | None if plot == True. 95 | else: 96 | fig (matplotlib figure): Figure of the plot. 97 | axs (numpy.ndarray of matplotlib.axes._subplots.AxesSubplot): Axis of the plot. 98 | ''' 99 | n = 3 100 | fig, axs = plt.subplots(n,1, figsize=(12, 3*n), sharex=True, sharey=False, 101 | gridspec_kw={'width_ratios':[1]}) 102 | axs = axs.ravel() 103 | plot_streams(axs[0], df[['Import Power [kW]','Export Power [kW]']], times=times) 104 | 105 | # create energy provision plot 106 | plot_streams(axs[2], df[['Tariff Energy [$/kWh]']], times=times) 107 | if plotFile: 108 | plt.savefig(plotFile) 109 | if plot: 110 | if tight: 111 | plt.tight_layout() 112 | plt.show() 113 | return fig, axs 114 | 115 | def plot_standard1(df, plot=True, tight=True, plot_reg=None, times=[8,12,18,22]): 116 | ''' 117 | A standard plotting template to present results. 118 | 119 | Input 120 | ----- 121 | df (pandas.DataFrame): The resulting dataframe with the optimization result. 122 | plot (bool): Flag to plot or return the figure. (default=True) 123 | plot_times (bool): Flag if time separation should be plotted. (default=True) 124 | tight (bool): Flag to use tight_layout. (default=True) 125 | 126 | Returns 127 | ------- 128 | None if plot == True. 129 | else: 130 | fig (matplotlib figure): Figure of the plot. 131 | axs (numpy.ndarray of matplotlib.axes._subplots.AxesSubplot): Axis of the plot. 132 | ''' 133 | # Check if include regulation 134 | if plot_reg is None and df[['Reg Up [kW]','Reg Dn [kW]', 135 | 'Tariff Reg Up [$/kWh]', 136 | 'Tariff Reg Dn [$/kWh]']].abs().sum().sum() > 0: 137 | plot_reg = True 138 | n = 4 + (2 if plot_reg else 0) 139 | fig, axs = plt.subplots(n,1, figsize=(12, 3*n), sharex=True, sharey=False, 140 | gridspec_kw={'width_ratios':[1]}) 141 | axs = axs.ravel() 142 | plot_streams(axs[0], df[['Import Power [kW]','Export Power [kW]']], times=times) 143 | 144 | # create energy provision plot 145 | if 'Battery Power [kW]' in df.columns: 146 | plot_streams(axs[1], df[['Battery Power [kW]','Load Power [kW]','PV Power [kW]']], 147 | times=times) 148 | else: 149 | plot_streams(axs[1], df[['Load Power [kW]','PV Power [kW]']], 150 | times=times) 151 | 152 | plot_streams(axs[2], df[['Tariff Energy [$/kWh]']], times=times) 153 | plot_streams(axs[3], df[['Battery SOC [%]']], times=times) 154 | if plot_reg: 155 | plot_streams(axs[4], df[['Reg Up [kW]','Reg Dn [kW]']], times=times) 156 | plot_streams(axs[5], df[['Tariff Reg Up [$/kWh]','Tariff Reg Dn [$/kWh]']], times=times) 157 | if plot: 158 | if tight: 159 | plt.tight_layout() 160 | plt.show() 161 | return fig, axs 162 | 163 | def plot_dynamic(df, parameter, plot=True, plotFile = None, 164 | tight=True, plot_reg=None, times=[8,12,18,22]): 165 | ''' 166 | A standard plotting template to present results. 167 | 168 | Input 169 | ----- 170 | df (pandas.DataFrame): The resulting dataframe with the optimization result. 171 | plot (bool): Flag to plot or return the figure. (default=True) 172 | plot_times (bool): Flag if time separation should be plotted. (default=True) 173 | tight (bool): Flag to use tight_layout. (default=True) 174 | 175 | Returns 176 | ------- 177 | None if plot == True. 178 | else: 179 | fig (matplotlib figure): Figure of the plot. 180 | axs (numpy.ndarray of matplotlib.axes._subplots.AxesSubplot): Axis of the plot. 181 | ''' 182 | 183 | # number of subplots. eventually dynamically determined 184 | n = 4 185 | 186 | if parameter['system']['battery']: 187 | # if batteries are enabled, add plot for SOC 188 | n += 1 189 | 190 | fig, axs = plt.subplots(n,1, figsize=(12, 3*n), sharex=True, sharey=False, 191 | gridspec_kw={'width_ratios':[1]}) 192 | axs = axs.ravel() 193 | # plot_streams(axs[0], df[['Import Power [kW]','Export Power [kW]']], times=times) 194 | df[['Import Power [kW]','Export Power [kW]']].plot(ax=axs[0], title = 'Power Flow at PCC') 195 | 196 | # create energy provision plot 197 | 198 | # list of provision columns in results df 199 | provision_cols = ['Import Power [kW]'] 200 | consumption_cols = ['Load Power [kW]', 'Export Power [kW]'] 201 | if parameter['system']['pv']: 202 | provision_cols += ['PV Power [kW]'] 203 | if parameter['system']['genset']: 204 | provision_cols += ['Genset Power [kW]'] 205 | if parameter['system']['battery']: 206 | provision_cols += ['Battery Discharging Power [kW]'] 207 | consumption_cols += ['Battery Charging Power [kW]'] 208 | if parameter['system']['load_control']: 209 | provision_cols += ['Total Shed Load [kW]'] 210 | 211 | df[provision_cols].plot.area(ax=axs[1], title='Energy Provision').legend(loc='upper right') 212 | df[consumption_cols].plot.area(ax=axs[2], title='Energy Consumption').legend(loc='upper right') 213 | if parameter['system']['battery']: 214 | df[['Battery Aggregate SOC [-]']].plot(ax=axs[3], title='Battery SOC') 215 | df[['Tariff Energy [$/kWh]']].plot(ax=axs[n-1], title='Tariff Energy Price') 216 | 217 | if plotFile: 218 | plt.savefig(plotFile, dpi=300) 219 | if plot: 220 | if tight: 221 | plt.tight_layout() 222 | plt.show() 223 | return fig, axs 224 | 225 | def formatExternalData(df): 226 | ''' 227 | Parameters 228 | ---------- 229 | df : TYPE 230 | DESCRIPTION. 231 | 232 | Returns 233 | ------- 234 | None. 235 | ''' 236 | 237 | supplyList = ['pvGen', 'genset', 'gridImport', 'powerAbs', 'batDischarge'] 238 | demandList = ['gridExport', 'load', 'powerInj', 'batCharge'] 239 | 240 | # set index to col 241 | # df['ts'] = df.index 242 | df['ts'] = np.arange(len(df)) 243 | 244 | # melt df 245 | df = df.melt(id_vars=['ts']) 246 | 247 | # create node col 248 | df[['src', 'node']] = df['variable'].str.split('_', 1, expand=True) 249 | 250 | # drop variable col 251 | df.drop(['variable'], axis=1, inplace=True) 252 | 253 | # add group based on source 254 | df['group'] = 'NA' 255 | df.group[df.src.isin(supplyList)] = 'supply' 256 | df.group[df.src.isin(demandList)] = 'demand' 257 | 258 | # return reformatted df 259 | return df 260 | -------------------------------------------------------------------------------- /doper/resources/__init__.py: -------------------------------------------------------------------------------- 1 | # Distributed Optimal and Predictive Energy Resources (DOPER) Copyright (c) 2019 2 | # The Regents of the University of California, through Lawrence Berkeley 3 | # National Laboratory (subject to receipt of any required approvals 4 | # from the U.S. Dept. of Energy). All rights reserved. 5 | 6 | """"Distributed Optimal and Predictive Energy Resources 7 | Resources module. 8 | """ 9 | -------------------------------------------------------------------------------- /doper/resources/pvlib/README.md: -------------------------------------------------------------------------------- 1 | The forecast.py file was downloaded from an old depreciated distribution of pvlib (https://github.com/pvlib/pvlib-python/blob/v0.9.5/pvlib/forecast.py) -------------------------------------------------------------------------------- /doper/resources/pvlib/__init__.py: -------------------------------------------------------------------------------- 1 | # Distributed Optimal and Predictive Energy Resources (DOPER) Copyright (c) 2019 2 | # The Regents of the University of California, through Lawrence Berkeley 3 | # National Laboratory (subject to receipt of any required approvals 4 | # from the U.S. Dept. of Energy). All rights reserved. 5 | 6 | """"Distributed Optimal and Predictive Energy Resources 7 | pvlib module. 8 | """ 9 | -------------------------------------------------------------------------------- /doper/solvers/clean_setup_solvers.sh: -------------------------------------------------------------------------------- 1 | sed -i 's/\r$//' setup_solvers.sh -------------------------------------------------------------------------------- /doper/solvers/setup_solvers.bat: -------------------------------------------------------------------------------- 1 | if [%1]==[] (set solver_dir="../solvers/") else (set solver_dir=%1) 2 | 3 | set cbc_repo=https://github.com/coin-or/Cbc/releases/download/releases%%2F 4 | set cbc_version=2.10.8 5 | 6 | set solvers=(cbc) 7 | cd $solver_dir 8 | 9 | mkdir Windows64 10 | cd Windows64 11 | for %%x in %solvers% do ( 12 | curl "%cbc_repo%%cbc_version%/Cbc-releases.%cbc_version%-i686-w64-mingw32.zip" -O -J -L 13 | tar -xf Cbc-releases.%cbc_version%-i686-w64-mingw32.zip 14 | MOVE bin\* 15 | del Cbc-releases.%cbc_version%-i686-w64-mingw32.zip 16 | ) 17 | -------------------------------------------------------------------------------- /doper/solvers/setup_solvers.sh: -------------------------------------------------------------------------------- 1 | if [ "$1" ]; then 2 | solver_dir=$1 3 | else 4 | solver_dir="../solvers/" 5 | fi 6 | 7 | cbc_repo="https://github.com/coin-or/Cbc/releases/download/releases%2F" 8 | cbc_version="2.10.8" 9 | 10 | solvers="cbc" 11 | cd $solver_dir 12 | 13 | mkdir Linux64 14 | cd Linux64 15 | for s in $solvers 16 | do 17 | fname=Cbc-releases.${cbc_version}-x86_64-ubuntu18-gcc750-static.tar.gz 18 | wget ${cbc_repo}${cbc_version}/${fname} 19 | tar -xvzf ${fname} 20 | rm ${fname} 21 | mv bin/* . 22 | done 23 | cd .. -------------------------------------------------------------------------------- /doper/wrapper.py: -------------------------------------------------------------------------------- 1 | # Advanced Fenestration Controller (AFC) Copyright (c) 2023, The 2 | # Regents of the University of California, through Lawrence Berkeley 3 | # National Laboratory (subject to receipt of any required approvals 4 | # from the U.S. Dept. of Energy). All rights reserved. 5 | 6 | """"Advanced Fenestration Controller 7 | Wrapper module. 8 | """ 9 | 10 | # pylint: disable=wrong-import-position, invalid-name, redefined-outer-name, bare-except 11 | # pylint: disable=too-many-instance-attributes, too-many-arguments, logging-fstring-interpolation 12 | # pylint: disable=broad-exception-caught, dangerous-default-value, unused-variable 13 | # pylint: disable=too-many-locals, wrong-import-order 14 | 15 | import os 16 | import copy 17 | import logging 18 | from time import time 19 | import pandas as pd 20 | import pyutilib.subprocess.GlobalData 21 | 22 | from .utility import fix_bug_pyomo, check_solver, pyomo_read_parameter 23 | 24 | # Fix bug in pyomo when intializing solver (timeout after 5s) 25 | fix_bug_pyomo() 26 | 27 | # Check if solver was properly installed 28 | check_solver() 29 | 30 | from pyomo.opt import SolverFactory, TerminationCondition 31 | from .models.basemodel import default_output_list, generate_summary_metrics 32 | 33 | def get_root(f=None): 34 | """get the root of the module""" 35 | try: 36 | if not f: 37 | f = __file__ 38 | root = os.path.dirname(os.path.abspath(f)) 39 | except: 40 | root = os.getcwd() 41 | return root 42 | root = get_root() 43 | 44 | logger = logging.getLogger(__name__) 45 | 46 | class DOPER: 47 | """wrapper class for DOPER""" 48 | def __init__(self, model=None, parameter=None, solver_name=None, solver_path='ipopt', 49 | output_list=None, singnal_handle=False, debug=False, pyomo_logger=logging.WARNING): 50 | ''' 51 | Initialization function of the DOPER package. 52 | 53 | Input 54 | ----- 55 | model (function): The optimization model as a pyomo.environ.ConcreteModel function. 56 | parameter (dict): Configuration dictionary for the optimization. (default=None) 57 | solver_name (str): Name of the solver. (default=None) 58 | solver_path (str): Full path to the solver. (default='ipopt') 59 | pyomo_to_pandas (function): The function to convert the model output to a 60 | pandas.DataFrame. (default=None) 61 | singnal_handle (bool): Flag to turn on the Python signal handling. 62 | It is recommended to use False in multiprocessing applications. (default=False) 63 | debug (bool): Flag to enable debug mode. (default=False) 64 | ''' 65 | logging.getLogger('pyomo.core').setLevel(pyomo_logger) 66 | if model: 67 | if isinstance(model, type(lambda x:x)): 68 | self._model = model 69 | else: 70 | logger.error(f'The model is a type {type(model)}, not a type {type(lambda x:x)}') 71 | else: 72 | logger.error('No model function supplied.' + \ 73 | ' Please supply a pyomo.environ.ConcreteModel object.') 74 | self.model_loaded = False 75 | self.parameter = copy.deepcopy(parameter) 76 | self.solver_path = solver_path 77 | if solver_name is None: 78 | solver_name = os.path.split(solver_path)[-1].split('.')[0] 79 | self.solver_name = solver_name 80 | self.output_list = output_list 81 | self.singnal_handle = singnal_handle 82 | self.debug = debug 83 | self.signal_handling_toggle() 84 | 85 | self.model = None 86 | self.results_df = None 87 | self.summary = None 88 | 89 | def signal_handling_toggle(self): 90 | ''' 91 | Toggle the Python signal handling. 92 | 93 | It is recommended to use disable signal handling in multiprocessing applications. But 94 | this can lead to memory leak and Zombie processes. 95 | ''' 96 | pyutilib.subprocess.GlobalData.DEFINE_SIGNAL_HANDLERS_DEFAULT = self.singnal_handle 97 | 98 | def initialize_model(self, data): 99 | ''' 100 | Loads the model with its parameters. 101 | 102 | Input 103 | ----- 104 | data (pandas.DataFrame): The input dataframe for the optimization. 105 | ''' 106 | self.model = self._model(data, self.parameter) 107 | self.model_loaded = True 108 | 109 | def write_ts_results(self): 110 | ''' 111 | function dynamicly generates timeseries results dataframe, based on 112 | settings defined in parameter['system'] 113 | 114 | Parameters 115 | ---------- 116 | model : pyomo.core.base.PyomoModel.ConcreteModel 117 | solved pyomo model 118 | parameter : dict 119 | input parameter dict with 'system' field 120 | conversion_data: list of dicts 121 | list of dicts containing instructions 122 | for which pyomo timeseries params and vars to include in results df 123 | if none is provided, will be generated automatically 124 | 125 | Returns 126 | ------- 127 | df : pandas.core.frame.DataFrame 128 | DESCRIPTION. 129 | ''' 130 | 131 | # list of results data to be extracted 132 | # name: generic name of data 133 | # data: var or param object in pyomo model where data is extracted 134 | # df_label: label to be used for dataframe column 135 | # isVar: whether data is pyomo param or var 136 | # include: bool on whether to include data - dynamicly determined from 137 | # parameter['system'] 138 | 139 | model = self.model 140 | parameter = self.parameter 141 | output_list = self.output_list 142 | 143 | if output_list is None: 144 | output_list = default_output_list(self.parameter) 145 | 146 | # create empty df indexed by timestamps 147 | df = pd.DataFrame(model.ts.ordered_data(), columns = ['timestep']) 148 | df.set_index('timestep',inplace = True) 149 | 150 | # iterate through output instructions to add to df 151 | for outputItem in output_list: 152 | dfColName = outputItem['df_label'] = outputItem['df_label'] 153 | tsDataDict = getattr(model, outputItem['data']).extract_values() 154 | 155 | # if output is only indexed by timestamp, add to dataframe 156 | if 'index' not in outputItem: 157 | # create new df for output item 158 | itemDf = pd.DataFrame.from_dict(tsDataDict, orient='index', columns=[dfColName]) 159 | # if df is empty, replace with itemDf, else merge with existing df 160 | # if df.shape[0]==0: 161 | # df = itemDf 162 | # else: 163 | # if itemDf len is 0, something went wrong, skip item 164 | if itemDf.shape[0] == 0: 165 | logging.warning(f'Could not process output data for: {dfColName}') 166 | continue 167 | df = pd.merge(df, itemDf, left_index=True, right_index=True) 168 | else: 169 | # process data for multi-dim timeseries data 170 | # get items in second index set 171 | # index2 = list(getattr(model, outputItem['index']).keys()) 172 | index2 = list(getattr(model, outputItem['index']).ordered_data()) 173 | # iterate through index 2 values 174 | for ii in index2: 175 | # create indexed column name for df 176 | # first try string interpolation 177 | try: 178 | dfColNameIndexed = dfColName %ii 179 | except: 180 | # otherwise just append 181 | dfColNameIndexed = f'{dfColName}{ii}' 182 | # create empty dict 183 | dataDictIndexed = {} 184 | 185 | for val in tsDataDict.items(): 186 | 187 | # check value of second index, if matches ii, add to dict 188 | # val is in nested tuple form ((ts, index), value) 189 | if val[0][1] == ii: 190 | dataDictIndexed[val[0][0]] = val[1] 191 | 192 | # add new indexed ts dict to main df 193 | itemDf = pd.DataFrame.from_dict(dataDictIndexed, orient='index', 194 | columns=[dfColNameIndexed]) 195 | df = pd.merge(df, itemDf, left_index=True, right_index=True) 196 | 197 | # construct extracted data into pandas dataframe 198 | # df = pd.DataFrame(df).transpose() 199 | # df.columns = columns 200 | df.index = pd.to_datetime(df.index, unit='s') 201 | 202 | # add energy price 203 | if 'Tariff Energy Period [-]' in df.columns: 204 | df['Tariff Energy [$/kWh]'] = \ 205 | df[['Tariff Energy Period [-]']].replace(pyomo_read_parameter(model.tariff_energy)) 206 | self.results_df = df 207 | return df 208 | 209 | def do_optimization(self, data, parameter=None, tee=False, keepfiles=False, 210 | report_timing=False, options={}, print_error=True, 211 | other_valid_terminations=[TerminationCondition.maxTimeLimit], 212 | process_outputs=True): 213 | ''' 214 | Integrated function to conduct the optimization for control purposes. 215 | 216 | Input 217 | ----- 218 | data (pandas.DataFrame): The input dataframe for the optimization. 219 | parameter (dict): Configuration dictionary for the optimization. (default=None) 220 | tee (bool): Prints the solver output. (default=False) 221 | keepfiles (bool): Keeps the solver input and output files. (default=False) 222 | report_timing (bool): Print pyomo timings. (default=False) 223 | options (dict): Options to be set for solver. (default={}) 224 | print_error (bool): Log error messages. (default=True) 225 | other_valid_terminations (list): Valid Pyomo termination status to load solutions. 226 | process_outputs (bool): Process the outputs from Pyomo. (default=True) 227 | 228 | Returns 229 | ------- 230 | duration (float): Duration of the optimization. 231 | objective (float): Value of the objective function. 232 | df (pandas.DataFrame): The resulting dataframe with the optimization result. 233 | model (pyomo.environ.ConcreteModel): The optimized model. 234 | result (pyomo.opt.SolverFactory): The optimization result object. 235 | termination (str): Termination statement of optimization. 236 | ''' 237 | if parameter: 238 | # Update parameter, if supplied 239 | self.parameter = copy.deepcopy(parameter) 240 | #if not self.model_loaded: 241 | # Instantiate the model, if not already 242 | self.initialize_model(data) 243 | with SolverFactory(self.solver_name, executable=self.solver_path) as solver: 244 | t_start = time() 245 | for k in options.keys(): 246 | solver.options[k] = options[k] 247 | result = solver.solve(self.model, load_solutions=False, tee=tee, 248 | keepfiles=keepfiles, report_timing=report_timing) 249 | termination = result.solver.termination_condition 250 | if termination == TerminationCondition.optimal \ 251 | and 'cbc' in str(result.solver).lower() \ 252 | and not 'objective' in result.solver.message: 253 | termination = TerminationCondition.infeasible # CBC does not report infeasible 254 | if termination != TerminationCondition.optimal and print_error: 255 | logger.warning(f'Solver did not report optimality:\n{result.solver}') 256 | try: 257 | self.model.solutions.load_from(result) 258 | except Exception as e: 259 | if print_error: 260 | logger.warning(f'Could not load solutions:\n{e}') 261 | if termination in ([TerminationCondition.optimal] + other_valid_terminations) \ 262 | and process_outputs: 263 | objective = self.model.objective() 264 | df = self.write_ts_results() 265 | self.summary = generate_summary_metrics(self.model) 266 | else: 267 | objective = None 268 | df = pd.DataFrame() 269 | self.summary = None 270 | # if self.pyomo_to_pandas and termination == TerminationCondition.optimal: 271 | # df = self.pyomo_to_pandas(self.model, self.parameter) 272 | # else: 273 | # df = pd.DataFrame() 274 | return [time()-t_start, objective, df, self.model, result, termination, self.parameter] 275 | -------------------------------------------------------------------------------- /examples/DOPER_Template.py: -------------------------------------------------------------------------------- 1 | # Import standard modules 2 | import sys 3 | import pandas as pd 4 | from pyomo.environ import Objective, minimize 5 | 6 | # Append parent directory to import DOPER 7 | sys.path.append('..') 8 | 9 | # Import DOPER modules (only relevant ones to new model) 10 | from DOPER.basemodel import base_model, convert_base_model 11 | from DOPER.batterymodel import add_battery, convert_battery 12 | 13 | # Setup Optimization Model 14 | def control_model(inputs, parameter): 15 | model = base_model(inputs, parameter) 16 | model = add_battery(model, inputs, parameter) 17 | 18 | def objective_function(model): 19 | return model.sum_energy_cost * parameter['objective']['weight_energy'] \ 20 | + model.sum_demand_cost * parameter['objective']['weight_demand'] \ 21 | + model.sum_export_revenue * parameter['objective']['weight_export'] \ 22 | + model.sum_regulation_revenue * parameter['objective']['weight_regulation'] 23 | model.objective = Objective(rule=objective_function, sense=minimize, doc='objective function') 24 | return model 25 | 26 | # Setup Result Conversion Model 27 | def pyomo_to_pandas(model, parameter): 28 | df = convert_base_model(model, parameter) 29 | df = pd.concat([df, convert_battery(model, parameter)], axis=1) 30 | return df 31 | 32 | # Test it 33 | if __name__ == '__main__': 34 | import os 35 | # Import DOPER modules (relevant to execution and plotting) 36 | from DOPER.wrapper import DOPER 37 | from DOPER.utility import get_solver, get_root, standard_report 38 | from DOPER.example import example_parameter_evfleet, example_inputs 39 | from DOPER.basemodel import plot_standard1 40 | from DOPER.batterymodel import plot_battery1 41 | 42 | # Setup exmaple controller 43 | parameter = example_parameter_evfleet() 44 | data = example_inputs(parameter, load='B90', scale_load=150, scale_pv=100) 45 | solver_path = get_solver('cbc', solver_dir=os.path.join(get_root(), 'solvers')) 46 | smartDER = DOPER(model=control_model, 47 | parameter=parameter, 48 | solver_path=solver_path, 49 | pyomo_to_pandas=pyomo_to_pandas) 50 | 51 | # Conduct optimization 52 | res = smartDER.do_optimization(data) 53 | duration, objective, df, model, result, termination, parameter = res 54 | print(standard_report(res)) -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LBNL-ETA/DOPER/4ef83739d60984d2491ddf9eff6e83c9b91351d9/examples/__init__.py -------------------------------------------------------------------------------- /examples/test code - bug fixes.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import sys 4 | 5 | # Append parent directory to import DOPER 6 | sys.path.append('../src') 7 | 8 | from DOPER.wrapper import DOPER 9 | from DOPER.utility import get_solver, get_root, standard_report 10 | from DOPER.basemodel import base_model, default_output_list 11 | 12 | from DOPER.battery import add_battery 13 | from DOPER.genset import add_genset 14 | from DOPER.battery import add_battery 15 | from DOPER.loadControl import add_loadControl 16 | 17 | import DOPER.example as example 18 | 19 | from DOPER.plotting import plot_dynamic 20 | 21 | from pyomo.environ import Objective, minimize 22 | 23 | 24 | 25 | def control_model(inputs, parameter): 26 | model = base_model(inputs, parameter) 27 | model = add_battery(model, inputs, parameter) 28 | # model = add_genset(model, inputs, parameter) 29 | # model = add_loadControl(model, inputs, parameter) 30 | 31 | def objective_function(model): 32 | return model.sum_energy_cost * parameter['objective']['weight_energy'] \ 33 | + model.sum_demand_cost * parameter['objective']['weight_demand'] \ 34 | + model.sum_export_revenue * parameter['objective']['weight_export'] \ 35 | + model.fuel_cost_total * parameter['objective']['weight_energy'] \ 36 | + model.load_shed_cost_total \ 37 | + model.co2_total * parameter['objective']['weight_co2'] 38 | 39 | 40 | model.objective = Objective(rule=objective_function, sense=minimize, doc='objective function') 41 | return model 42 | 43 | ### PARAMETER & TS DEFS GO HERE: ### 44 | 45 | 46 | parameter = example.default_parameter() 47 | parameter = example.parameter_add_battery(parameter) 48 | 49 | data = example.ts_inputs(parameter, load='B90', scale_load=150, scale_pv=100) 50 | # offgrid_data = example.ts_inputs_offgrid(parameter) 51 | 52 | 53 | ### --- ### 54 | 55 | # generate standard output data 56 | # output_list = default_output_list(parameter) 57 | 58 | 59 | # Define the path to the solver executable 60 | solver_path = get_solver('cbc', solver_dir=os.path.join(get_root(), 'solvers')) 61 | # print(solver_path) 62 | 63 | # Initialize DOPER 64 | smartDER = DOPER(model=control_model, 65 | parameter=parameter, 66 | solver_path=solver_path) 67 | 68 | # Conduct optimization 69 | res = smartDER.do_optimization(data) 70 | 71 | # Get results 72 | duration, objective, df, model, result, termination, parameter = res 73 | print(standard_report(res)) 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /examples/test code - multinode.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Created on Wed Dec 9 14:00:49 2020 4 | 5 | @author: nicholas 6 | """ 7 | 8 | import os 9 | import sys 10 | import pandas as pd 11 | import matplotlib.pyplot as plt 12 | from pprint import pprint 13 | import logging 14 | import json 15 | 16 | logging.basicConfig( 17 | format='%(asctime)s %(levelname)-8s %(message)s', 18 | level=logging.INFO, 19 | datefmt='%Y-%m-%d %H:%M:%S') 20 | 21 | # Append parent directory to import DOPER 22 | sys.path.append('../src') 23 | 24 | from DOPER.wrapper import DOPER 25 | from DOPER.utility import get_solver, get_root, standard_report 26 | from DOPER.basemodel import base_model, default_output_list, dev_output_list 27 | # from DOPER.batterymodel import add_battery, convert_battery, plot_battery1 28 | from DOPER.battery import add_battery 29 | from DOPER.genset import add_genset 30 | from DOPER.loadControl import add_loadControl 31 | from DOPER.network import add_network, add_network_simple 32 | 33 | import DOPER.example as example 34 | 35 | from DOPER.plotting import plot_dynamic_nodes, formatExternalData 36 | 37 | 38 | from pyomo.environ import Objective, minimize 39 | 40 | def control_model(inputs, parameter): 41 | model = base_model(inputs, parameter) 42 | 43 | model = add_battery(model, inputs, parameter) 44 | model = add_genset(model, inputs, parameter) 45 | # model = add_loadControl(model, inputs, parameter) 46 | 47 | model = add_network_simple(model, inputs, parameter) 48 | 49 | 50 | def objective_function(model): 51 | return model.sum_energy_cost * parameter['objective']['weight_energy'] \ 52 | + model.sum_demand_cost * parameter['objective']['weight_demand'] \ 53 | + model.sum_export_revenue * parameter['objective']['weight_export'] \ 54 | + model.fuel_cost_total * parameter['objective']['weight_energy'] \ 55 | + model.load_shed_cost_total 56 | 57 | def objective_function_co2(model): 58 | return model.co2_total 59 | 60 | model.objective = Objective(rule=objective_function, sense=minimize, doc='objective function') 61 | return model 62 | 63 | parameter = None 64 | 65 | # add specific assets 66 | parameter = example.parameter_add_battery_multinode(parameter) 67 | parameter = example.parameter_add_genset_multinode(parameter) 68 | # parameter = example.parameter_add_loadcontrol_multinode_test(parameter) 69 | 70 | parameter = example.parameter_add_network_test(parameter) 71 | # parameter = example.parameter_add_network(parameter) 72 | 73 | 74 | 75 | 76 | 77 | 78 | # # reduce fuel prices for testing 79 | # parameter['fuels'][0]['rate'] = 0.5 80 | # parameter['fuels'][1]['rate'] = 0.5 81 | 82 | # data = example_inputs(parameter, load='B90', scale_load=150, scale_pv=100) 83 | data = example.ts_inputs_multinode_test(parameter) 84 | # data = example.ts_inputs_load_shed_multinode_test(parameter, data) 85 | 86 | # add external gen for testing 87 | # data['external_gen_1'] = 12.2 88 | # data['external_gen_2'] = 44.3 89 | 90 | # data1 = example.ts_inputs_variable_co2(parameter, data, scaling=[2,3,2,1]) 91 | # data2 = example.ts_inputs_variable_co2(parameter, data, scaling=[3,1,2,1]) 92 | # data = example.ts_inputs_fueloutage(parameter) 93 | # data = example.ts_inputs_load_shed(parameter) 94 | # add planned outage 95 | # data = example.ts_inputs_planned_outage(parameter, data) 96 | # data = example.ts_inputs_offgrid(parameter) 97 | #data 98 | 99 | # increase dmd charges for more interesting results 100 | # parameter['tariff']['demand'] = {0:0,1:0,2:0} 101 | # parameter['tariff']['demand_coincident'] = 25 102 | 103 | 104 | # output_list = default_output_list(parameter) 105 | # add individual battery charging to output list 106 | # output_list += [ 107 | # { 108 | # 'name': 'gridImport', 109 | # 'data': 'grid_import', 110 | # 'index': 'nodes', 111 | # 'df_label': 'Node grid import [kW]' 112 | # }, 113 | # { 114 | # 'name': 'gridExport', 115 | # 'data': 'grid_export', 116 | # 'index': 'nodes', 117 | # 'df_label': 'Node grid export [kW]' 118 | # }, 119 | # { 120 | # 'name': 'loadServed', 121 | # 'data': 'load_served', 122 | # 'index': 'nodes', 123 | # 'df_label': 'Node load served [kW]' 124 | # }, 125 | # { 126 | # 'name': 'pvGen', 127 | # 'data': 'generation_pv', 128 | # 'index': 'nodes', 129 | # 'df_label': 'Node pv gen [kW]' 130 | # }, 131 | # { 132 | # 'name': 'powerInj', 133 | # 'data': 'power_inj', 134 | # 'index': 'nodes', 135 | # 'df_label': 'Node power injected [kW]' 136 | # }, 137 | # { 138 | # 'name': 'powerAbs', 139 | # 'data': 'power_abs', 140 | # 'index': 'nodes', 141 | # 'df_label': 'Node power absorbed [kW]' 142 | # }, 143 | # { 144 | # 'name': 'gensetGen', 145 | # 'data': 'sum_genset_power', 146 | # 'index': 'nodes', 147 | # 'df_label': 'Node genset gen [kW]' 148 | # } 149 | 150 | 151 | # ] 152 | 153 | output_list = dev_output_list(parameter) 154 | 155 | # output_list += [{ 156 | # 'name': 'batSoc', 157 | # 'data': 'battery_agg_soc', 158 | # 'df_label': 'battery_SOC_agg' 159 | # }] 160 | 161 | # Define the path to the solver executable 162 | solver_path = get_solver('cbc', solver_dir=os.path.join(get_root(), 'solvers')) 163 | print(solver_path) 164 | # Initialize DOPER 165 | smartDER = DOPER(model=control_model, 166 | parameter=parameter, 167 | solver_path=solver_path, 168 | output_list=output_list) 169 | 170 | # Conduct optimization 171 | res = smartDER.do_optimization(data) 172 | 173 | # Get results 174 | duration, objective, df, model, result, termination, parameter = res 175 | print(standard_report(res)) 176 | 177 | # for t in model.ts: 178 | # print(model.sum_battery_charge_grid_power[t].value) 179 | 180 | df.to_csv('test_results/test_results.csv') 181 | 182 | # plotData = plot_dynamic(df, parameter, plotFile = 'test_results/test_results.png', plot_reg=False) 183 | # plotData.savefig('test_results.png') 184 | 185 | # try: 186 | # plotData = plot_dynamic_nodes(df, parameter, plotFile = 'test_results/test_results_NODES.png') 187 | # # plotData.savefig('test_results.png') 188 | # except Exception as e: 189 | # print(e) 190 | 191 | # reformat ts data for R plotting 192 | # df2 = formatExternalData(df) 193 | # df2.to_csv('test_results/test_results_R.csv') 194 | 195 | # # dump power flow data to csv 196 | # pfData = [['ind', 'ts', 'n1', 'n2', 'line', 'val']] 197 | # rawData = model.line_power_real.extract_values() 198 | # for ii, ts in enumerate(model.ts.ordered_data()): 199 | 200 | # for n1 in model.nodes.ordered_data(): 201 | 202 | # for n2 in model.nodes.ordered_data(): 203 | 204 | # val = rawData[(ts, n1, n2)] 205 | # line = f'{n1}-{n2}' 206 | 207 | # dataRow = [ii, ts, n1, n2, line, val] 208 | 209 | # pfData.append(dataRow) 210 | 211 | # with open ('test_results/pf_data.csv', 'w') as fo: 212 | # for row in pfData: 213 | # row = [str(val) for val in row] 214 | # row = ','.join(row) 215 | # fo.write(row + '\n') 216 | 217 | # data.to_csv('test_results/test_inputs_multinode.csv') 218 | 219 | # with open("input_parameter_multinode.json", "w") as outfile: 220 | # json.dump(parameter, outfile) 221 | 222 | def getVals(model, varName): 223 | 224 | vals = getattr(model, varName).extract_values().values() 225 | n = len(vals) 226 | 227 | return { 228 | 'name': varName, 229 | 'sum': int(sum(vals)), 230 | 'mean': int(sum(vals)/float(n)), 231 | 'min': min(vals), 232 | 'max': int(max(vals)), 233 | } 234 | 235 | 236 | varList = [ 237 | 'load_served_site', 'generation_pv_site', 238 | 'grid_import_site', 'grid_export_site', 239 | 'powerExchangeIn', 'powerExchangeOut' 240 | ] 241 | 242 | for vv in varList: 243 | print(getVals(model, vv)) 244 | -------------------------------------------------------------------------------- /examples/test code - singlenode.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import sys 4 | 5 | # Append parent directory to import DOPER 6 | sys.path.append('../src') 7 | 8 | from DOPER.wrapper import DOPER 9 | from DOPER.utility import get_solver, get_root, standard_report 10 | from DOPER.basemodel import base_model, default_output_list 11 | 12 | from DOPER.battery import add_battery 13 | from DOPER.genset import add_genset 14 | from DOPER.loadControl import add_loadControl 15 | 16 | import DOPER.example as example 17 | 18 | from DOPER.plotting import plot_dynamic 19 | 20 | from pyomo.environ import Objective, minimize 21 | 22 | 23 | 24 | def control_model(inputs, parameter): 25 | model = base_model(inputs, parameter) 26 | model = add_battery(model, inputs, parameter) 27 | # model = add_genset(model, inputs, parameter) 28 | # model = add_loadControl(model, inputs, parameter) 29 | 30 | def objective_function(model): 31 | return model.sum_energy_cost * parameter['objective']['weight_energy'] \ 32 | + model.sum_demand_cost * parameter['objective']['weight_demand'] \ 33 | + model.sum_export_revenue * parameter['objective']['weight_export'] \ 34 | + model.fuel_cost_total * parameter['objective']['weight_energy'] \ 35 | + model.load_shed_cost_total \ 36 | + model.co2_total * parameter['objective']['weight_co2'] 37 | 38 | 39 | model.objective = Objective(rule=objective_function, sense=minimize, doc='objective function') 40 | return model 41 | 42 | # generate input parameter and data 43 | parameter = example.test_default_parameter() 44 | parameter = example.test_parameter_add_battery(parameter) 45 | # parameter = example.parameter_add_genset(parameter) 46 | # parameter = example.parameter_add_loadcontrol(parameter) 47 | 48 | data = example.ts_inputs(parameter, load='B90', scale_load=150, scale_pv=100) 49 | # data = example.ts_inputs_planned_outage(parameter, data) 50 | # data = example.ts_inputs_load_shed(parameter, data) 51 | 52 | # add simple external_gen for testing 53 | 54 | # data['external_gen'] = 12.2 55 | 56 | # generate standard output data 57 | output_list = default_output_list(parameter) 58 | 59 | 60 | # Define the path to the solver executable 61 | solver_path = get_solver('cbc', solver_dir=os.path.join(get_root(), 'solvers')) 62 | # print(solver_path) 63 | 64 | # Initialize DOPER 65 | smartDER = DOPER(model=control_model, 66 | parameter=parameter, 67 | solver_path=solver_path, 68 | output_list=output_list) 69 | 70 | # Conduct optimization 71 | res = smartDER.do_optimization(data) 72 | 73 | # Get results 74 | duration, objective, df, model, result, termination, parameter = res 75 | print(standard_report(res)) 76 | 77 | # plotData = plot_dynamic(df, parameter, plotFile = None, plot_reg=False) 78 | 79 | -------------------------------------------------------------------------------- /examples/test code.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Created on Wed Dec 9 14:00:49 2020 4 | 5 | @author: nicholas 6 | """ 7 | 8 | import os 9 | import sys 10 | import pandas as pd 11 | import matplotlib.pyplot as plt 12 | from pprint import pprint 13 | import logging 14 | 15 | logging.basicConfig( 16 | format='%(asctime)s %(levelname)-8s %(message)s', 17 | level=logging.INFO, 18 | datefmt='%Y-%m-%d %H:%M:%S') 19 | 20 | # Append parent directory to import DOPER 21 | sys.path.append('../src') 22 | 23 | from DOPER.wrapper import DOPER 24 | from DOPER.utility import get_solver, get_root, standard_report 25 | from DOPER.basemodel import base_model, default_output_list 26 | # from DOPER.batterymodel import add_battery, convert_battery, plot_battery1 27 | from DOPER.battery import add_battery 28 | from DOPER.genset import add_genset 29 | from DOPER.loadControl import add_loadControl 30 | from DOPER.example import (example_inputs, example_parameter_add_genset, 31 | example_inputs_offgrid, example_inputs_planned_outage, 32 | example_parameter_add_battery, example_parameter_add_loadcontrol, 33 | example_inputs_load_shed, example_inputs_fueloutage, example_inputs_variable_co2) 34 | 35 | 36 | from pyomo.environ import Objective, minimize 37 | 38 | def control_model(inputs, parameter): 39 | model = base_model(inputs, parameter) 40 | model = add_battery(model, inputs, parameter) 41 | model = add_genset(model, inputs, parameter) 42 | # model = add_loadControl(model, inputs, parameter) 43 | # model = add_battery(model, inputs, parameter) 44 | 45 | def objective_function(model): 46 | return model.sum_energy_cost * parameter['objective']['weight_energy'] \ 47 | + model.sum_demand_cost * parameter['objective']['weight_demand'] \ 48 | + model.sum_export_revenue * parameter['objective']['weight_export'] \ 49 | + model.sum_regulation_revenue * parameter['objective']['weight_regulation'] \ 50 | + model.fuel_cost_total * parameter['objective']['weight_energy'] \ 51 | + model.load_shed_cost_total 52 | 53 | def objective_function_co2(model): 54 | return model.co2_total 55 | 56 | model.objective = Objective(rule=objective_function_co2, sense=minimize, doc='objective function') 57 | return model 58 | 59 | parameter = None 60 | # parameter = example_parameter_evfleet() 61 | parameter = example_parameter_add_genset(parameter) 62 | # parameter = example_parameter_add_loadcontrol(parameter) 63 | parameter = example_parameter_add_battery(parameter) 64 | #parameter 65 | 66 | data = example_inputs(parameter, load='B90', scale_load=150, scale_pv=100) 67 | data1 = example_inputs_variable_co2(parameter, data, scaling=[2,3,2,1]) 68 | data2 = example_inputs_variable_co2(parameter, data, scaling=[3,1,2,1]) 69 | # data = example_inputs_fueloutage(parameter) 70 | # data = example_inputs_load_shed(parameter) 71 | # add planned outage 72 | # data = example_inputs_planned_outage(parameter, data) 73 | # data = example_inputs_offgrid(parameter) 74 | #data 75 | 76 | 77 | output_list = default_output_list(parameter) 78 | # add individual battery charging to output list 79 | output_list.append({ 80 | 'name': 'batSOC', 81 | 'data': 'battery_energy', 82 | 'index': 'batteries', 83 | 'df_label': 'Energy in Battery %s' 84 | }) 85 | 86 | # Define the path to the solver executable 87 | solver_path = get_solver('cbc', solver_dir=os.path.join(get_root(), 'solvers')) 88 | print(solver_path) 89 | # Initialize DOPER 90 | smartDER = DOPER(model=control_model, 91 | parameter=parameter, 92 | solver_path=solver_path, 93 | output_list=output_list) 94 | 95 | # Conduct optimization 96 | res = smartDER.do_optimization(data) 97 | 98 | # Get results 99 | duration, objective, df, model, result, termination, parameter = res 100 | print(standard_report(res)) 101 | 102 | # for t in model.ts: 103 | # print(model.sum_battery_charge_grid_power[t].value) 104 | 105 | df.to_csv('test_results/test_results.csv') 106 | 107 | plotData = plot_dynamic(df, parameter, plotFile = 'test_results/test_results.png', plot_reg=False) 108 | # plotData.savefig('test_results.png') -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | Distributed Optimal and Predictive Energy Resources (DOPER) Copyright (c) 2019, The 2 | Regents of the University of California, through Lawrence Berkeley National 3 | Laboratory (subject to receipt of any required approvals from the U.S. 4 | Dept. of Energy). All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | (1) Redistributions of source code must retain the above copyright notice, 10 | this list of conditions and the following disclaimer. 11 | 12 | (2) Redistributions in binary form must reproduce the above copyright 13 | notice, this list of conditions and the following disclaimer in the 14 | documentation and/or other materials provided with the distribution. 15 | 16 | (3) Neither the name of the University of California, Lawrence Berkeley 17 | National Laboratory, U.S. Dept. of Energy nor the names of its contributors 18 | may be used to endorse or promote products derived from this software 19 | without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 24 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 25 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 26 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 27 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 28 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 29 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 30 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 31 | POSSIBILITY OF SUCH DAMAGE. 32 | 33 | You are under no obligation whatsoever to provide any bug fixes, patches, 34 | or upgrades to the features, functionality or performance of the source 35 | code ("Enhancements") to anyone; however, if you choose to make your 36 | Enhancements available either publicly, or directly to Lawrence Berkeley 37 | National Laboratory, without imposing a separate written license agreement 38 | for such Enhancements, then you hereby grant the following license: a 39 | non-exclusive, royalty-free perpetual license to install, use, modify, 40 | prepare derivative works, incorporate into other computer software, 41 | distribute, and sublicense such enhancements or derivative works thereof, 42 | in binary and source code form. -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | pandas 3 | pyomo>6.0 4 | matplotlib 5 | fmlc 6 | pyutilib 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Setup file for the Distributed Optimal and Predictive Energy Resources. 3 | """ 4 | 5 | import os 6 | import sys 7 | import json 8 | import setuptools 9 | import subprocess as sp 10 | 11 | root = os.path.dirname(os.path.abspath(__file__)) 12 | 13 | INSTALL_SOLVERS = True 14 | 15 | # description 16 | with open('README.md', 'r', encoding='utf8') as f: 17 | long_description = f.read() 18 | 19 | # requirements 20 | with open('requirements.txt', 'r', encoding='utf8') as f: 21 | install_requires = f.read().splitlines() 22 | 23 | # version 24 | with open('doper/__init__.py', 'r', encoding='utf8') as f: 25 | version = json.loads(f.read().split('__version__ = ')[1].split('\n')[0]) 26 | 27 | # setup solvers 28 | if INSTALL_SOLVERS: 29 | print('Installing Solvers...') 30 | if not 'win' in sys.platform: 31 | sp.call('sh setup_solvers.sh', shell=True, cwd=os.path.join(root, 'doper', 'solvers')) 32 | else: 33 | sp.call('setup_solvers.bat', shell=True, cwd=os.path.join(root, 'doper', 'solvers')) 34 | print('done.') 35 | 36 | setuptools.setup( 37 | name="DOPER", 38 | version=version, 39 | author="Gehbauer, Christoph", 40 | description="Distributed Optimal and Predictive Energy Resources", 41 | long_description=long_description, 42 | long_description_content_type="text/markdown", 43 | license_files = ['license.txt'], 44 | url="https://github.com/LBNL-ETA/DOPER", 45 | project_urls={ 46 | "Bug Tracker": "https://github.com/LBNL-ETA/DOPER/issues", 47 | }, 48 | classifiers=[ 49 | "Programming Language :: Python :: 3", 50 | "Operating System :: OS Independent", 51 | ], 52 | packages=['doper'], 53 | package_data={'': ['*.txt.', '*.md'], 54 | 'doper': ['models/*', 55 | 'solvers/*', 56 | 'solvers/Linux64/*', 57 | 'solvers/Windows64/*', 58 | 'data/*', 59 | 'examples/*', 60 | 'resources/*', 61 | 'resources/pvlib/*']}, 62 | include_package_data=True, 63 | python_requires=">=3.6", 64 | install_requires=install_requires 65 | ) 66 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is the DOPER test module. 3 | """ -------------------------------------------------------------------------------- /test/run_tests.sh: -------------------------------------------------------------------------------- 1 | pip3 -qq install pylint flake8 pytest 2 | 3 | echo "Running pylint" 4 | cd ../doper 5 | pylint $(find . -name "*.py" -not -path "*/interface/*") 6 | 7 | echo "Running pytest" 8 | pytest -------------------------------------------------------------------------------- /test/test_basemodel.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | from pyomo.environ import Objective, minimize 5 | 6 | from doper import DOPER, get_solver, get_root 7 | from doper.models.basemodel import base_model, default_output_list 8 | import doper.examples as example 9 | 10 | class TestBaseModel(unittest.TestCase): 11 | ''' 12 | 13 | unit tests for running DOPER base model. 14 | test setup is configured to only run optimization on first test, 15 | then test the various outputs from initiali optimization in subsequent tests 16 | 17 | does not test for proper error handling for incorrect use of basemodel optimization. 18 | 19 | 20 | ''' 21 | 22 | # set initial setUpComplete flag to false, so optimization is run on first test 23 | setupComplete = False 24 | # define acceptable delta when comparing objective and other vars 25 | tolerance = 0.05 26 | 27 | def setUp(self): 28 | ''' 29 | setUp method is run before each test. 30 | Only one optimization is needed for all tests, so if self.setupComplete 31 | is True, the optimization step is skipped 32 | 33 | ''' 34 | 35 | if not hasattr(self, 'setupComplete'): 36 | print('Initializing test: running optimization') 37 | self.runOptimization() 38 | else: 39 | if self.setupComplete is False: 40 | print('Initializing test: running optimization') 41 | self.runOptimization() 42 | else: 43 | print('Test optimization has been completed') 44 | 45 | # define expected objective 46 | self.__class__.expObjective = 5500 47 | self.__class__.objTolerance = self.expObjective * self.tolerance 48 | 49 | 50 | def runOptimization(self): 51 | ''' 52 | run optimization and store outputs as attributes of the class 53 | 54 | ''' 55 | 56 | def control_model(inputs, parameter): 57 | model = base_model(inputs, parameter) 58 | 59 | def objective_function(model): 60 | return model.sum_energy_cost * parameter['objective']['weight_energy'] \ 61 | + model.sum_demand_cost * parameter['objective']['weight_demand'] \ 62 | + model.sum_export_revenue * parameter['objective']['weight_export'] \ 63 | + model.fuel_cost_total * parameter['objective']['weight_energy'] \ 64 | + model.load_shed_cost_total \ 65 | + model.co2_total * parameter['objective']['weight_co2'] 66 | 67 | 68 | model.objective = Objective(rule=objective_function, sense=minimize, doc='objective function') 69 | return model 70 | 71 | # generate input parameter and data 72 | parameter = example.test_default_parameter() 73 | data = example.ts_inputs(parameter, load='B90', scale_load=150, scale_pv=100) 74 | 75 | # generate standard output data 76 | output_list = default_output_list(parameter) 77 | 78 | 79 | # Define the path to the solver executable 80 | solver_path = get_solver('cbc') 81 | 82 | # Initialize DOPER 83 | smartDER = DOPER(model=control_model, 84 | parameter=parameter, 85 | solver_path=solver_path, 86 | output_list=output_list) 87 | 88 | # Conduct optimization 89 | res = smartDER.do_optimization(data) 90 | 91 | # Get results 92 | duration, objective, df, model, result, termination, parameter = res 93 | 94 | # package model results into unit test class attributes 95 | self.__class__.duration = duration 96 | self.__class__.objective = objective 97 | self.__class__.df = df 98 | self.__class__.model = model 99 | self.__class__.result = result 100 | self.__class__.termination = termination 101 | self.__class__.parameter = parameter 102 | 103 | # change setupComplete to True 104 | self.__class__.setupComplete = True 105 | 106 | 107 | # check that setup optimization has created expected result objects 108 | def test_exists_duration(self): 109 | self.assertTrue(hasattr(self, 'duration'), msg='duration does not exist') 110 | 111 | def test_exists_objective(self): 112 | self.assertTrue(hasattr(self, 'objective'), msg='objective does not exist') 113 | 114 | def test_exists_df(self): 115 | self.assertTrue(hasattr(self, 'df'), msg='df does not exist') 116 | 117 | def test_exists_model(self): 118 | self.assertTrue(hasattr(self, 'model'), msg='model does not exist') 119 | 120 | def test_exists_termination(self): 121 | self.assertTrue(hasattr(self, 'termination'), msg='termination does not exist') 122 | 123 | # check that duration is nonzero 124 | def test_duration_nonxero(self): 125 | self.assertGreater(self.duration, 0, msg='duration is zero') 126 | 127 | # check that objective is close to expected objective value 128 | def test_obj_tolerance(self): 129 | self.assertAlmostEqual(self.objective, self.expObjective, msg='objective does not match expected with 5%', delta=self.objTolerance) 130 | 131 | # check that model contains key pyomo vars as attributes 132 | def test_pyomo_has_imports(self): 133 | self.assertTrue(hasattr(self.model, 'grid_import'), msg='pyomo model is missing key var: grid_import') 134 | 135 | 136 | if __name__ == '__main__': 137 | unittest.main() -------------------------------------------------------------------------------- /test/test_basemodel_multinode.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | from pyomo.environ import Objective, minimize 5 | 6 | from doper import DOPER, get_solver, get_root 7 | from doper.models.basemodel import base_model, default_output_list 8 | from doper.models.network import add_network_simple 9 | import doper.examples as example 10 | 11 | class TestBaseModel(unittest.TestCase): 12 | ''' 13 | 14 | unit tests for running DOPER base model. 15 | test setup is configured to only run optimization on first test, 16 | then test the various outputs from initiali optimization in subsequent tests 17 | 18 | does not test for proper error handling for incorrect use of basemodel optimization. 19 | 20 | 21 | ''' 22 | 23 | # set initial setUpComplete flag to false, so optimization is run on first test 24 | setupComplete = False 25 | # define acceptable delta when comparing objective and other vars 26 | tolerance = 0.05 27 | 28 | def setUp(self): 29 | ''' 30 | setUp method is run before each test. 31 | Only one optimization is needed for all tests, so if self.setupComplete 32 | is True, the optimization step is skipped 33 | 34 | ''' 35 | 36 | if not hasattr(self, 'setupComplete'): 37 | print('Initializing test: running optimization') 38 | self.runOptimization() 39 | else: 40 | if self.setupComplete is False: 41 | print('Initializing test: running optimization') 42 | self.runOptimization() 43 | else: 44 | print('Test optimization has been completed') 45 | 46 | # define expected objective 47 | self.__class__.expObjective = 29900 48 | self.__class__.objTolerance = self.expObjective * self.tolerance 49 | 50 | 51 | def runOptimization(self): 52 | ''' 53 | run optimization and store outputs as attributes of the class 54 | 55 | ''' 56 | 57 | def control_model(inputs, parameter): 58 | model = base_model(inputs, parameter) 59 | model = add_network_simple(model, inputs, parameter) 60 | 61 | def objective_function(model): 62 | return model.sum_energy_cost * parameter['objective']['weight_energy'] \ 63 | + model.sum_demand_cost * parameter['objective']['weight_demand'] \ 64 | + model.sum_export_revenue * parameter['objective']['weight_export'] \ 65 | + model.fuel_cost_total * parameter['objective']['weight_energy'] \ 66 | + model.load_shed_cost_total \ 67 | + model.co2_total * parameter['objective']['weight_co2'] 68 | 69 | 70 | model.objective = Objective(rule=objective_function, sense=minimize, doc='objective function') 71 | return model 72 | 73 | # generate input parameter and data 74 | parameter = example.parameter_add_network_test() 75 | data = example.ts_inputs_multinode_test(parameter) 76 | 77 | # generate standard output data 78 | output_list = default_output_list(parameter) 79 | 80 | 81 | # Define the path to the solver executable 82 | solver_path = get_solver('cbc') 83 | 84 | # Initialize DOPER 85 | smartDER = DOPER(model=control_model, 86 | parameter=parameter, 87 | solver_path=solver_path, 88 | output_list=output_list) 89 | 90 | # Conduct optimization 91 | res = smartDER.do_optimization(data) 92 | 93 | # Get results 94 | duration, objective, df, model, result, termination, parameter = res 95 | 96 | # package model results into unit test class attributes 97 | self.__class__.duration = duration 98 | self.__class__.objective = objective 99 | self.__class__.df = df 100 | self.__class__.model = model 101 | self.__class__.result = result 102 | self.__class__.termination = termination 103 | self.__class__.parameter = parameter 104 | 105 | # change setupComplete to True 106 | self.__class__.setupComplete = True 107 | 108 | # print(f'obj: {objective}') 109 | 110 | 111 | # check that setup optimization has created expected result objects 112 | def test_exists_duration(self): 113 | self.assertTrue(hasattr(self, 'duration'), msg='duration does not exist') 114 | 115 | def test_exists_objective(self): 116 | self.assertTrue(hasattr(self, 'objective'), msg='objective does not exist') 117 | 118 | def test_exists_df(self): 119 | self.assertTrue(hasattr(self, 'df'), msg='df does not exist') 120 | 121 | def test_exists_model(self): 122 | self.assertTrue(hasattr(self, 'model'), msg='model does not exist') 123 | 124 | def test_exists_termination(self): 125 | self.assertTrue(hasattr(self, 'termination'), msg='termination does not exist') 126 | 127 | # check that model contains correct number of nodes 128 | def test_node_count(self): 129 | nNodesIn = len(self.parameter['network']['nodes']) 130 | nNodesOut = len(self.model.nodes.ordered_data()) 131 | self.assertEqual(nNodesIn, nNodesOut, msg='model node count incorrect') 132 | 133 | # check that duration is nonzero 134 | def test_duration_nonxero(self): 135 | self.assertGreater(self.duration, 0, msg='duration is zero') 136 | 137 | # check that objective is close to expected objective value 138 | def test_obj_tolerance(self): 139 | self.assertAlmostEqual(self.objective, self.expObjective, msg='objective does not match expected with 5%', delta=self.objTolerance) 140 | 141 | # check that model contains key pyomo vars as attributes 142 | def test_pyomo_has_imports(self): 143 | self.assertTrue(hasattr(self.model, 'grid_import'), msg='pyomo model is missing key var: grid_import') 144 | 145 | 146 | if __name__ == '__main__': 147 | unittest.main() -------------------------------------------------------------------------------- /test/test_battery.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | from pyomo.environ import Objective, minimize 5 | 6 | from doper import DOPER, get_solver, get_root 7 | from doper.models.basemodel import base_model, default_output_list 8 | from doper.models.battery import add_battery 9 | import doper.examples as example 10 | 11 | class TestBaseModel(unittest.TestCase): 12 | ''' 13 | 14 | unit tests for running DOPER base model. 15 | test setup is configured to only run optimization on first test, 16 | then test the various outputs from initiali optimization in subsequent tests 17 | 18 | does not test for proper error handling for incorrect use of basemodel optimization. 19 | 20 | 21 | ''' 22 | 23 | # set initial setUpComplete flag to false, so optimization is run on first test 24 | setupComplete = False 25 | # define acceptable delta when comparing objective and other vars 26 | tolerance = 0.05 27 | 28 | def setUp(self): 29 | ''' 30 | setUp method is run before each test. 31 | Only one optimization is needed for all tests, so if self.setupComplete 32 | is True, the optimization step is skipped 33 | 34 | ''' 35 | 36 | if not hasattr(self, 'setupComplete'): 37 | print('Initializing test: running optimization') 38 | self.runOptimization() 39 | else: 40 | if self.setupComplete is False: 41 | print('Initializing test: running optimization') 42 | self.runOptimization() 43 | else: 44 | print('Test optimization has been completed') 45 | 46 | # define expected objective 47 | self.__class__.expObjective = 3756 48 | self.__class__.objTolerance = self.expObjective * self.tolerance 49 | 50 | 51 | def runOptimization(self): 52 | ''' 53 | run optimization and store outputs as attributes of the class 54 | 55 | ''' 56 | 57 | def control_model(inputs, parameter): 58 | model = base_model(inputs, parameter) 59 | model = add_battery(model, inputs, parameter) 60 | 61 | def objective_function(model): 62 | return model.sum_energy_cost * parameter['objective']['weight_energy'] \ 63 | + model.sum_demand_cost * parameter['objective']['weight_demand'] \ 64 | + model.sum_export_revenue * parameter['objective']['weight_export'] \ 65 | + model.fuel_cost_total * parameter['objective']['weight_energy'] \ 66 | + model.load_shed_cost_total \ 67 | + model.co2_total * parameter['objective']['weight_co2'] 68 | 69 | 70 | model.objective = Objective(rule=objective_function, sense=minimize, doc='objective function') 71 | return model 72 | 73 | # generate input parameter and data 74 | parameter = example.test_default_parameter() 75 | parameter = example.test_parameter_add_battery(parameter) 76 | data = example.ts_inputs(parameter, load='B90', scale_load=150, scale_pv=100) 77 | 78 | # generate standard output data 79 | output_list = default_output_list(parameter) 80 | 81 | 82 | # Define the path to the solver executable 83 | solver_path = get_solver('cbc') 84 | 85 | # Initialize DOPER 86 | smartDER = DOPER(model=control_model, 87 | parameter=parameter, 88 | solver_path=solver_path, 89 | output_list=output_list) 90 | 91 | # Conduct optimization 92 | res = smartDER.do_optimization(data) 93 | 94 | # Get results 95 | duration, objective, df, model, result, termination, parameter = res 96 | 97 | # package model results into unit test class attributes 98 | self.__class__.duration = duration 99 | self.__class__.objective = objective 100 | self.__class__.df = df 101 | self.__class__.model = model 102 | self.__class__.result = result 103 | self.__class__.termination = termination 104 | self.__class__.parameter = parameter 105 | 106 | # change setupComplete to True 107 | self.__class__.setupComplete = True 108 | 109 | 110 | # check that setup optimization has created expected result objects 111 | def test_exists_duration(self): 112 | self.assertTrue(hasattr(self, 'duration'), msg='duration does not exist') 113 | 114 | def test_exists_objective(self): 115 | self.assertTrue(hasattr(self, 'objective'), msg='objective does not exist') 116 | 117 | def test_exists_df(self): 118 | self.assertTrue(hasattr(self, 'df'), msg='df does not exist') 119 | 120 | def test_exists_model(self): 121 | self.assertTrue(hasattr(self, 'model'), msg='model does not exist') 122 | 123 | def test_exists_termination(self): 124 | self.assertTrue(hasattr(self, 'termination'), msg='termination does not exist') 125 | 126 | # check that duration is nonzero 127 | def test_duration_nonxero(self): 128 | self.assertGreater(self.duration, 0, msg='duration is zero') 129 | 130 | # check that objective is close to expected objective value 131 | def test_obj_tolerance(self): 132 | self.assertAlmostEqual(self.objective, self.expObjective, msg='objective does not match expected with 5%', delta=self.objTolerance) 133 | 134 | # check that model contains key pyomo vars as attributes 135 | def test_pyomo_has_imports(self): 136 | self.assertTrue(hasattr(self.model, 'grid_import'), msg='pyomo model is missing key var: grid_import') 137 | 138 | def test_pyomo_has_battery_soc(self): 139 | self.assertTrue(hasattr(self.model, 'battery_soc'), msg='pyomo model is missing key var: battery_soc') 140 | 141 | 142 | if __name__ == '__main__': 143 | unittest.main() -------------------------------------------------------------------------------- /test/test_battery_multinode.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | from pyomo.environ import Objective, minimize 5 | 6 | from doper import DOPER, get_solver, get_root 7 | from doper.models.basemodel import base_model, default_output_list 8 | from doper.models.battery import add_battery 9 | from doper.models.network import add_network_simple 10 | import doper.examples as example 11 | 12 | class TestBaseModel(unittest.TestCase): 13 | ''' 14 | 15 | unit tests for running DOPER base model. 16 | test setup is configured to only run optimization on first test, 17 | then test the various outputs from initiali optimization in subsequent tests 18 | 19 | does not test for proper error handling for incorrect use of basemodel optimization. 20 | 21 | 22 | ''' 23 | 24 | # set initial setUpComplete flag to false, so optimization is run on first test 25 | setupComplete = False 26 | # define acceptable delta when comparing objective and other vars 27 | tolerance = 0.05 28 | 29 | def setUp(self): 30 | ''' 31 | setUp method is run before each test. 32 | Only one optimization is needed for all tests, so if self.setupComplete 33 | is True, the optimization step is skipped 34 | 35 | ''' 36 | 37 | if not hasattr(self, 'setupComplete'): 38 | print('Initializing test: running optimization') 39 | self.runOptimization() 40 | else: 41 | if self.setupComplete is False: 42 | print('Initializing test: running optimization') 43 | self.runOptimization() 44 | else: 45 | print('Test optimization has been completed') 46 | 47 | # define expected objective 48 | self.__class__.expObjective = 20500 49 | self.__class__.objTolerance = self.expObjective * self.tolerance 50 | 51 | 52 | def runOptimization(self): 53 | ''' 54 | run optimization and store outputs as attributes of the class 55 | 56 | ''' 57 | 58 | def control_model(inputs, parameter): 59 | model = base_model(inputs, parameter) 60 | model = add_network_simple(model, inputs, parameter) 61 | model = add_battery(model, inputs, parameter) 62 | 63 | def objective_function(model): 64 | return model.sum_energy_cost * parameter['objective']['weight_energy'] \ 65 | + model.sum_demand_cost * parameter['objective']['weight_demand'] \ 66 | + model.sum_export_revenue * parameter['objective']['weight_export'] \ 67 | + model.fuel_cost_total * parameter['objective']['weight_energy'] \ 68 | + model.load_shed_cost_total \ 69 | + model.co2_total * parameter['objective']['weight_co2'] 70 | 71 | 72 | model.objective = Objective(rule=objective_function, sense=minimize, doc='objective function') 73 | return model 74 | 75 | # generate input parameter and data 76 | parameter = example.parameter_add_network_test() 77 | parameter = example.parameter_add_battery_multinode_test(parameter) 78 | data = example.ts_inputs_multinode_test(parameter) 79 | 80 | # generate standard output data 81 | output_list = default_output_list(parameter) 82 | 83 | 84 | # Define the path to the solver executable 85 | solver_path = get_solver('cbc') 86 | 87 | # Initialize DOPER 88 | smartDER = DOPER(model=control_model, 89 | parameter=parameter, 90 | solver_path=solver_path, 91 | output_list=output_list) 92 | 93 | # Conduct optimization 94 | res = smartDER.do_optimization(data) 95 | 96 | # Get results 97 | duration, objective, df, model, result, termination, parameter = res 98 | 99 | # package model results into unit test class attributes 100 | self.__class__.duration = duration 101 | self.__class__.objective = objective 102 | self.__class__.df = df 103 | self.__class__.model = model 104 | self.__class__.result = result 105 | self.__class__.termination = termination 106 | self.__class__.parameter = parameter 107 | 108 | # change setupComplete to True 109 | self.__class__.setupComplete = True 110 | 111 | # print(f'obj: {objective}') 112 | 113 | 114 | # check that setup optimization has created expected result objects 115 | def test_exists_duration(self): 116 | self.assertTrue(hasattr(self, 'duration'), msg='duration does not exist') 117 | 118 | def test_exists_objective(self): 119 | self.assertTrue(hasattr(self, 'objective'), msg='objective does not exist') 120 | 121 | def test_exists_df(self): 122 | self.assertTrue(hasattr(self, 'df'), msg='df does not exist') 123 | 124 | def test_exists_model(self): 125 | self.assertTrue(hasattr(self, 'model'), msg='model does not exist') 126 | 127 | def test_exists_termination(self): 128 | self.assertTrue(hasattr(self, 'termination'), msg='termination does not exist') 129 | 130 | # check that model contains correct number of nodes 131 | def test_node_count(self): 132 | nNodesIn = len(self.parameter['network']['nodes']) 133 | nNodesOut = len(self.model.nodes.ordered_data()) 134 | self.assertEqual(nNodesIn, nNodesOut, msg='model node count incorrect') 135 | 136 | # check that duration is nonzero 137 | def test_duration_nonxero(self): 138 | self.assertGreater(self.duration, 0, msg='duration is zero') 139 | 140 | # check that objective is close to expected objective value 141 | def test_obj_tolerance(self): 142 | self.assertAlmostEqual(self.objective, self.expObjective, msg='objective does not match expected with 5%', delta=self.objTolerance) 143 | 144 | # check that model contains key pyomo vars as attributes 145 | def test_pyomo_has_imports(self): 146 | self.assertTrue(hasattr(self.model, 'grid_import'), msg='pyomo model is missing key var: grid_import') 147 | 148 | def test_pyomo_has_battery_soc(self): 149 | self.assertTrue(hasattr(self.model, 'battery_soc'), msg='pyomo model is missing key var: battery_soc') 150 | 151 | 152 | if __name__ == '__main__': 153 | unittest.main() -------------------------------------------------------------------------------- /test/test_genset.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | from pyomo.environ import Objective, minimize 5 | 6 | from doper import DOPER, get_solver, get_root 7 | from doper.models.basemodel import base_model, default_output_list 8 | from doper.models.genset import add_genset 9 | import doper.examples as example 10 | 11 | class TestBaseModel(unittest.TestCase): 12 | ''' 13 | 14 | unit tests for running DOPER base model. 15 | test setup is configured to only run optimization on first test, 16 | then test the various outputs from initiali optimization in subsequent tests 17 | 18 | does not test for proper error handling for incorrect use of basemodel optimization. 19 | 20 | 21 | ''' 22 | 23 | # set initial setUpComplete flag to false, so optimization is run on first test 24 | setupComplete = False 25 | # define acceptable delta when comparing objective and other vars 26 | tolerance = 0.05 27 | 28 | def setUp(self): 29 | ''' 30 | setUp method is run before each test. 31 | Only one optimization is needed for all tests, so if self.setupComplete 32 | is True, the optimization step is skipped 33 | 34 | ''' 35 | 36 | if not hasattr(self, 'setupComplete'): 37 | print('Initializing test: running optimization') 38 | self.runOptimization() 39 | else: 40 | if self.setupComplete is False: 41 | print('Initializing test: running optimization') 42 | self.runOptimization() 43 | else: 44 | print('Test optimization has been completed') 45 | 46 | # define expected objective 47 | self.__class__.expObjective = 3560 48 | self.__class__.objTolerance = self.expObjective * self.tolerance 49 | 50 | 51 | def runOptimization(self): 52 | ''' 53 | run optimization and store outputs as attributes of the class 54 | 55 | ''' 56 | 57 | def control_model(inputs, parameter): 58 | model = base_model(inputs, parameter) 59 | model = add_genset(model, inputs, parameter) 60 | 61 | def objective_function(model): 62 | return model.sum_energy_cost * parameter['objective']['weight_energy'] \ 63 | + model.sum_demand_cost * parameter['objective']['weight_demand'] \ 64 | + model.sum_export_revenue * parameter['objective']['weight_export'] \ 65 | + model.fuel_cost_total * parameter['objective']['weight_energy'] \ 66 | + model.load_shed_cost_total \ 67 | + model.co2_total * parameter['objective']['weight_co2'] 68 | 69 | 70 | model.objective = Objective(rule=objective_function, sense=minimize, doc='objective function') 71 | return model 72 | 73 | # generate input parameter and data 74 | parameter = example.test_default_parameter() 75 | parameter = example.parameter_add_genset(parameter) 76 | 77 | # add planned outage to ts_data to drive genset utilization 78 | data = example.ts_inputs(parameter, load='B90', scale_load=150, scale_pv=100) 79 | data = example.ts_inputs_planned_outage(parameter, data) 80 | 81 | # generate standard output data 82 | output_list = default_output_list(parameter) 83 | 84 | 85 | # Define the path to the solver executable 86 | solver_path = get_solver('cbc') 87 | 88 | # Initialize DOPER 89 | smartDER = DOPER(model=control_model, 90 | parameter=parameter, 91 | solver_path=solver_path, 92 | output_list=output_list) 93 | 94 | # Conduct optimization 95 | res = smartDER.do_optimization(data) 96 | 97 | # Get results 98 | duration, objective, df, model, result, termination, parameter = res 99 | 100 | # package model results into unit test class attributes 101 | self.__class__.duration = duration 102 | self.__class__.objective = objective 103 | self.__class__.df = df 104 | self.__class__.model = model 105 | self.__class__.result = result 106 | self.__class__.termination = termination 107 | self.__class__.parameter = parameter 108 | 109 | # extract fuel costs for comparison test 110 | self.__class__.fuelCost = model.fuel_cost_total.value 111 | 112 | # change setupComplete to True 113 | self.__class__.setupComplete = True 114 | 115 | 116 | # check that setup optimization has created expected result objects 117 | def test_exists_duration(self): 118 | self.assertTrue(hasattr(self, 'duration'), msg='duration does not exist') 119 | 120 | def test_exists_objective(self): 121 | self.assertTrue(hasattr(self, 'objective'), msg='objective does not exist') 122 | 123 | def test_exists_df(self): 124 | self.assertTrue(hasattr(self, 'df'), msg='df does not exist') 125 | 126 | def test_exists_model(self): 127 | self.assertTrue(hasattr(self, 'model'), msg='model does not exist') 128 | 129 | def test_exists_termination(self): 130 | self.assertTrue(hasattr(self, 'termination'), msg='termination does not exist') 131 | 132 | # check that duration is nonzero 133 | def test_duration_nonxero(self): 134 | self.assertGreater(self.duration, 0, msg='duration is zero') 135 | 136 | # check that fuel cost is nonzero 137 | def test_fuel_cost_nonxero(self): 138 | self.assertGreater(self.fuelCost, 0, msg='total fuel costs is zero') 139 | 140 | # check that objective is close to expected objective value 141 | def test_obj_tolerance(self): 142 | self.assertAlmostEqual(self.objective, self.expObjective, msg='objective does not match expected with 5%', delta=self.objTolerance) 143 | 144 | # check that model contains key pyomo vars as attributes 145 | def test_pyomo_has_imports(self): 146 | self.assertTrue(hasattr(self.model, 'grid_import'), msg='pyomo model is missing key var: grid_import') 147 | 148 | def test_pyomo_has_genset_output(self): 149 | self.assertTrue(hasattr(self.model, 'sum_genset_power'), msg='pyomo model is missing key var: sum_genset_power') 150 | 151 | 152 | if __name__ == '__main__': 153 | unittest.main() -------------------------------------------------------------------------------- /test/test_genset_multinode.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | from pyomo.environ import Objective, minimize 5 | 6 | from doper import DOPER, get_solver, get_root 7 | from doper.models.basemodel import base_model, default_output_list 8 | from doper.models.genset import add_genset 9 | from doper.models.network import add_network_simple 10 | import doper.examples as example 11 | 12 | class TestBaseModel(unittest.TestCase): 13 | ''' 14 | 15 | unit tests for running DOPER base model. 16 | test setup is configured to only run optimization on first test, 17 | then test the various outputs from initiali optimization in subsequent tests 18 | 19 | does not test for proper error handling for incorrect use of basemodel optimization. 20 | 21 | 22 | ''' 23 | 24 | # set initial setUpComplete flag to false, so optimization is run on first test 25 | setupComplete = False 26 | # define acceptable delta when comparing objective and other vars 27 | tolerance = 0.05 28 | 29 | def setUp(self): 30 | ''' 31 | setUp method is run before each test. 32 | Only one optimization is needed for all tests, so if self.setupComplete 33 | is True, the optimization step is skipped 34 | 35 | ''' 36 | 37 | if not hasattr(self, 'setupComplete'): 38 | print('Initializing test: running optimization') 39 | self.runOptimization() 40 | else: 41 | if self.setupComplete is False: 42 | print('Initializing test: running optimization') 43 | self.runOptimization() 44 | else: 45 | print('Test optimization has been completed') 46 | 47 | # define expected objective 48 | self.__class__.expObjective = 21800 49 | self.__class__.objTolerance = self.expObjective * self.tolerance 50 | 51 | 52 | def runOptimization(self): 53 | ''' 54 | run optimization and store outputs as attributes of the class 55 | 56 | ''' 57 | 58 | def control_model(inputs, parameter): 59 | model = base_model(inputs, parameter) 60 | model = add_network_simple(model, inputs, parameter) 61 | model = add_genset(model, inputs, parameter) 62 | 63 | def objective_function(model): 64 | return model.sum_energy_cost * parameter['objective']['weight_energy'] \ 65 | + model.sum_demand_cost * parameter['objective']['weight_demand'] \ 66 | + model.sum_export_revenue * parameter['objective']['weight_export'] \ 67 | + model.fuel_cost_total * parameter['objective']['weight_energy'] \ 68 | + model.load_shed_cost_total \ 69 | + model.co2_total * parameter['objective']['weight_co2'] 70 | 71 | 72 | model.objective = Objective(rule=objective_function, sense=minimize, doc='objective function') 73 | return model 74 | 75 | # generate input parameter and data 76 | parameter = example.parameter_add_network_test() 77 | parameter = example.parameter_add_genset_multinode_test(parameter) 78 | 79 | # add planned outage to ts_data to drive genset utilization 80 | data = example.ts_inputs_multinode_test(parameter) 81 | 82 | # generate standard output data 83 | output_list = default_output_list(parameter) 84 | 85 | 86 | # Define the path to the solver executable 87 | solver_path = get_solver('cbc') 88 | 89 | # Initialize DOPER 90 | smartDER = DOPER(model=control_model, 91 | parameter=parameter, 92 | solver_path=solver_path, 93 | output_list=output_list) 94 | 95 | # Conduct optimization 96 | res = smartDER.do_optimization(data) 97 | 98 | # Get results 99 | duration, objective, df, model, result, termination, parameter = res 100 | 101 | # package model results into unit test class attributes 102 | self.__class__.duration = duration 103 | self.__class__.objective = objective 104 | self.__class__.df = df 105 | self.__class__.model = model 106 | self.__class__.result = result 107 | self.__class__.termination = termination 108 | self.__class__.parameter = parameter 109 | 110 | # extract fuel costs for comparison test 111 | self.__class__.fuelCost = model.fuel_cost_total.value 112 | 113 | # change setupComplete to True 114 | self.__class__.setupComplete = True 115 | 116 | # print(f'obj: {objective}') 117 | 118 | 119 | # check that setup optimization has created expected result objects 120 | def test_exists_duration(self): 121 | self.assertTrue(hasattr(self, 'duration'), msg='duration does not exist') 122 | 123 | def test_exists_objective(self): 124 | self.assertTrue(hasattr(self, 'objective'), msg='objective does not exist') 125 | 126 | def test_exists_df(self): 127 | self.assertTrue(hasattr(self, 'df'), msg='df does not exist') 128 | 129 | def test_exists_model(self): 130 | self.assertTrue(hasattr(self, 'model'), msg='model does not exist') 131 | 132 | def test_exists_termination(self): 133 | self.assertTrue(hasattr(self, 'termination'), msg='termination does not exist') 134 | 135 | # check that model contains correct number of nodes 136 | def test_node_count(self): 137 | nNodesIn = len(self.parameter['network']['nodes']) 138 | nNodesOut = len(self.model.nodes.ordered_data()) 139 | self.assertEqual(nNodesIn, nNodesOut, msg='model node count incorrect') 140 | 141 | # check that duration is nonzero 142 | def test_duration_nonxero(self): 143 | self.assertGreater(self.duration, 0, msg='duration is zero') 144 | 145 | # check that fuel cost is nonzero 146 | def test_fuel_cost_nonxero(self): 147 | self.assertGreater(self.fuelCost, 0, msg='total fuel costs is zero') 148 | 149 | # check that objective is close to expected objective value 150 | def test_obj_tolerance(self): 151 | self.assertAlmostEqual(self.objective, self.expObjective, msg='objective does not match expected with 5%', delta=self.objTolerance) 152 | 153 | # check that model contains key pyomo vars as attributes 154 | def test_pyomo_has_imports(self): 155 | self.assertTrue(hasattr(self.model, 'grid_import'), msg='pyomo model is missing key var: grid_import') 156 | 157 | def test_pyomo_has_genset_output(self): 158 | self.assertTrue(hasattr(self.model, 'sum_genset_power'), msg='pyomo model is missing key var: sum_genset_power') 159 | 160 | 161 | if __name__ == '__main__': 162 | unittest.main() -------------------------------------------------------------------------------- /test/test_install.py: -------------------------------------------------------------------------------- 1 | """ 2 | DOPER install test module. 3 | """ 4 | 5 | import subprocess as sp 6 | 7 | def test_install(): 8 | """ 9 | This is a test to verify the install of DOPER. 10 | """ 11 | import doper 12 | solver = doper.get_solver('cbc') 13 | sp.check_output(f'{solver} exit', shell=True) 14 | 15 | def test_tariff(): 16 | """ 17 | This is a test to verify the tariff module of DOPER. 18 | """ 19 | import doper 20 | tariff = doper.get_tariff('e19-2020') 21 | assert tariff['name'].startswith('PG&E E-19') 22 | -------------------------------------------------------------------------------- /test/test_loadControl.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | from pyomo.environ import Objective, minimize 5 | 6 | from doper import DOPER, get_solver, get_root 7 | from doper.models.basemodel import base_model, default_output_list 8 | from doper.models.loadControl import add_loadControl 9 | import doper.examples as example 10 | 11 | class TestBaseModel(unittest.TestCase): 12 | ''' 13 | 14 | unit tests for running DOPER base model. 15 | test setup is configured to only run optimization on first test, 16 | then test the various outputs from initiali optimization in subsequent tests 17 | 18 | does not test for proper error handling for incorrect use of basemodel optimization. 19 | 20 | 21 | ''' 22 | 23 | # set initial setUpComplete flag to false, so optimization is run on first test 24 | setupComplete = False 25 | # define acceptable delta when comparing objective and other vars 26 | tolerance = 0.05 27 | 28 | def setUp(self): 29 | ''' 30 | setUp method is run before each test. 31 | Only one optimization is needed for all tests, so if self.setupComplete 32 | is True, the optimization step is skipped 33 | 34 | ''' 35 | 36 | if not hasattr(self, 'setupComplete'): 37 | print('Initializing test: running optimization') 38 | self.runOptimization() 39 | else: 40 | if self.setupComplete is False: 41 | print('Initializing test: running optimization') 42 | self.runOptimization() 43 | else: 44 | print('Test optimization has been completed') 45 | 46 | # define expected objective 47 | self.__class__.expObjective = 3059 48 | self.__class__.objTolerance = self.expObjective * self.tolerance 49 | 50 | 51 | def runOptimization(self): 52 | ''' 53 | run optimization and store outputs as attributes of the class 54 | 55 | ''' 56 | 57 | def control_model(inputs, parameter): 58 | model = base_model(inputs, parameter) 59 | model = add_loadControl(model, inputs, parameter) 60 | 61 | def objective_function(model): 62 | return model.sum_energy_cost * parameter['objective']['weight_energy'] \ 63 | + model.sum_demand_cost * parameter['objective']['weight_demand'] \ 64 | + model.sum_export_revenue * parameter['objective']['weight_export'] \ 65 | + model.fuel_cost_total * parameter['objective']['weight_energy'] \ 66 | + model.load_shed_cost_total \ 67 | + model.co2_total * parameter['objective']['weight_co2'] 68 | 69 | 70 | model.objective = Objective(rule=objective_function, sense=minimize, doc='objective function') 71 | return model 72 | 73 | # generate input parameter and data 74 | parameter = example.test_default_parameter() 75 | parameter = example.test_parameter_add_loadcontrol(parameter) 76 | 77 | # add planned outage to ts_data to drive genset utilization 78 | data = example.ts_inputs(parameter, load='B90', scale_load=150, scale_pv=100) 79 | data = example.ts_inputs_load_shed(parameter, data) 80 | 81 | # generate standard output data 82 | output_list = default_output_list(parameter) 83 | 84 | 85 | # Define the path to the solver executable 86 | solver_path = get_solver('cbc') 87 | 88 | # Initialize DOPER 89 | smartDER = DOPER(model=control_model, 90 | parameter=parameter, 91 | solver_path=solver_path, 92 | output_list=output_list) 93 | 94 | # Conduct optimization 95 | res = smartDER.do_optimization(data) 96 | 97 | # Get results 98 | duration, objective, df, model, result, termination, parameter = res 99 | 100 | # package model results into unit test class attributes 101 | self.__class__.duration = duration 102 | self.__class__.objective = objective 103 | self.__class__.df = df 104 | self.__class__.model = model 105 | self.__class__.result = result 106 | self.__class__.termination = termination 107 | self.__class__.parameter = parameter 108 | 109 | # extract fuel costs for comparison test 110 | self.__class__.loadShedCost = model.load_shed_cost_total.value 111 | 112 | # change setupComplete to True 113 | self.__class__.setupComplete = True 114 | 115 | 116 | # check that setup optimization has created expected result objects 117 | def test_exists_duration(self): 118 | self.assertTrue(hasattr(self, 'duration'), msg='duration does not exist') 119 | 120 | def test_exists_objective(self): 121 | self.assertTrue(hasattr(self, 'objective'), msg='objective does not exist') 122 | 123 | def test_exists_df(self): 124 | self.assertTrue(hasattr(self, 'df'), msg='df does not exist') 125 | 126 | def test_exists_model(self): 127 | self.assertTrue(hasattr(self, 'model'), msg='model does not exist') 128 | 129 | def test_exists_termination(self): 130 | self.assertTrue(hasattr(self, 'termination'), msg='termination does not exist') 131 | 132 | # check that duration is nonzero 133 | def test_duration_nonxero(self): 134 | self.assertGreater(self.duration, 0, msg='duration is zero') 135 | 136 | # check that fuel cost is nonzero 137 | def test_load_shed_cost_total_nonxero(self): 138 | self.assertGreater(self.loadShedCost, 0, msg='total load shed costs is zero') 139 | 140 | # check that objective is close to expected objective value 141 | def test_obj_tolerance(self): 142 | self.assertAlmostEqual(self.objective, self.expObjective, msg='objective does not match expected with 5%', delta=self.objTolerance) 143 | 144 | # check that model contains key pyomo vars as attributes 145 | def test_pyomo_has_imports(self): 146 | self.assertTrue(hasattr(self.model, 'grid_import'), msg='pyomo model is missing key var: grid_import') 147 | 148 | def test_pyomo_has_load_shed(self): 149 | self.assertTrue(hasattr(self.model, 'load_shed'), msg='pyomo model is missing key var: load_shed') 150 | 151 | 152 | if __name__ == '__main__': 153 | unittest.main() -------------------------------------------------------------------------------- /test/test_loadControl_multinode.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | from pyomo.environ import Objective, minimize 5 | 6 | from doper import DOPER, get_solver, get_root 7 | from doper.models.basemodel import base_model, default_output_list 8 | from doper.models.loadControl import add_loadControl 9 | from doper.models.network import add_network_simple 10 | import doper.examples as example 11 | 12 | class TestBaseModel(unittest.TestCase): 13 | ''' 14 | 15 | unit tests for running DOPER base model. 16 | test setup is configured to only run optimization on first test, 17 | then test the various outputs from initiali optimization in subsequent tests 18 | 19 | does not test for proper error handling for incorrect use of basemodel optimization. 20 | 21 | 22 | ''' 23 | 24 | # set initial setUpComplete flag to false, so optimization is run on first test 25 | setupComplete = False 26 | # define acceptable delta when comparing objective and other vars 27 | tolerance = 0.05 28 | 29 | def setUp(self): 30 | ''' 31 | setUp method is run before each test. 32 | Only one optimization is needed for all tests, so if self.setupComplete 33 | is True, the optimization step is skipped 34 | 35 | ''' 36 | 37 | if not hasattr(self, 'setupComplete'): 38 | print('Initializing test: running optimization') 39 | self.runOptimization() 40 | else: 41 | if self.setupComplete is False: 42 | print('Initializing test: running optimization') 43 | self.runOptimization() 44 | else: 45 | print('Test optimization has been completed') 46 | 47 | # define expected objective 48 | self.__class__.expObjective = 22500 49 | self.__class__.objTolerance = self.expObjective * self.tolerance 50 | 51 | 52 | def runOptimization(self): 53 | ''' 54 | run optimization and store outputs as attributes of the class 55 | 56 | ''' 57 | 58 | def control_model(inputs, parameter): 59 | model = base_model(inputs, parameter) 60 | model = add_network_simple(model, inputs, parameter) 61 | model = add_loadControl(model, inputs, parameter) 62 | 63 | def objective_function(model): 64 | return model.sum_energy_cost * parameter['objective']['weight_energy'] \ 65 | + model.sum_demand_cost * parameter['objective']['weight_demand'] \ 66 | + model.sum_export_revenue * parameter['objective']['weight_export'] \ 67 | + model.fuel_cost_total * parameter['objective']['weight_energy'] \ 68 | + model.load_shed_cost_total \ 69 | + model.co2_total * parameter['objective']['weight_co2'] 70 | 71 | 72 | model.objective = Objective(rule=objective_function, sense=minimize, doc='objective function') 73 | return model 74 | 75 | # generate input parameter and data 76 | parameter = example.parameter_add_network_test() 77 | parameter = example.parameter_add_loadcontrol_multinode_test(parameter) 78 | 79 | # add planned outage to ts_data to drive genset utilization 80 | data = example.ts_inputs_multinode_test(parameter) 81 | data = example.data = example.ts_inputs_load_shed_multinode_test(parameter, data) 82 | 83 | # generate standard output data 84 | output_list = default_output_list(parameter) 85 | 86 | 87 | # Define the path to the solver executable 88 | solver_path = get_solver('cbc') 89 | 90 | # Initialize DOPER 91 | smartDER = DOPER(model=control_model, 92 | parameter=parameter, 93 | solver_path=solver_path, 94 | output_list=output_list) 95 | 96 | # Conduct optimization 97 | res = smartDER.do_optimization(data) 98 | 99 | # Get results 100 | duration, objective, df, model, result, termination, parameter = res 101 | 102 | # package model results into unit test class attributes 103 | self.__class__.duration = duration 104 | self.__class__.objective = objective 105 | self.__class__.df = df 106 | self.__class__.model = model 107 | self.__class__.result = result 108 | self.__class__.termination = termination 109 | self.__class__.parameter = parameter 110 | 111 | # extract fuel costs for comparison test 112 | self.__class__.loadShedCost = model.load_shed_cost_total.value 113 | 114 | # change setupComplete to True 115 | self.__class__.setupComplete = True 116 | 117 | # print(f'obj: {objective}') 118 | 119 | 120 | # check that setup optimization has created expected result objects 121 | def test_exists_duration(self): 122 | self.assertTrue(hasattr(self, 'duration'), msg='duration does not exist') 123 | 124 | def test_exists_objective(self): 125 | self.assertTrue(hasattr(self, 'objective'), msg='objective does not exist') 126 | 127 | def test_exists_df(self): 128 | self.assertTrue(hasattr(self, 'df'), msg='df does not exist') 129 | 130 | def test_exists_model(self): 131 | self.assertTrue(hasattr(self, 'model'), msg='model does not exist') 132 | 133 | def test_exists_termination(self): 134 | self.assertTrue(hasattr(self, 'termination'), msg='termination does not exist') 135 | 136 | # check that model contains correct number of nodes 137 | def test_node_count(self): 138 | nNodesIn = len(self.parameter['network']['nodes']) 139 | nNodesOut = len(self.model.nodes.ordered_data()) 140 | self.assertEqual(nNodesIn, nNodesOut, msg='model node count incorrect') 141 | 142 | # check that duration is nonzero 143 | def test_duration_nonxero(self): 144 | self.assertGreater(self.duration, 0, msg='duration is zero') 145 | 146 | # check that fuel cost is nonzero 147 | def test_load_shed_cost_total_nonxero(self): 148 | self.assertGreater(self.loadShedCost, 0, msg='total load shed costs is zero') 149 | 150 | # check that objective is close to expected objective value 151 | def test_obj_tolerance(self): 152 | self.assertAlmostEqual(self.objective, self.expObjective, msg='objective does not match expected with 5%', delta=self.objTolerance) 153 | 154 | # check that model contains key pyomo vars as attributes 155 | def test_pyomo_has_imports(self): 156 | self.assertTrue(hasattr(self.model, 'grid_import'), msg='pyomo model is missing key var: grid_import') 157 | 158 | def test_pyomo_has_load_shed(self): 159 | self.assertTrue(hasattr(self.model, 'load_shed'), msg='pyomo model is missing key var: load_shed') 160 | 161 | 162 | if __name__ == '__main__': 163 | unittest.main() -------------------------------------------------------------------------------- /test/test_powerflow.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | import sys 4 | 5 | # Append parent directory to import DOPER 6 | sys.path.append('../src') 7 | 8 | 9 | from doper import DOPER, get_solver, get_root 10 | from doper.models.basemodel import base_model, default_output_list 11 | from doper.models.battery import add_battery 12 | from doper.models.network import add_network 13 | import doper.examples as example 14 | 15 | 16 | from pyomo.environ import Objective, minimize 17 | 18 | def create_test_parameter(): 19 | ''' 20 | generates input and parameter objects for testing 21 | 22 | ''' 23 | 24 | parameter = example.default_parameter() 25 | 26 | # Add nodes and line options 27 | parameter['network'] = {} 28 | 29 | 30 | # Add network settings to define power-flow constaints 31 | parameter['network']['settings'] = { 32 | 33 | # turn off simepl power exchange to utilize full power-flow equations 34 | 'simplePowerExchange': False, 35 | 'simpleNetworkLosses': 0.05, 36 | 37 | 'enableGenPqLimits': False, # not implemented yet 38 | 39 | 40 | # powerflow parameters 41 | 'slackBusVoltage': 1, 42 | 'sBase': 1000, 43 | 'vBase': 1, 44 | 'cableDerating': 1, 45 | 'txDerating': 1, 46 | 47 | # power factors 48 | 'powerFactors': { 49 | 'pv': 1, 50 | 'genset': 1, 51 | 'batteryDisc': 1, 52 | 'batteryChar': 1, 53 | 'load': 1 54 | }, 55 | 56 | # powerflow model settings 57 | 'enableLosses': True, 58 | 'thetaMin': -0.18, 59 | 'thetaMax': 0.09, 60 | 'voltMin': 0.8, 61 | 'voltMax': 1.1, 62 | 'useConsVoltMin': False, 63 | } 64 | 65 | parameter['network']['nodes'] = [ # list of dict to define inputs for each node in network 66 | { # node 1 67 | 'node_id': 'N1', # unique str to id node 68 | 'pcc': True, # bool to define if node is pcc 69 | 'slack': True, 70 | 'load_id': None, # str, list of str, or None to find load profile in ts data (if node is load bus) by column label 71 | 'ders': { # dict of der assets at node, if None or not included, no ders present 72 | 'pv_id': None, # str, list, or None to find pv profile in ts data (if pv at node) by column label 73 | 'pv_maxS': 0, 74 | 'battery': None, # list of str corresponding to battery assets (defined in parameter['system']['battery']) 75 | 'genset': None, # list of str correponsing to genset assets (defined in parameter['system']['genset']) 76 | 'load_control': None # str, list or None correponsing to genset assets (defined in parameter['system']['load_control']) 77 | }, 78 | 'connections': [ # list of connected nodes, and line connecting them 79 | { 80 | 'node': 'N2', # str containing unique node_id of connected node 81 | 'line': 'L1' # str containing unique line_id of line connection nodes, (defined in parameter['network']['lines']) 82 | }, 83 | { 84 | 'node': 'N4', 85 | 'line': 'L2' 86 | } 87 | ] 88 | }, 89 | { # node 2 90 | 'node_id': 'N2', 91 | 'pcc': True, 92 | 'slack': False, 93 | 'load_id': 'pf_demand_node2', 94 | 'ders': { 95 | 'pv_id': 'pf_pv_node2', 96 | 'pv_maxS': 300, 97 | 'battery': 'pf_bat_node2', # node can contain multiple battery assets, so should be list 98 | 'genset': None, 99 | 'load_control': None # node likely to only contain single load_control asset, so should be str 100 | }, 101 | 'connections': [ 102 | { 103 | 'node': 'N1', 104 | 'line': 'L1' 105 | }, 106 | { 107 | 'node': 'N3', 108 | 'line': 'L1' 109 | } 110 | ] 111 | }, 112 | { # node 3 113 | 'node_id': 'N3', 114 | 'pcc': True, 115 | 'slack': False, 116 | 'load_id': 'pf_pv_node3', 117 | 'ders': { 118 | 'pv_id': None, 119 | 'pv_maxS': 1200, 120 | 'battery': 'pf_bat_node3', 121 | 'genset': 'pf_gen_node3', 122 | 'load_control': None 123 | }, 124 | 'connections': [ 125 | { 126 | 'node': 'N2', 127 | 'line': 'L1' 128 | } 129 | ] 130 | }, 131 | { # node 4 132 | 'node_id': 'N4', 133 | 'pcc': True, 134 | 'slack': False, 135 | 'load_id': 'pf_demand_node4', 136 | 'ders': { 137 | 'pv_id': 'pf_pv_node4', 138 | 'pv_maxS': 1000, 139 | 'battery': 'pf_bat_node4', 140 | 'genset': 'pf_gen_node4', 141 | 'load_control': 'testLc4' 142 | }, 143 | 'connections': [ 144 | { 145 | 'node': 'N1', 146 | 'line': 'L2' 147 | }, 148 | { 149 | 'node': 'N5', 150 | 'line': 'L3' 151 | } 152 | ] 153 | }, 154 | { # node 5 155 | 'node_id': 'N5', 156 | 'pcc': True, 157 | 'slack': False, 158 | 'load_id': 'pf_demand_node5', 159 | 'ders': { 160 | 'pv_id': 'pf_pv_node5', 161 | 'pv_maxS': 1500, 162 | 'battery': 'pf_bat_node5', 163 | 'genset': 'pf_gen_node5', 164 | 'load_control': None 165 | }, 166 | 'connections': [ 167 | { 168 | 'node': 'N4', 169 | 'line': 'L3' 170 | } 171 | ] 172 | } 173 | ] 174 | 175 | parameter['network']['lines'] = [ # list of dicts define each cable/line properties 176 | { 177 | 'line_id': 'L1', 178 | 'power_capacity': 3500, # line power capacity only used for simple power=exchange 179 | 180 | 'length': 1200, # line length in meters 181 | 'resistance': 4.64e-6, # line properties are all in pu, based on SBase/VBase defined above 182 | 'inductance': 8.33e-7, 183 | 'ampacity': 3500, 184 | }, 185 | { 186 | 'line_id': 'L2', 187 | 'power_capacity': 3500, 188 | 189 | 'length': 1800, 190 | 'resistance': 4.64e-6, 191 | 'inductance': 8.33e-7, 192 | 'ampacity': 3500, 193 | }, 194 | { 195 | 'line_id': 'L3', 196 | 'power_capacity': 3500, 197 | 198 | 'length': 900, 199 | 'resistance': 4.64e-6, 200 | 'inductance': 8.33e-7, 201 | 'ampacity': 3500, 202 | } 203 | ] 204 | 205 | return parameter 206 | 207 | def create_test_input(parameter): 208 | 209 | # create data ts for each node 210 | data2 = example.ts_inputs(parameter, load='B90', scale_load=700, scale_pv=300) 211 | data3 = example.ts_inputs(parameter, load='B90', scale_load=1200, scale_pv=1200) 212 | data4 = example.ts_inputs(parameter, load='B90', scale_load=1500, scale_pv=1000) 213 | data5 = example.ts_inputs(parameter, load='B90', scale_load=2000, scale_pv=1500) 214 | 215 | # use data1 as starting point for multinode df 216 | data = data2.copy() 217 | 218 | # drop load and pv from multinode df 219 | data = data.drop(labels='load_demand', axis=1) 220 | data = data.drop(labels='generation_pv', axis=1) 221 | 222 | # add node specifc load and pv (where applicable) 223 | data['pf_demand_node2'] = data2['load_demand'] 224 | data['pf_demand_node3'] = data3['load_demand'] 225 | data['pf_demand_node4'] = data4['load_demand'] 226 | data['pf_demand_node5'] = data5['load_demand'] 227 | 228 | data['pf_pv_node2'] = data2['generation_pv'] 229 | data['pf_pv_node3'] = data3['generation_pv'] 230 | data['pf_pv_node4'] = data4['generation_pv'] 231 | data['pf_pv_node5'] = data5['generation_pv'] 232 | 233 | return data 234 | 235 | class TestBaseModel(unittest.TestCase): 236 | ''' 237 | 238 | unit tests for running DOPER base model. 239 | test setup is configured to only run optimization on first test, 240 | then test the various outputs from initiali optimization in subsequent tests 241 | 242 | does not test for proper error handling for incorrect use of basemodel optimization. 243 | 244 | 245 | ''' 246 | 247 | # set initial setUpComplete flag to false, so optimization is run on first test 248 | setupComplete = False 249 | # define acceptable delta when comparing objective and other vars 250 | tolerance = 0.05 251 | 252 | def setUp(self): 253 | ''' 254 | setUp method is run before each test. 255 | Only one optimization is needed for all tests, so if self.setupComplete 256 | is True, the optimization step is skipped 257 | 258 | ''' 259 | 260 | if not hasattr(self, 'setupComplete'): 261 | print('Initializing test: running optimization') 262 | self.runOptimization() 263 | else: 264 | if self.setupComplete is False: 265 | print('Initializing test: running optimization') 266 | self.runOptimization() 267 | else: 268 | print('Test optimization has been completed') 269 | 270 | # define expected objective 271 | self.__class__.expObjective = 160000 272 | self.__class__.objTolerance = self.expObjective * self.tolerance 273 | 274 | 275 | def runOptimization(self): 276 | ''' 277 | run optimization and store outputs as attributes of the class 278 | 279 | ''' 280 | 281 | def control_model(inputs, parameter): 282 | model = base_model(inputs, parameter) 283 | model = add_network(model, inputs, parameter) 284 | # model = add_battery(model, inputs, parameter) 285 | 286 | def objective_function(model): 287 | return model.sum_energy_cost * parameter['objective']['weight_energy'] \ 288 | + model.sum_demand_cost * parameter['objective']['weight_demand'] \ 289 | + model.sum_export_revenue * parameter['objective']['weight_export'] \ 290 | + model.fuel_cost_total * parameter['objective']['weight_energy'] \ 291 | + model.load_shed_cost_total \ 292 | + model.co2_total * parameter['objective']['weight_co2'] 293 | 294 | 295 | model.objective = Objective(rule=objective_function, sense=minimize, doc='objective function') 296 | return model 297 | 298 | # generate input parameter and data 299 | parameter = create_test_parameter() 300 | data = create_test_input(parameter) 301 | 302 | # generate standard output data 303 | output_list = default_output_list(parameter) 304 | 305 | 306 | # Define the path to the solver executable 307 | solver_path = get_solver('cbc', solver_dir=os.path.join(get_root(), 'solvers')) 308 | 309 | # Initialize DOPER 310 | smartDER = DOPER(model=control_model, 311 | parameter=parameter, 312 | solver_path=solver_path, 313 | output_list=output_list) 314 | 315 | # Conduct optimization 316 | res = smartDER.do_optimization(data) 317 | 318 | # Get results 319 | duration, objective, df, model, result, termination, parameter = res 320 | 321 | # package model results into unit test class attributes 322 | self.__class__.duration = duration 323 | self.__class__.objective = objective 324 | self.__class__.df = df 325 | self.__class__.model = model 326 | self.__class__.result = result 327 | self.__class__.termination = termination 328 | self.__class__.parameter = parameter 329 | 330 | # change setupComplete to True 331 | self.__class__.setupComplete = True 332 | 333 | # print(f'obj: {objective}') 334 | 335 | 336 | # check that setup optimization has created expected result objects 337 | def test_exists_duration(self): 338 | self.assertTrue(hasattr(self, 'duration'), msg='duration does not exist') 339 | 340 | def test_exists_objective(self): 341 | self.assertTrue(hasattr(self, 'objective'), msg='objective does not exist') 342 | 343 | def test_exists_df(self): 344 | self.assertTrue(hasattr(self, 'df'), msg='df does not exist') 345 | 346 | def test_exists_model(self): 347 | self.assertTrue(hasattr(self, 'model'), msg='model does not exist') 348 | 349 | def test_exists_termination(self): 350 | self.assertTrue(hasattr(self, 'termination'), msg='termination does not exist') 351 | 352 | # check that model contains correct number of nodes 353 | def test_node_count(self): 354 | nNodesIn = len(self.parameter['network']['nodes']) 355 | nNodesOut = len(self.model.nodes.ordered_data()) 356 | self.assertEqual(nNodesIn, nNodesOut, msg='model node count incorrect') 357 | 358 | # check that duration is nonzero 359 | def test_duration_nonxero(self): 360 | self.assertGreater(self.duration, 0, msg='duration is zero') 361 | 362 | # check that objective is close to expected objective value 363 | def test_obj_tolerance(self): 364 | self.assertAlmostEqual(self.objective, self.expObjective, msg='objective does not match expected with 5%', delta=self.objTolerance) 365 | 366 | # check that model contains key pyomo vars as attributes 367 | def test_pyomo_has_voltage(self): 368 | self.assertTrue(hasattr(self.model, 'voltage_real'), msg='pyomo model is missing key var: voltage_real') 369 | 370 | def test_pyomo_has_current(self): 371 | self.assertTrue(hasattr(self.model, 'real_branch_cur'), msg='pyomo model is missing key var: real_branch_cur') 372 | 373 | def test_pyomo_has_current_square(self): 374 | self.assertTrue(hasattr(self.model, 'real_branch_cur_square'), msg='pyomo model is missing key var: real_branch_cur_square') 375 | 376 | 377 | if __name__ == '__main__': 378 | unittest.main() --------------------------------------------------------------------------------