mirror of
https://github.com/ANL-CEEESA/UnitCommitment.jl.git
synced 2025-12-06 08:18:51 -06:00
Initial public release
This commit is contained in:
15
src/UnitCommitment.jl
Normal file
15
src/UnitCommitment.jl
Normal file
@@ -0,0 +1,15 @@
|
||||
# UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment
|
||||
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
|
||||
# Released under the modified BSD license. See COPYING.md for more details.
|
||||
|
||||
module UnitCommitment
|
||||
include("log.jl")
|
||||
include("dotdict.jl")
|
||||
include("instance.jl")
|
||||
include("screening.jl")
|
||||
include("model.jl")
|
||||
include("sensitivity.jl")
|
||||
include("validate.jl")
|
||||
include("convert.jl")
|
||||
include("initcond.jl")
|
||||
end
|
||||
56
src/convert.jl
Normal file
56
src/convert.jl
Normal file
@@ -0,0 +1,56 @@
|
||||
# UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment
|
||||
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
|
||||
# Released under the modified BSD license. See COPYING.md for more details.
|
||||
|
||||
using DataStructures, JSON, GZip
|
||||
|
||||
function read_json(path::String)::OrderedDict
|
||||
if endswith(path, ".gz")
|
||||
file = GZip.gzopen(path)
|
||||
else
|
||||
file = open(path)
|
||||
end
|
||||
return JSON.parse(file, dicttype=()->DefaultOrderedDict(nothing))
|
||||
end
|
||||
|
||||
function read_egret_solution(path::String)::OrderedDict
|
||||
egret = read_json(path)
|
||||
T = length(egret["system"]["time_keys"])
|
||||
|
||||
solution = OrderedDict()
|
||||
is_on = solution["Is on"] = OrderedDict()
|
||||
production = solution["Production (MW)"] = OrderedDict()
|
||||
reserve = solution["Reserve (MW)"] = OrderedDict()
|
||||
production_cost = solution["Production cost (\$)"] = OrderedDict()
|
||||
startup_cost = solution["Startup cost (\$)"] = OrderedDict()
|
||||
|
||||
for (gen_name, gen_dict) in egret["elements"]["generator"]
|
||||
if endswith(gen_name, "_T") || endswith(gen_name, "_R")
|
||||
gen_name = gen_name[1:end-2]
|
||||
end
|
||||
if "commitment" in keys(gen_dict)
|
||||
is_on[gen_name] = gen_dict["commitment"]["values"]
|
||||
else
|
||||
is_on[gen_name] = ones(T)
|
||||
end
|
||||
production[gen_name] = gen_dict["pg"]["values"]
|
||||
if "rg" in keys(gen_dict)
|
||||
reserve[gen_name] = gen_dict["rg"]["values"]
|
||||
else
|
||||
reserve[gen_name] = zeros(T)
|
||||
end
|
||||
startup_cost[gen_name] = zeros(T)
|
||||
production_cost[gen_name] = zeros(T)
|
||||
if "commitment_cost" in keys(gen_dict)
|
||||
for t in 1:T
|
||||
x = gen_dict["commitment"]["values"][t]
|
||||
commitment_cost = gen_dict["commitment_cost"]["values"][t]
|
||||
prod_above_cost = gen_dict["production_cost"]["values"][t]
|
||||
prod_base_cost = gen_dict["p_cost"]["values"][1][2] * x
|
||||
startup_cost[gen_name][t] = commitment_cost - prod_base_cost
|
||||
production_cost[gen_name][t] = prod_above_cost + prod_base_cost
|
||||
end
|
||||
end
|
||||
end
|
||||
return solution
|
||||
end
|
||||
28
src/docs/css/custom.css
Normal file
28
src/docs/css/custom.css
Normal file
@@ -0,0 +1,28 @@
|
||||
.navbar-default {
|
||||
border-bottom: 0px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0px 0px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
a, .navbar-default a {
|
||||
color: #06a !important;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.disabled > a {
|
||||
color: #999 !important;
|
||||
}
|
||||
|
||||
.navbar-default a:hover,
|
||||
.navbar-default .active,
|
||||
.active > a {
|
||||
background-color: #f0f0f0 !important;
|
||||
}
|
||||
|
||||
.icon-bar {
|
||||
background-color: #666 !important;
|
||||
}
|
||||
|
||||
.navbar-collapse {
|
||||
border-color: #fff !important;
|
||||
}
|
||||
257
src/docs/format.md
Normal file
257
src/docs/format.md
Normal file
@@ -0,0 +1,257 @@
|
||||
Data Format
|
||||
===========
|
||||
|
||||
Instances are specified by JSON files containing the following main sections:
|
||||
|
||||
* [Parameters](#parameters)
|
||||
* [Buses](#buses)
|
||||
* [Generators](#generators)
|
||||
* [Price-sensitive loads](#price-sensitive-loads)
|
||||
* [Transmission lines](#transmission-lines)
|
||||
* [Reserves](#reserves)
|
||||
* [Contingencies](#contingencies)
|
||||
|
||||
Each section is described in detail below. For a complete example, see [case14.json](https://github.com/ANL-CEEESA/UnitCommitment.jl/blob/dev/instances/matpower-24h/case14.json).
|
||||
|
||||
## Parameters
|
||||
|
||||
This section describes system-wide parameters, such as power balance penalties, and optimization parameters, such as the length of the planning horizon.
|
||||
|
||||
| Key | Description | Default | Time series?
|
||||
| :----------------------------- | :------------------------------------------------ | :------: | :------------:
|
||||
| `Time (h)` | Length of the planning horizon (in hours) | Required | N
|
||||
| `Power balance penalty ($/MW)` | Penalty for system-wide shortage or surplus in production (in $/MW). This is charged per time period. For example, if there is a shortage of 1 MW for three time periods, three times this amount will be charged. | `1000.0` | Y
|
||||
|
||||
|
||||
### Example
|
||||
```json
|
||||
{
|
||||
"Parameters": {
|
||||
"Time (h)": 4,
|
||||
"Power balance penalty ($/MW)": 1000.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Buses
|
||||
|
||||
This section describes the characteristics of each bus in the system.
|
||||
|
||||
| Key | Description | Default | Time series?
|
||||
| :----------------- | :------------------------------------------------------------ | ------- | :-------------:
|
||||
| `Load (MW)` | Fixed load connected to the bus (in MW). | Required | Y
|
||||
|
||||
|
||||
### Example
|
||||
```json
|
||||
{
|
||||
"Buses": {
|
||||
"b1": {
|
||||
"Load (MW)": 0.0
|
||||
},
|
||||
"b2": {
|
||||
"Load (MW)": [
|
||||
26.01527,
|
||||
24.46212,
|
||||
23.29725,
|
||||
22.90897
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Generators
|
||||
|
||||
This section describes all generators in the system, including thermal units, renewable units and virtual units.
|
||||
|
||||
| Key | Description | Default | Time series?
|
||||
| :------------------------ | :------------------------------------------------| ------- | :-----------:
|
||||
| `Bus` | Identifier of the bus where this generator is located (string) | Required | N
|
||||
| `Production cost curve (MW)` and `Production cost curve ($)` | Parameters describing the piecewise-linear production costs. See below for more details. | Required | Y
|
||||
| `Startup costs ($)` and `Startup delays (h)` | Parameters describing how much it costs to start the generator after it has been shut down for a certain amount of time. If `Startup costs ($)` and `Startup delays (h)` are set to `[300.0, 400.0]` and `[1, 4]`, for example, and the generator is shut down at time `t`, then it costs 300 to start up the generator at times `t+1`, `t+2` or `t+3`, and 400 to start the generator at time `t+4` or any time after that. The number of startup cost points is unlimited, and may be different for each generator. Startup delays must be strictly increasing. | `[0.0]` and `[1]` | N
|
||||
| `Minimum uptime (h)` | Minimum amount of time the generator must stay operational after starting up (in hours). For example, if the generator starts up at time 1 and `Minimum uptime (h)` is set to 4, then the generator can only shut down at time 5. | `1` | N
|
||||
| `Minimum downtime (h)` | Minimum amount of time the generator must stay offline after shutting down (in hours). For example, if the generator shuts down at time 1 and `Minimum downtime (h)` is set to 4, then the generator can only start producing power again at time 5. | `1` | N
|
||||
| `Ramp up limit (MW)` | Maximum increase in production from one time period to the next (in MW). For example, if the generator is producing 100 MW at time 1 and if this parameter is set to 40 MW, then the generator will produce at most 140 MW at time 2. | `+inf` | N
|
||||
| `Ramp down limit (MW)` | Maximum decrease in production from one time period to the next (in MW). For example, if the generator is producing 100 MW at time 1 and this parameter is set to 40 MW, then the generator will produce at least 60 MW at time 2. | `+inf` | N
|
||||
| `Startup limit (MW)` | Maximum amount of power a generator can produce immediately after starting up (in MW). | `+inf` | N
|
||||
| `Shutdown limit (MW)` | Maximum amount of power a generator can produce immediately before shutting down (in MW). Specifically, the generator can only shut down at time `t+1` if its production at time `t` is below this limit. | `+inf` | N
|
||||
| `Initial status (h)` | If set to a positive number, indicates the amount of time the generator has been on at the beginning of the simulation, and if set to a negative number, the amount of time the generator has been off. For example, if `Initial status (h)` is `-2`, this means that the generator was off at simulation time `-2` and `-1`. The simulation starts at time `0`. | Required | N
|
||||
| `Initial power (MW)` | Amount of power the generator at time period `-1`, immediately before the planning horizon starts. | Required | N
|
||||
| `Must run?` | If `true`, the generator should be committed, even that is not economical (Boolean). | `false` | Y
|
||||
| `Provides spinning reserves?` | If `true`, this generator may provide spinning reserves (Boolean). | `true` | Y
|
||||
|
||||
### Production costs and limits
|
||||
|
||||
Production costs are represented as piecewise-linear curves. Figure 1 shows an example cost curve with three segments, where it costs 1400, 1600, 2200 and 2400 dollars to generate, respectively, 100, 110, 130 and 135 MW of power. To model this generator, `Production cost curve (MW)` should be set to `[100, 110, 130, 135]`, and `Production cost curve ($)` should be set to `[1400, 1600, 2200, 2400]`.
|
||||
Note that this curve also specifies the production limits. Specifically, the first point identifies the minimum power output when the unit is operational, while the last point identifies the maximum power output.
|
||||
|
||||
<center>
|
||||
<img src="../images/cost_curve.png" style="max-width: 500px"/>
|
||||
<div><b>Figure 1.</b> Piecewise-linear production cost curve.</div>
|
||||
<br/>
|
||||
</center>
|
||||
|
||||
**Additional remarks:**
|
||||
|
||||
* For time-dependent production limits or time-dependent production costs, the usage of nested arrays is allowed. For example, if `Production cost curve (MW)` is set to `[5.0, [10.0, 12.0, 15.0, 20.0]]`, then the unit may generate at most 10, 12, 15 and 20 MW of power during time periods 1, 2, 3 and 4, respectively. The minimum output for all time periods is fixed to at 5 MW.
|
||||
* There is no limit to the number of piecewise-linear segments, and different generators may have a different number of segments.
|
||||
* If `Production cost curve (MW)` and `Production cost curve ($)` both contain a single element, then the generator must produce exactly that amount of power when operational. To specify that the generator may produce any amount of power up to a certain limit `P`, the parameter `Production cost curve (MW)` should be set to `[0, P]`.
|
||||
* Production cost curves must be convex.
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"Generators": {
|
||||
"gen1": {
|
||||
"Bus": "b1",
|
||||
"Production cost curve (MW)": [100.0, 110.0, 130.0, 135.0],
|
||||
"Production cost curve ($)": [1400.0, 1600.0, 2200.0, 2400.0],
|
||||
"Startup costs ($)": [300.0, 400.0],
|
||||
"Startup delays (h)": [1, 4],
|
||||
"Ramp up limit (MW)": 232.68,
|
||||
"Ramp down limit (MW)": 232.68,
|
||||
"Startup limit (MW)": 232.68,
|
||||
"Shutdown limit (MW)": 232.68,
|
||||
"Minimum downtime (h)": 4,
|
||||
"Minimum uptime (h)": 4,
|
||||
"Initial status (h)": 12,
|
||||
"Must run?": false,
|
||||
"Provides spinning reserves?": true,
|
||||
},
|
||||
"gen2": {
|
||||
"Bus": "b5",
|
||||
"Production cost curve (MW)": [0.0, [10.0, 8.0, 0.0, 3.0]],
|
||||
"Production cost curve ($)": [0.0, 0.0],
|
||||
"Provides spinning reserves?": true,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Price-sensitive loads
|
||||
|
||||
This section describes components in the system which may increase or reduce their energy consumption according to the energy prices. Fixed loads (as described in the `buses` section) are always served, regardless of the price, unless there is significant congestion in the system or insufficient production capacity. Price-sensitive loads, on the other hand, are only served if it is economical to do so.
|
||||
|
||||
| Key | Description | Default | Time series?
|
||||
| :---------------- | :------------------------------------------------ | :------: | :------------:
|
||||
| `Bus` | Bus where the load is located. Multiple price-sensitive loads may be placed at the same bus. | Required | N
|
||||
| `Revenue ($/MW)` | Revenue obtained for serving each MW of power to this load. | Required | Y
|
||||
| `Demand (MW)` | Maximum amount of power required by this load. Any amount lower than this may be served. | Required | Y
|
||||
|
||||
|
||||
### Example
|
||||
```json
|
||||
{
|
||||
"Price-sensitive loads": {
|
||||
"p1": {
|
||||
"Bus": "b3",
|
||||
"Revenue ($/MW)": 23.0,
|
||||
"Demand (MW)": 50.0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Transmission Lines
|
||||
|
||||
This section describes the characteristics of transmission system, such as its topology and the susceptance of each transmission line.
|
||||
|
||||
| Key | Description | Default | Time series?
|
||||
| :--------------------- | :----------------------------------------------- | ------- | :------------:
|
||||
| `Source bus` | Identifier of the bus where the transmission line originates. | Required | N
|
||||
| `Target bus` | Identifier of the bus where the transmission line reaches. | Required | N
|
||||
| `Reactance (ohms)` | Reactance of the transmission line (in ohms). | Required | N
|
||||
| `Susceptance (S)` | Susceptance of the transmission line (in siemens). | Required | N
|
||||
| `Normal flow limit (MW)` | Maximum amount of power (in MW) allowed to flow through the line when the system is in its regular, fully-operational state. May be `null` is there is no limit. | `+inf` | Y
|
||||
| `Emergency flow limit (MW)` | Maximum amount of power (in MW) allowed to flow through the line when the system is in degraded state (for example, after the failure of another transmission line). | `+inf` | Y
|
||||
| `Flow limit penalty ($/MW)` | Penalty for violating the flow limits of the transmission line (in $/MW). This is charged per time period. For example, if there is a thermal violation of 1 MW for three time periods, three times this amount will be charged. | `5000.0` | Y
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"Transmission lines": {
|
||||
"l1": {
|
||||
"Source bus": "b1",
|
||||
"Target bus": "b2",
|
||||
"Reactance (ohms)": 0.05917,
|
||||
"Susceptance (S)": 29.49686,
|
||||
"Normal flow limit (MW)": 15000.0,
|
||||
"Emergency flow limit (MW)": 20000.0,
|
||||
"Flow limit penalty ($/MW)": 5000.0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Reserves
|
||||
|
||||
This section describes the hourly amount of operating reserves required.
|
||||
|
||||
|
||||
| Key | Description | Default | Time series?
|
||||
| :-------------------- | :------------------------------------------------- | --------- | :----:
|
||||
| `Spinning (MW)` | Minimum amount of system-wide spinning reserves (in MW). Only generators which are online may provide this reserve. | `0.0` | Y
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"Reserves": {
|
||||
"Spinning (MW)": [
|
||||
57.30552,
|
||||
53.88429,
|
||||
51.31838,
|
||||
50.46307
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Contingencies
|
||||
|
||||
This section describes credible contingency scenarios in the optimization, such as the loss of a transmission line or generator.
|
||||
|
||||
| Key | Description | Default
|
||||
| :-------------------- | :----------------------------------------------- | ----------
|
||||
| `Affected generators` | List of generators affected by this contingency. May be omitted if no generators are affected. | `[]`
|
||||
| `Affected lines` | List of transmission lines affected by this contingency. May be omitted if no lines are affected. | `[]`
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"Contingencies": {
|
||||
"c1": {
|
||||
"Affected lines": ["l1", "l2", "l3"],
|
||||
"Affected generators": ["g1"]
|
||||
},
|
||||
"c2": {
|
||||
"Affected lines": ["l4"]
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Additional remarks
|
||||
|
||||
### Time series parameters
|
||||
|
||||
Many numerical properties in the JSON file can be specified either as a single floating point number if they are time-independent, or as an array containing exactly `T` elements, where `T` is the length of the planning horizon, if they are time-dependent. For example, both formats below are valid when `T=3`:
|
||||
|
||||
```json
|
||||
{
|
||||
"Load (MW)": 800.0,
|
||||
"Load (MW)": [800.0, 850.0, 730.0]
|
||||
}
|
||||
```
|
||||
|
||||
### Current limitations
|
||||
|
||||
* All reserves are system-wide (no zonal reserves)
|
||||
* Network topology remains the same for all time periods
|
||||
* Only N-1 transmission contingencies are supported. Generator contingencies are not supported.
|
||||
60
src/docs/illustrations.ipynb
Normal file
60
src/docs/illustrations.ipynb
Normal file
File diff suppressed because one or more lines are too long
BIN
src/docs/images/cost_curve.png
Normal file
BIN
src/docs/images/cost_curve.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
23
src/docs/index.md
Normal file
23
src/docs/index.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# UnitCommitment.jl
|
||||
|
||||
**UnitCommitment.jl** is an optimization package for the Security-Constrained Unit Commitment Problem (SCUC), a fundamental optimization problem in power systems which is used, for example, to clear the day-ahead electricity markets. The problem asks for the most cost-effective power generation schedule under a number of physical, operational and economic constraints.
|
||||
|
||||
### Package Components
|
||||
|
||||
* **Data Format:** The package proposes an extensible and fully-documented JSON-based data specification format for SCUC, developed in collaboration with Independent System Operators (ISOs), which describes the most important aspects of the problem.
|
||||
* **Benchmark Instances:** The package provides a diverse collection of large-scale benchmark instances collected from the literature and extended to make them more challenging and realistic, based on publicly available data from ISOs.
|
||||
* **Model Implementation**: The package provides a Julia/JuMP implementation of state-of-the-art formulations and solution methods for SCUC. Our goal is to keep this implementation up-to-date, as new methods are proposed in the literature.
|
||||
* **Benchmark Tools:** The package provides automated benchmark scripts to accurately evaluate the performance impact of proposed code changes.
|
||||
|
||||
### Contents
|
||||
|
||||
* [Installation Guide](install.md)
|
||||
* [Data Format Specification](format.md)
|
||||
|
||||
### Authors
|
||||
* **Alinson Santos Xavier,** Argonne National Laboratory
|
||||
* **Feng Qiu,** Argonne National Laboratory
|
||||
|
||||
### Collaborators
|
||||
* **Yonghong Chen,** Midcontinent Independent System Operator
|
||||
* **Feng Pan,** Pacific Northwest National Laboratory
|
||||
21
src/docs/install.md
Normal file
21
src/docs/install.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Installation Guide
|
||||
|
||||
This package was tested and developed with [Julia 1.4](https://julialang.org/). To install Julia, please
|
||||
follow the [installation guide on their website](https://julialang.org/downloads/platform.html).
|
||||
To install `UnitCommitment.jl`, run the Julia interpreter, type `]` to open the
|
||||
package manager, then type:
|
||||
|
||||
```text
|
||||
pkg> add https://github.com/ANL-CEEESA/UnitCommitment.jl.git
|
||||
```
|
||||
|
||||
To test that the package has been correctly installed, run:
|
||||
|
||||
```text
|
||||
pkg> test UnitCommitment
|
||||
```
|
||||
|
||||
If all tests pass, the package should now be ready to be used by any Julia script on the machine. To try it out in the julia interpreter hit `backspace` to return to the regular interpreter, and type the following command:
|
||||
```julia
|
||||
using UnitCommitment
|
||||
```
|
||||
19
src/docs/isf.md
Normal file
19
src/docs/isf.md
Normal file
@@ -0,0 +1,19 @@
|
||||
Linear Sensitivity Factors
|
||||
==========================
|
||||
|
||||
UnitCommitment.jl includes a number of functions to compute typical linear sensitivity
|
||||
factors, such as [Injection Shift Factors](@ref) and [Line Outage Distribution Factors](@ref). These sensitivity factors can be used to quickly compute DC power flows in both base and N-1 contigency scenarios.
|
||||
|
||||
Injection Shift Factors
|
||||
-----------------------
|
||||
Given a network with `B` buses and `L` transmission lines, the Injection Shift Factors (ISF) matrix is an `L`-by-`B` matrix which indicates much power flows through a certain transmission line when 1 MW of power is injected at bus `b` and withdrawn from the slack bus. For example, `isf[:l7, :b5]` indicates the amount of power (in MW) that flows through line `l7` when 1 MW of power is injected at bus `b5` and withdrawn from the slack bus.
|
||||
This matrix is computed based on the DC linearization of power flow equations and does not include losses.
|
||||
To compute the ISF matrix, the function `injection_shift_factors` can be used. It is necessary to specify the set of lines, buses and the slack bus:
|
||||
```julia
|
||||
using UnitCommitment
|
||||
instance = UnitCommitment.load("ieee_rts/case14")
|
||||
isf = UnitCommitment.injection_shift_factors(lines = instance.lines,
|
||||
buses = instance.buses,
|
||||
slack = :b14)
|
||||
@show isf[:l7, :b5]
|
||||
```
|
||||
8
src/docs/js/mathjax.js
Normal file
8
src/docs/js/mathjax.js
Normal file
@@ -0,0 +1,8 @@
|
||||
MathJax.Hub.Config({
|
||||
"tex2jax": { inlineMath: [ [ '$', '$' ] ] }
|
||||
});
|
||||
MathJax.Hub.Config({
|
||||
config: ["MMLorHTML.js"],
|
||||
jax: ["input/TeX", "output/HTML-CSS", "output/NativeMML"],
|
||||
extensions: ["MathMenu.js", "MathZoom.js"]
|
||||
});
|
||||
90
src/docs/model.md
Normal file
90
src/docs/model.md
Normal file
@@ -0,0 +1,90 @@
|
||||
Benchmark Model
|
||||
===============
|
||||
|
||||
UnitCommitment.jl includes a reference Mixed-Integer Linear Programming
|
||||
(MILP), built with [JuMP](https://github.com/JuliaOpt/JuMP.jl), which can
|
||||
either be used as-is to solve instances of the problem, or be extended to
|
||||
build more complex formulations.
|
||||
|
||||
Building and Solving the Model
|
||||
-------------------------------
|
||||
|
||||
Given an instance and a JuMP optimizer, the function `build_model` can be used to
|
||||
build the reference MILP model. For example:
|
||||
|
||||
```julia
|
||||
using UnitCommitment, JuMP, Cbc
|
||||
instance = UnitCommitment.load("ieee_rts/case118")
|
||||
model = build_model(instance, with_optimizer(Cbc.Optimizer))
|
||||
```
|
||||
|
||||
The model enforces all unit constraints described in [Unit Commitment
|
||||
Instances](@ref), including ramping, minimum-up and minimum-down times. Some
|
||||
system-wide constraints, such as spinning reserves, are also enforced. The
|
||||
model, however, does not enforce transmission or N-1 security constraints,
|
||||
since these are typically generated on-the-fly.
|
||||
|
||||
A reference to the JuMP model is stored at `model.mip`. After constructed, the model can
|
||||
be optimized as follows:
|
||||
|
||||
```julia
|
||||
optimize!(model.mip)
|
||||
```
|
||||
|
||||
Decision Variables
|
||||
------------------
|
||||
|
||||
References to all decision variables are stored at `model.vars`.
|
||||
A complete list of available decision variables is as follows:
|
||||
|
||||
| Variable | Description
|
||||
| :---------------------------- | :---------------------------
|
||||
| `model.vars.production[gi,t]` | Amount of power (in MW) produced by unit with index `gi` at time `t`.
|
||||
| `model.vars.reserve[gi,t]` | Amount of spinning reserves (in MW) provided by unit with index `gi` at time `t`.
|
||||
| `model.vars.is_on[gi,t]` | Binary variable indicating if unit with index `gi` is operational at time `t`.
|
||||
| `model.vars.switch_on[gi,t]` | Binary variable indicating if unit with index `gi` was switched on at time `t`. That is, the unit was not operational at time `t-1`, but it is operational at time `t`.
|
||||
| `model.vars.switch_off[gi,t]` | Binary variable indicating if unit with index `gi` was switched off at time `t`. That is, the unit was operational at time `t-1`, but it is no longer operational at time `t`.
|
||||
| `model.vars.unit_cost[gi,t]` | The total cost to operate unit with index `gi` at time `t`. Includes start-up costs, no-load costs and any other production costs.
|
||||
| `model.vars.cost[t]` | Total cost at time `t`.
|
||||
| `model.vars.net_injection[bi,t]` | Total net injection (in MW) at bus with index `bi` and time `t`. Net injection is defined as the total power being produced by units located at the bus minus the bus load.
|
||||
|
||||
|
||||
Accessing the Solution
|
||||
----------------------
|
||||
To access the value of a particular decision variable after the
|
||||
optimization is completed, the function `JuMP.value(var)` can be used. The
|
||||
following example prints the amount of power (in MW) produced by each unit at time 5:
|
||||
|
||||
```julia
|
||||
for g in instance.units
|
||||
@show value(model.vars.production[g.index, 5])
|
||||
end
|
||||
```
|
||||
|
||||
Modifying the Model
|
||||
-------------------
|
||||
|
||||
Prior to being solved, the reference model can be modified by using the variable references
|
||||
above and conventional JuMP macros. For example, the
|
||||
following code can be used to ensure that at most 10 units are operational at time 4:
|
||||
|
||||
```julia
|
||||
using UnitCommitment, JuMP, Cbc
|
||||
instance = UnitCommitment.load("ieee_rts/case118")
|
||||
model = build_model(instance, with_optimizer(Cbc.Optimizer))
|
||||
|
||||
@contraint(model.mip,
|
||||
sum(model.vars.is_on[g.index, 4]
|
||||
for g in instance.units) <= 10)
|
||||
|
||||
optimize!(model.mip)
|
||||
```
|
||||
|
||||
It is not currently possible to modify the constraints included in the
|
||||
reference model.
|
||||
|
||||
Reference
|
||||
---------
|
||||
```@docs
|
||||
UnitCommitment.build_model
|
||||
```
|
||||
81
src/docs/solvers.md
Normal file
81
src/docs/solvers.md
Normal file
@@ -0,0 +1,81 @@
|
||||
Benchmark Solver
|
||||
================
|
||||
|
||||
Solving an instance of the Unit Commitment problem typically involves more
|
||||
than simply building a Mixed-Integer Linear Programming and handing it over to the
|
||||
solver. Since the number of transmission and N-1 security constraints can
|
||||
easily exceed hundreds of millions for large instances of the problem, it is often
|
||||
necessary to iterate between MILP optimization and contingency screening, so
|
||||
that only necessary transmission constraints are added to the MILP.
|
||||
|
||||
`UnitCommitment.jl` includes a fast implementation of the contingency
|
||||
screening method described in
|
||||
[[1]](https://doi.org/10.1109/TPWRS.2019.2892620), which is able to
|
||||
efficiently handle even ISO-scale instances of the problem. The method makes
|
||||
use of Injection Shift Factors (ISFs) and Line Outage Distribution Factors
|
||||
(LODFs) to model DC power flows and N-1 contingencies. If Julia is configured
|
||||
to use multiple threads (through the environment variable `JULIA_NUM_THREADS`)
|
||||
then multiple contingency scenarios are evaluated in parallel.
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
To solve one of the benchmark instances using the included benchmark solver, use the method `UnitCommitment.solve`
|
||||
as shown in the example below.
|
||||
|
||||
julia> UnitCommitment.solve("ieee_rts/case118")
|
||||
[ Info: Loading instance: ieee_rts/case118
|
||||
[ Info: 54 units
|
||||
[ Info: 118 buses
|
||||
[ Info: 186 lines
|
||||
[ Info: Scaling problem (0.6 demands, 1.0 limits)...
|
||||
[ Info: Using Cbc as MILP solver (0.001 gap, 4 threads)
|
||||
[ Info: Computing sensitivity factors (0.001 ISF cutoff, 0.0001 LODF cutoff)...
|
||||
[ Info: Building MILP model (24 hours, 0.01 reserve)...
|
||||
[ Info: Optimizing...
|
||||
[ Info: Optimal value: 4.033106e+06
|
||||
[ Info: Solved in 8.73 seconds
|
||||
|
||||
With default settings, the solver does not consider any transmission or
|
||||
security constraints, and the peak load is automatically set to 60% of the
|
||||
installed capacity of the system. These, and many other settings, can be
|
||||
configured using keyword arguments. See the reference section below for more
|
||||
details. Sample usage:
|
||||
|
||||
julia> UnitCommitment.solve("ieee_rts/case118", demand_scale=0.7, security=true)
|
||||
[ Info: Loading instance: ieee_rts/case118
|
||||
[ Info: 54 units
|
||||
[ Info: 118 buses
|
||||
[ Info: 186 lines
|
||||
[ Info: Scaling problem (0.7 demands, 1.0 limits)...
|
||||
[ Info: Using Cbc as MILP solver (0.001 gap, 4 threads)
|
||||
[ Info: Computing sensitivity factors (0.001 ISF cutoff, 0.0001 LODF cutoff)...
|
||||
[ Info: Building MILP model (24 hours, 0.01 reserve)...
|
||||
[ Info: Optimizing...
|
||||
[ Info: Verifying flow constraints...
|
||||
[ Info: Optimal value: 4.888740e+06
|
||||
[ Info: Solved in 4.50 seconds
|
||||
|
||||
When transmission or N-1 security constraints are activated, the solver uses an
|
||||
iterative method to lazily enforce them. See [this
|
||||
paper](https://doi.org/10.1109/TPWRS.2019.2892620) for a detailed description
|
||||
of the method. Injection Shift Factors (ISF) and Line Outage Distribution
|
||||
Factors (LODF) are used for the computation of DC power flows.
|
||||
|
||||
!!! note
|
||||
Many of the benchmark instances were not originally designed for N-1
|
||||
security-constrained studies, and may become infeasible if these constraints
|
||||
are enforced. To avoid infeasibilities, the transmission limits can be
|
||||
increased through the keyword argument `limit_scale`.
|
||||
|
||||
By default, the MILP is solved using [Cbc, the COIN-OR Branch and Cut
|
||||
solver](https://github.com/coin-or/Cbc). If `UnitCommitment` is loaded after
|
||||
either [CPLEX](https://github.com/JuliaOpt/CPLEX.jl) or
|
||||
[SCIP](https://github.com/SCIP-Interfaces/SCIP.jl), then these solvers will be
|
||||
used instead. A detailed solver log can be displayed by setting `verbose=true`.
|
||||
|
||||
## Reference
|
||||
|
||||
```@docs
|
||||
UnitCommitment.solve
|
||||
```
|
||||
68
src/dotdict.jl
Normal file
68
src/dotdict.jl
Normal file
@@ -0,0 +1,68 @@
|
||||
# UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment
|
||||
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
|
||||
# Released under the modified BSD license. See COPYING.md for more details.
|
||||
|
||||
struct DotDict
|
||||
inner::Dict
|
||||
end
|
||||
|
||||
DotDict() = DotDict(Dict())
|
||||
|
||||
function Base.setproperty!(d::DotDict, key::Symbol, value)
|
||||
setindex!(getfield(d, :inner), value, key)
|
||||
end
|
||||
|
||||
function Base.getproperty(d::DotDict, key::Symbol)
|
||||
(key == :inner ? getfield(d, :inner) : d.inner[key])
|
||||
end
|
||||
|
||||
function Base.getindex(d::DotDict, key::Int64)
|
||||
d.inner[Symbol(key)]
|
||||
end
|
||||
|
||||
function Base.getindex(d::DotDict, key::Symbol)
|
||||
d.inner[key]
|
||||
end
|
||||
|
||||
function Base.keys(d::DotDict)
|
||||
keys(d.inner)
|
||||
end
|
||||
|
||||
function Base.values(d::DotDict)
|
||||
values(d.inner)
|
||||
end
|
||||
|
||||
function Base.iterate(d::DotDict)
|
||||
iterate(values(d.inner))
|
||||
end
|
||||
|
||||
function Base.iterate(d::DotDict, v::Int64)
|
||||
iterate(values(d.inner), v)
|
||||
end
|
||||
|
||||
function Base.length(d::DotDict)
|
||||
length(values(d.inner))
|
||||
end
|
||||
|
||||
function Base.show(io::IO, d::DotDict)
|
||||
print(io, "DotDict with $(length(keys(d.inner))) entries:\n")
|
||||
count = 0
|
||||
for k in keys(d.inner)
|
||||
count += 1
|
||||
if count > 10
|
||||
print(io, " ...\n")
|
||||
break
|
||||
end
|
||||
print(io, " :$(k) => $(d.inner[k])\n")
|
||||
end
|
||||
end
|
||||
|
||||
function recursive_to_dot_dict(el)
|
||||
if typeof(el) == Dict{String, Any}
|
||||
return DotDict(Dict(Symbol(k) => recursive_to_dot_dict(el[k]) for k in keys(el)))
|
||||
else
|
||||
return el
|
||||
end
|
||||
end
|
||||
|
||||
export recursive_to_dot_dict
|
||||
76
src/initcond.jl
Normal file
76
src/initcond.jl
Normal file
@@ -0,0 +1,76 @@
|
||||
# UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment
|
||||
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
|
||||
# Released under the modified BSD license. See COPYING.md for more details.
|
||||
|
||||
using JuMP
|
||||
|
||||
"""
|
||||
generate_initial_conditions!(instance, optimizer)
|
||||
|
||||
Generates feasible initial conditions for the given instance, by constructing
|
||||
and solving a single-period mixed-integer optimization problem, using the given
|
||||
optimizer. The instance is modified in-place.
|
||||
"""
|
||||
function generate_initial_conditions!(instance::UnitCommitmentInstance,
|
||||
optimizer)
|
||||
G = instance.units
|
||||
B = instance.buses
|
||||
t = 1
|
||||
mip = JuMP.Model(optimizer)
|
||||
|
||||
# Decision variables
|
||||
@variable(mip, x[G], Bin)
|
||||
@variable(mip, p[G] >= 0)
|
||||
|
||||
# Constraint: Minimum power
|
||||
@constraint(mip,
|
||||
min_power[g in G],
|
||||
p[g] >= g.min_power[t] * x[g])
|
||||
|
||||
# Constraint: Maximum power
|
||||
@constraint(mip,
|
||||
max_power[g in G],
|
||||
p[g] <= g.max_power[t] * x[g])
|
||||
|
||||
# Constraint: Production equals demand
|
||||
@constraint(mip,
|
||||
power_balance,
|
||||
sum(b.load[t] for b in B) == sum(p[g] for g in G))
|
||||
|
||||
# Constraint: Must run
|
||||
for g in G
|
||||
if g.must_run[t]
|
||||
@constraint(mip, x[g] == 1)
|
||||
end
|
||||
end
|
||||
|
||||
# Objective function
|
||||
function cost_slope(g)
|
||||
mw = g.min_power[t]
|
||||
c = g.min_power_cost[t]
|
||||
for k in g.cost_segments
|
||||
mw += k.mw[t]
|
||||
c += k.mw[t] * k.cost[t]
|
||||
end
|
||||
if mw < 1e-3
|
||||
return 0.0
|
||||
else
|
||||
return c / mw
|
||||
end
|
||||
end
|
||||
@objective(mip,
|
||||
Min,
|
||||
sum(p[g] * cost_slope(g) for g in G))
|
||||
|
||||
JuMP.optimize!(mip)
|
||||
|
||||
for g in G
|
||||
if JuMP.value(x[g]) > 0
|
||||
g.initial_power = JuMP.value(p[g])
|
||||
g.initial_status = 24
|
||||
else
|
||||
g.initial_power = 0
|
||||
g.initial_status = -24
|
||||
end
|
||||
end
|
||||
end
|
||||
349
src/instance.jl
Normal file
349
src/instance.jl
Normal file
@@ -0,0 +1,349 @@
|
||||
# UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment
|
||||
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
|
||||
# Released under the modified BSD license. See COPYING.md for more details.
|
||||
|
||||
using Printf
|
||||
using JSON
|
||||
using DataStructures
|
||||
import Base: getindex, time
|
||||
import GZip
|
||||
|
||||
|
||||
mutable struct Bus
|
||||
name::String
|
||||
offset::Int
|
||||
load::Array{Float64}
|
||||
units::Array
|
||||
price_sensitive_loads::Array
|
||||
end
|
||||
|
||||
|
||||
mutable struct CostSegment
|
||||
mw::Array{Float64}
|
||||
cost::Array{Float64}
|
||||
end
|
||||
|
||||
|
||||
mutable struct StartupCategory
|
||||
delay::Int
|
||||
cost::Float64
|
||||
end
|
||||
|
||||
|
||||
mutable struct Unit
|
||||
name::String
|
||||
bus::Bus
|
||||
max_power::Array{Float64}
|
||||
min_power::Array{Float64}
|
||||
must_run::Array{Bool}
|
||||
min_power_cost::Array{Float64}
|
||||
cost_segments::Array{CostSegment}
|
||||
min_uptime::Int
|
||||
min_downtime::Int
|
||||
ramp_up_limit::Float64
|
||||
ramp_down_limit::Float64
|
||||
startup_limit::Float64
|
||||
shutdown_limit::Float64
|
||||
initial_status::Union{Int,Nothing}
|
||||
initial_power::Union{Float64,Nothing}
|
||||
provides_spinning_reserves::Array{Bool}
|
||||
startup_categories::Array{StartupCategory}
|
||||
end
|
||||
|
||||
|
||||
mutable struct TransmissionLine
|
||||
name::String
|
||||
offset::Int
|
||||
source::Bus
|
||||
target::Bus
|
||||
reactance::Float64
|
||||
susceptance::Float64
|
||||
normal_flow_limit::Array{Float64}
|
||||
emergency_flow_limit::Array{Float64}
|
||||
flow_limit_penalty::Array{Float64}
|
||||
end
|
||||
|
||||
|
||||
mutable struct Reserves
|
||||
spinning::Array{Float64}
|
||||
end
|
||||
|
||||
|
||||
mutable struct Contingency
|
||||
name::String
|
||||
lines::Array{TransmissionLine}
|
||||
units::Array{Unit}
|
||||
end
|
||||
|
||||
|
||||
mutable struct PriceSensitiveLoad
|
||||
name::String
|
||||
bus::Bus
|
||||
demand::Array{Float64}
|
||||
revenue::Array{Float64}
|
||||
end
|
||||
|
||||
|
||||
mutable struct UnitCommitmentInstance
|
||||
time::Int
|
||||
power_balance_penalty::Array{Float64}
|
||||
units::Array{Unit}
|
||||
buses::Array{Bus}
|
||||
lines::Array{TransmissionLine}
|
||||
reserves::Reserves
|
||||
contingencies::Array{Contingency}
|
||||
price_sensitive_loads::Array{PriceSensitiveLoad}
|
||||
end
|
||||
|
||||
|
||||
function Base.show(io::IO, instance::UnitCommitmentInstance)
|
||||
print(io, "UnitCommitmentInstance with ")
|
||||
print(io, "$(length(instance.units)) units, ")
|
||||
print(io, "$(length(instance.buses)) buses, ")
|
||||
print(io, "$(length(instance.lines)) lines, ")
|
||||
print(io, "$(length(instance.contingencies)) contingencies, ")
|
||||
print(io, "$(length(instance.price_sensitive_loads)) price sensitive loads")
|
||||
end
|
||||
|
||||
|
||||
function read_benchmark(name::AbstractString) :: UnitCommitmentInstance
|
||||
basedir = dirname(@__FILE__)
|
||||
return UnitCommitment.read("$basedir/../instances/$name.json.gz")
|
||||
end
|
||||
|
||||
|
||||
function read(path::AbstractString)::UnitCommitmentInstance
|
||||
if endswith(path, ".gz")
|
||||
return read(GZip.gzopen(path))
|
||||
else
|
||||
return read(open(path))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function read(file::IO)::UnitCommitmentInstance
|
||||
return from_json(JSON.parse(file, dicttype=()->DefaultOrderedDict(nothing)))
|
||||
end
|
||||
|
||||
function from_json(json; fix=true)
|
||||
units = Unit[]
|
||||
buses = Bus[]
|
||||
contingencies = Contingency[]
|
||||
lines = TransmissionLine[]
|
||||
loads = PriceSensitiveLoad[]
|
||||
T = json["Parameters"]["Time (h)"]
|
||||
|
||||
name_to_bus = Dict{String, Bus}()
|
||||
name_to_line = Dict{String, TransmissionLine}()
|
||||
name_to_unit = Dict{String, Unit}()
|
||||
|
||||
function timeseries(x; default=nothing)
|
||||
x != nothing || return default
|
||||
x isa Array || return [x for t in 1:T]
|
||||
return x
|
||||
end
|
||||
|
||||
function scalar(x; default=nothing)
|
||||
x != nothing || return default
|
||||
x
|
||||
end
|
||||
|
||||
# Read parameters
|
||||
power_balance_penalty = timeseries(json["Parameters"]["Power balance penalty (\$/MW)"],
|
||||
default=[1000.0 for t in 1:T])
|
||||
|
||||
# Read buses
|
||||
for (bus_name, dict) in json["Buses"]
|
||||
bus = Bus(bus_name,
|
||||
length(buses),
|
||||
timeseries(dict["Load (MW)"]),
|
||||
Unit[],
|
||||
PriceSensitiveLoad[])
|
||||
name_to_bus[bus_name] = bus
|
||||
push!(buses, bus)
|
||||
end
|
||||
|
||||
# Read units
|
||||
for (unit_name, dict) in json["Generators"]
|
||||
bus = name_to_bus[dict["Bus"]]
|
||||
|
||||
# Read production cost curve
|
||||
K = length(dict["Production cost curve (MW)"])
|
||||
curve_mw = hcat([timeseries(dict["Production cost curve (MW)"][k]) for k in 1:K]...)
|
||||
curve_cost = hcat([timeseries(dict["Production cost curve (\$)"][k]) for k in 1:K]...)
|
||||
min_power = curve_mw[:, 1]
|
||||
max_power = curve_mw[:, K]
|
||||
min_power_cost = curve_cost[:, 1]
|
||||
segments = CostSegment[]
|
||||
for k in 2:K
|
||||
amount = curve_mw[:, k] - curve_mw[:, k-1]
|
||||
cost = (curve_cost[:, k] - curve_cost[:, k-1]) ./ amount
|
||||
replace!(cost, NaN=>0.0)
|
||||
push!(segments, CostSegment(amount, cost))
|
||||
end
|
||||
|
||||
# Read startup costs
|
||||
startup_delays = scalar(dict["Startup delays (h)"], default=[1])
|
||||
startup_costs = scalar(dict["Startup costs (\$)"], default=[0.])
|
||||
startup_categories = StartupCategory[]
|
||||
for k in 1:length(startup_delays)
|
||||
push!(startup_categories, StartupCategory(startup_delays[k],
|
||||
startup_costs[k]))
|
||||
end
|
||||
|
||||
# Read and validate initial conditions
|
||||
initial_power = scalar(dict["Initial power (MW)"], default=nothing)
|
||||
initial_status = scalar(dict["Initial status (h)"], default=nothing)
|
||||
if initial_power == nothing
|
||||
initial_status == nothing || error("unit $unit_name has initial status but no initial power")
|
||||
else
|
||||
initial_status != nothing || error("unit $unit_name has initial power but no initial status")
|
||||
initial_status != 0 || error("unit $unit_name has invalid initial status")
|
||||
if initial_status < 0 && initial_power > 1e-3
|
||||
error("unit $unit_name has invalid initial power")
|
||||
end
|
||||
end
|
||||
|
||||
unit = Unit(unit_name,
|
||||
bus,
|
||||
max_power,
|
||||
min_power,
|
||||
timeseries(dict["Must run?"], default=[false for t in 1:T]),
|
||||
min_power_cost,
|
||||
segments,
|
||||
scalar(dict["Minimum uptime (h)"], default=1),
|
||||
scalar(dict["Minimum downtime (h)"], default=1),
|
||||
scalar(dict["Ramp up limit (MW)"], default=1e6),
|
||||
scalar(dict["Ramp down limit (MW)"], default=1e6),
|
||||
scalar(dict["Startup limit (MW)"], default=1e6),
|
||||
scalar(dict["Shutdown limit (MW)"], default=1e6),
|
||||
initial_status,
|
||||
initial_power,
|
||||
timeseries(dict["Provides spinning reserves?"],
|
||||
default=[true for t in 1:T]),
|
||||
startup_categories)
|
||||
push!(bus.units, unit)
|
||||
name_to_unit[unit_name] = unit
|
||||
push!(units, unit)
|
||||
end
|
||||
|
||||
# Read reserves
|
||||
reserves = Reserves(zeros(T))
|
||||
if "Reserves" in keys(json)
|
||||
reserves.spinning = timeseries(json["Reserves"]["Spinning (MW)"],
|
||||
default=zeros(T))
|
||||
end
|
||||
|
||||
# Read transmission lines
|
||||
if "Transmission lines" in keys(json)
|
||||
for (line_name, dict) in json["Transmission lines"]
|
||||
line = TransmissionLine(line_name,
|
||||
length(lines) + 1,
|
||||
name_to_bus[dict["Source bus"]],
|
||||
name_to_bus[dict["Target bus"]],
|
||||
scalar(dict["Reactance (ohms)"]),
|
||||
scalar(dict["Susceptance (S)"]),
|
||||
timeseries(dict["Normal flow limit (MW)"],
|
||||
default=[1e8 for t in 1:T]),
|
||||
timeseries(dict["Emergency flow limit (MW)"],
|
||||
default=[1e8 for t in 1:T]),
|
||||
timeseries(dict["Flow limit penalty (\$/MW)"],
|
||||
default=[5000.0 for t in 1:T]))
|
||||
name_to_line[line_name] = line
|
||||
push!(lines, line)
|
||||
end
|
||||
end
|
||||
|
||||
# Read contingencies
|
||||
if "Contingencies" in keys(json)
|
||||
for (cont_name, dict) in json["Contingencies"]
|
||||
affected_units = Unit[]
|
||||
affected_lines = TransmissionLine[]
|
||||
if "Affected lines" in keys(dict)
|
||||
affected_lines = [name_to_line[l] for l in dict["Affected lines"]]
|
||||
end
|
||||
if "Affected units" in keys(dict)
|
||||
affected_units = [name_to_unit[u] for u in dict["Affected units"]]
|
||||
end
|
||||
cont = Contingency(cont_name, affected_lines, affected_units)
|
||||
push!(contingencies, cont)
|
||||
end
|
||||
end
|
||||
|
||||
# Read price-sensitive loads
|
||||
if "Price-sensitive loads" in keys(json)
|
||||
for (load_name, dict) in json["Price-sensitive loads"]
|
||||
bus = name_to_bus[dict["Bus"]]
|
||||
load = PriceSensitiveLoad(load_name,
|
||||
bus,
|
||||
timeseries(dict["Demand (MW)"]),
|
||||
timeseries(dict["Revenue (\$/MW)"]),
|
||||
)
|
||||
push!(bus.price_sensitive_loads, load)
|
||||
push!(loads, load)
|
||||
end
|
||||
end
|
||||
|
||||
instance = UnitCommitmentInstance(T,
|
||||
power_balance_penalty,
|
||||
units,
|
||||
buses,
|
||||
lines,
|
||||
reserves,
|
||||
contingencies,
|
||||
loads)
|
||||
if fix
|
||||
UnitCommitment.fix!(instance)
|
||||
end
|
||||
return instance
|
||||
end
|
||||
|
||||
|
||||
"""
|
||||
slice(instance, range)
|
||||
|
||||
Creates a new instance, with only a subset of the time periods.
|
||||
This function does not modify the provided instance. The initial
|
||||
conditions are also not modified.
|
||||
|
||||
Example
|
||||
-------
|
||||
|
||||
# Build a 2-hour UC instance
|
||||
instance = UnitCommitment.read_benchmark("test/case14")
|
||||
modified = UnitCommitment.slice(instance, 1:2)
|
||||
|
||||
"""
|
||||
function slice(instance::UnitCommitmentInstance, range::UnitRange{Int})::UnitCommitmentInstance
|
||||
modified = deepcopy(instance)
|
||||
modified.time = length(range)
|
||||
modified.power_balance_penalty = modified.power_balance_penalty[range]
|
||||
modified.reserves.spinning = modified.reserves.spinning[range]
|
||||
for u in modified.units
|
||||
u.max_power = u.max_power[range]
|
||||
u.min_power = u.min_power[range]
|
||||
u.must_run = u.must_run[range]
|
||||
u.min_power_cost = u.min_power_cost[range]
|
||||
u.provides_spinning_reserves = u.provides_spinning_reserves[range]
|
||||
for s in u.cost_segments
|
||||
s.mw = s.mw[range]
|
||||
s.cost = s.cost[range]
|
||||
end
|
||||
end
|
||||
for b in modified.buses
|
||||
b.load = b.load[range]
|
||||
end
|
||||
for l in modified.lines
|
||||
l.normal_flow_limit = l.normal_flow_limit[range]
|
||||
l.emergency_flow_limit = l.emergency_flow_limit[range]
|
||||
l.flow_limit_penalty = l.flow_limit_penalty[range]
|
||||
end
|
||||
for ps in modified.price_sensitive_loads
|
||||
ps.demand = ps.demand[range]
|
||||
ps.revenue = ps.revenue[range]
|
||||
end
|
||||
return modified
|
||||
end
|
||||
|
||||
|
||||
export UnitCommitmentInstance
|
||||
50
src/log.jl
Normal file
50
src/log.jl
Normal file
@@ -0,0 +1,50 @@
|
||||
# UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment
|
||||
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
|
||||
# Released under the modified BSD license. See COPYING.md for more details.
|
||||
|
||||
import Logging: min_enabled_level, shouldlog, handle_message
|
||||
using Base.CoreLogging, Logging, Printf
|
||||
|
||||
struct TimeLogger <: AbstractLogger
|
||||
initial_time::Float64
|
||||
file::Union{Nothing, IOStream}
|
||||
screen_log_level
|
||||
io_log_level
|
||||
end
|
||||
|
||||
function TimeLogger(;
|
||||
initial_time::Float64,
|
||||
file::Union{Nothing, IOStream} = nothing,
|
||||
screen_log_level = CoreLogging.Info,
|
||||
io_log_level = CoreLogging.Info,
|
||||
) :: TimeLogger
|
||||
return TimeLogger(initial_time, file, screen_log_level, io_log_level)
|
||||
end
|
||||
|
||||
min_enabled_level(logger::TimeLogger) = logger.io_log_level
|
||||
shouldlog(logger::TimeLogger, level, _module, group, id) = true
|
||||
|
||||
function handle_message(logger::TimeLogger,
|
||||
level,
|
||||
message,
|
||||
_module,
|
||||
group,
|
||||
id,
|
||||
filepath,
|
||||
line;
|
||||
kwargs...)
|
||||
elapsed_time = time() - logger.initial_time
|
||||
time_string = @sprintf("[%12.3f] ", elapsed_time)
|
||||
if level >= logger.screen_log_level
|
||||
print(time_string)
|
||||
println(message)
|
||||
end
|
||||
if logger.file != nothing && level >= logger.io_log_level
|
||||
write(logger.file, time_string)
|
||||
write(logger.file, message)
|
||||
write(logger.file, "\n")
|
||||
flush(logger.file)
|
||||
end
|
||||
end
|
||||
|
||||
export TimeLogger
|
||||
646
src/model.jl
Normal file
646
src/model.jl
Normal file
@@ -0,0 +1,646 @@
|
||||
# UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment
|
||||
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
|
||||
# Released under the modified BSD license. See COPYING.md for more details.
|
||||
|
||||
using JuMP, MathOptInterface, DataStructures
|
||||
import JuMP: value, fix, set_name
|
||||
|
||||
|
||||
# Extend some JuMP functions so that decision variables can be safely replaced by
|
||||
# (constant) floating point numbers.
|
||||
function value(x::Float64)
|
||||
x
|
||||
end
|
||||
|
||||
function fix(x::Float64, v::Float64; force)
|
||||
abs(x - v) < 1e-6 || error("Value mismatch: $x != $v")
|
||||
end
|
||||
|
||||
function set_name(x::Float64, n::String)
|
||||
# nop
|
||||
end
|
||||
|
||||
|
||||
mutable struct UnitCommitmentModel
|
||||
mip::JuMP.Model
|
||||
vars::DotDict
|
||||
eqs::DotDict
|
||||
exprs::DotDict
|
||||
instance::UnitCommitmentInstance
|
||||
isf::Array{Float64, 2}
|
||||
lodf::Array{Float64, 2}
|
||||
obj::AffExpr
|
||||
end
|
||||
|
||||
|
||||
function build_model(;
|
||||
filename::Union{String, Nothing}=nothing,
|
||||
instance::Union{UnitCommitmentInstance, Nothing}=nothing,
|
||||
isf::Union{Array{Float64,2}, Nothing}=nothing,
|
||||
lodf::Union{Array{Float64,2}, Nothing}=nothing,
|
||||
isf_cutoff::Float64=0.005,
|
||||
lodf_cutoff::Float64=0.001,
|
||||
optimizer=nothing,
|
||||
model=nothing,
|
||||
variable_names::Bool=false,
|
||||
) :: UnitCommitmentModel
|
||||
|
||||
if (filename == nothing) && (instance == nothing)
|
||||
error("Either filename or instance must be specified")
|
||||
end
|
||||
|
||||
if filename != nothing
|
||||
@info "Reading: $(filename)"
|
||||
time_read = @elapsed begin
|
||||
instance = UnitCommitment.read(filename)
|
||||
end
|
||||
@info @sprintf("Read problem in %.2f seconds", time_read)
|
||||
end
|
||||
|
||||
if length(instance.buses) == 1
|
||||
isf = zeros(0, 0)
|
||||
lodf = zeros(0, 0)
|
||||
else
|
||||
if isf == nothing
|
||||
@info "Computing injection shift factors..."
|
||||
time_isf = @elapsed begin
|
||||
isf = UnitCommitment.injection_shift_factors(lines=instance.lines,
|
||||
buses=instance.buses)
|
||||
end
|
||||
@info @sprintf("Computed ISF in %.2f seconds", time_isf)
|
||||
|
||||
@info "Computing line outage factors..."
|
||||
time_lodf = @elapsed begin
|
||||
lodf = UnitCommitment.line_outage_factors(lines=instance.lines,
|
||||
buses=instance.buses,
|
||||
isf=isf)
|
||||
end
|
||||
@info @sprintf("Computed LODF in %.2f seconds", time_lodf)
|
||||
|
||||
@info @sprintf("Applying PTDF and LODF cutoffs (%.5f, %.5f)", isf_cutoff, lodf_cutoff)
|
||||
isf[abs.(isf) .< isf_cutoff] .= 0
|
||||
lodf[abs.(lodf) .< lodf_cutoff] .= 0
|
||||
end
|
||||
end
|
||||
|
||||
@info "Building model..."
|
||||
time_model = @elapsed begin
|
||||
if model == nothing
|
||||
if optimizer == nothing
|
||||
mip = Model()
|
||||
else
|
||||
mip = Model(optimizer)
|
||||
end
|
||||
else
|
||||
mip = model
|
||||
end
|
||||
model = UnitCommitmentModel(mip,
|
||||
DotDict(), # vars
|
||||
DotDict(), # eqs
|
||||
DotDict(), # exprs
|
||||
instance,
|
||||
isf,
|
||||
lodf,
|
||||
AffExpr(), # obj
|
||||
)
|
||||
for field in [:prod_above, :segprod, :reserve, :is_on, :switch_on, :switch_off,
|
||||
:net_injection, :curtail, :overflow, :loads, :startup]
|
||||
setproperty!(model.vars, field, OrderedDict())
|
||||
end
|
||||
for field in [:startup_choose, :startup_restrict, :segprod_limit, :prod_above_def,
|
||||
:prod_limit, :binary_link, :switch_on_off, :ramp_up, :ramp_down,
|
||||
:startup_limit, :shutdown_limit, :min_uptime, :min_downtime, :power_balance,
|
||||
:net_injection_def, :min_reserve]
|
||||
setproperty!(model.eqs, field, OrderedDict())
|
||||
end
|
||||
for field in [:inj, :reserve, :net_injection]
|
||||
setproperty!(model.exprs, field, OrderedDict())
|
||||
end
|
||||
for lm in instance.lines
|
||||
add_transmission_line!(model, lm)
|
||||
end
|
||||
for b in instance.buses
|
||||
add_bus!(model, b)
|
||||
end
|
||||
for g in instance.units
|
||||
add_unit!(model, g)
|
||||
end
|
||||
for ps in instance.price_sensitive_loads
|
||||
add_price_sensitive_load!(model, ps)
|
||||
end
|
||||
build_net_injection_eqs!(model)
|
||||
build_reserve_eqs!(model)
|
||||
build_obj_function!(model)
|
||||
end
|
||||
@info @sprintf("Built model in %.2f seconds", time_model)
|
||||
|
||||
if variable_names
|
||||
set_variable_names!(model)
|
||||
end
|
||||
|
||||
return model
|
||||
end
|
||||
|
||||
|
||||
function add_transmission_line!(model, lm)
|
||||
vars, obj, T = model.vars, model.obj, model.instance.time
|
||||
for t in 1:T
|
||||
overflow = vars.overflow[lm.name, t] = @variable(model.mip, lower_bound=0)
|
||||
add_to_expression!(obj, overflow, lm.flow_limit_penalty[t])
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function add_bus!(model::UnitCommitmentModel, b::Bus)
|
||||
mip, vars, exprs = model.mip, model.vars, model.exprs
|
||||
for t in 1:model.instance.time
|
||||
# Fixed load
|
||||
exprs.net_injection[b.name, t] = AffExpr(-b.load[t])
|
||||
|
||||
# Reserves
|
||||
exprs.reserve[b.name, t] = AffExpr()
|
||||
|
||||
# Load curtailment
|
||||
vars.curtail[b.name, t] = @variable(mip, lower_bound=0, upper_bound=b.load[t])
|
||||
add_to_expression!(exprs.net_injection[b.name, t], vars.curtail[b.name, t], 1.0)
|
||||
add_to_expression!(model.obj,
|
||||
vars.curtail[b.name, t],
|
||||
model.instance.power_balance_penalty[t])
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function add_price_sensitive_load!(model::UnitCommitmentModel, ps::PriceSensitiveLoad)
|
||||
mip, vars = model.mip, model.vars
|
||||
for t in 1:model.instance.time
|
||||
# Decision variable
|
||||
vars.loads[ps.name, t] = @variable(mip, lower_bound=0, upper_bound=ps.demand[t])
|
||||
|
||||
# Objective function terms
|
||||
add_to_expression!(model.obj, vars.loads[ps.name, t], -ps.revenue[t])
|
||||
|
||||
# Net injection
|
||||
add_to_expression!(model.exprs.net_injection[ps.bus.name, t], vars.loads[ps.name, t], -1.0)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function add_unit!(model::UnitCommitmentModel, g::Unit)
|
||||
mip, vars, eqs, exprs, T = model.mip, model.vars, model.eqs, model.exprs, model.instance.time
|
||||
gi, K, S = g.name, length(g.cost_segments), length(g.startup_categories)
|
||||
|
||||
if !all(g.must_run) && any(g.must_run)
|
||||
error("Partially must-run units are not currently supported")
|
||||
end
|
||||
|
||||
if g.initial_power == nothing || g.initial_status == nothing
|
||||
error("Initial conditions for $(g.name) must be provided")
|
||||
end
|
||||
|
||||
is_initially_on = (g.initial_status > 0 ? 1.0 : 0.0)
|
||||
|
||||
# Decision variables
|
||||
for t in 1:T
|
||||
for k in 1:K
|
||||
model.vars.segprod[gi, t, k] = @variable(model.mip, lower_bound=0)
|
||||
end
|
||||
model.vars.prod_above[gi, t] = @variable(model.mip, lower_bound=0)
|
||||
if g.provides_spinning_reserves[t]
|
||||
model.vars.reserve[gi, t] = @variable(model.mip, lower_bound=0)
|
||||
else
|
||||
model.vars.reserve[gi, t] = 0.0
|
||||
end
|
||||
for s in 1:S
|
||||
model.vars.startup[gi, t, s] = @variable(model.mip, binary=true)
|
||||
end
|
||||
if g.must_run[t]
|
||||
model.vars.is_on[gi, t] = 1.0
|
||||
model.vars.switch_on[gi, t] = (t == 1 ? 1.0 - is_initially_on : 0.0)
|
||||
model.vars.switch_off[gi, t] = 0.0
|
||||
else
|
||||
model.vars.is_on[gi, t] = @variable(model.mip, binary=true)
|
||||
model.vars.switch_on[gi, t] = @variable(model.mip, binary=true)
|
||||
model.vars.switch_off[gi, t] = @variable(model.mip, binary=true)
|
||||
end
|
||||
end
|
||||
|
||||
for t in 1:T
|
||||
# Time-dependent start-up costs
|
||||
for s in 1:S
|
||||
# If unit is switching on, we must choose a startup category
|
||||
eqs.startup_choose[gi, t, s] =
|
||||
@constraint(mip, vars.switch_on[gi, t] == sum(vars.startup[gi, t, s] for s in 1:S))
|
||||
|
||||
# If unit has not switched off in the last `delay` time periods, startup category is forbidden.
|
||||
# The last startup category is always allowed.
|
||||
if s < S
|
||||
range = (t - g.startup_categories[s + 1].delay + 1):(t - g.startup_categories[s].delay)
|
||||
initial_sum = (g.initial_status < 0 && (g.initial_status + 1 in range) ? 1.0 : 0.0)
|
||||
eqs.startup_restrict[gi, t, s] =
|
||||
@constraint(mip, vars.startup[gi, t, s]
|
||||
<= initial_sum + sum(vars.switch_off[gi, i] for i in range if i >= 1))
|
||||
end
|
||||
|
||||
# Objective function terms for start-up costs
|
||||
add_to_expression!(model.obj,
|
||||
vars.startup[gi, t, s],
|
||||
g.startup_categories[s].cost)
|
||||
end
|
||||
|
||||
# Objective function terms for production costs
|
||||
add_to_expression!(model.obj, vars.is_on[gi, t], g.min_power_cost[t])
|
||||
for k in 1:K
|
||||
add_to_expression!(model.obj, vars.segprod[gi, t, k], g.cost_segments[k].cost[t])
|
||||
end
|
||||
|
||||
# Production limits (piecewise-linear segments)
|
||||
for k in 1:K
|
||||
eqs.segprod_limit[gi, t, k] =
|
||||
@constraint(mip, vars.segprod[gi, t, k] <= g.cost_segments[k].mw[t] * vars.is_on[gi, t])
|
||||
end
|
||||
|
||||
# Definition of production
|
||||
eqs.prod_above_def[gi, t] =
|
||||
@constraint(mip, vars.prod_above[gi, t] == sum(vars.segprod[gi, t, k] for k in 1:K))
|
||||
|
||||
# Production limit
|
||||
eqs.prod_limit[gi, t] =
|
||||
@constraint(mip,
|
||||
vars.prod_above[gi, t] + vars.reserve[gi, t]
|
||||
<= (g.max_power[t] - g.min_power[t]) * vars.is_on[gi, t])
|
||||
|
||||
# Binary variable equations for economic units
|
||||
if !g.must_run[t]
|
||||
|
||||
# Link binary variables
|
||||
if t == 1
|
||||
eqs.binary_link[gi, t] =
|
||||
@constraint(mip,
|
||||
vars.is_on[gi, t] - is_initially_on ==
|
||||
vars.switch_on[gi, t] - vars.switch_off[gi, t])
|
||||
else
|
||||
eqs.binary_link[gi, t] =
|
||||
@constraint(mip,
|
||||
vars.is_on[gi, t] - vars.is_on[gi, t-1] ==
|
||||
vars.switch_on[gi, t] - vars.switch_off[gi, t])
|
||||
end
|
||||
|
||||
# Cannot switch on and off at the same time
|
||||
eqs.switch_on_off[gi, t] =
|
||||
@constraint(mip, vars.switch_on[gi, t] + vars.switch_off[gi, t] <= 1)
|
||||
end
|
||||
|
||||
# Ramp up limit
|
||||
if t == 1
|
||||
if is_initially_on == 1
|
||||
eqs.ramp_up[gi, t] =
|
||||
@constraint(mip,
|
||||
vars.prod_above[gi, t] + vars.reserve[gi, t] <=
|
||||
(g.initial_power - g.min_power[t]) + g.ramp_up_limit)
|
||||
end
|
||||
else
|
||||
eqs.ramp_up[gi, t] =
|
||||
@constraint(mip,
|
||||
vars.prod_above[gi, t] + vars.reserve[gi, t] <=
|
||||
vars.prod_above[gi, t-1] + g.ramp_up_limit)
|
||||
end
|
||||
|
||||
# Ramp down limit
|
||||
if t == 1
|
||||
if is_initially_on == 1
|
||||
eqs.ramp_down[gi, t] =
|
||||
@constraint(mip,
|
||||
vars.prod_above[gi, t] >=
|
||||
(g.initial_power - g.min_power[t]) - g.ramp_down_limit)
|
||||
end
|
||||
else
|
||||
eqs.ramp_down[gi, t] =
|
||||
@constraint(mip,
|
||||
vars.prod_above[gi, t] >=
|
||||
vars.prod_above[gi, t-1] - g.ramp_down_limit)
|
||||
end
|
||||
|
||||
# Startup limit
|
||||
eqs.startup_limit[gi, t] =
|
||||
@constraint(mip,
|
||||
vars.prod_above[gi, t] + vars.reserve[gi, t] <=
|
||||
(g.max_power[t] - g.min_power[t]) * vars.is_on[gi, t]
|
||||
- max(0, g.max_power[t] - g.startup_limit) * vars.switch_on[gi, t])
|
||||
|
||||
# Shutdown limit
|
||||
if g.initial_power > g.shutdown_limit
|
||||
eqs.shutdown_limit[gi, 0] =
|
||||
@constraint(mip, vars.switch_off[gi, 1] <= 0)
|
||||
end
|
||||
if t < T
|
||||
eqs.shutdown_limit[gi, t] =
|
||||
@constraint(mip,
|
||||
vars.prod_above[gi, t] <=
|
||||
(g.max_power[t] - g.min_power[t]) * vars.is_on[gi, t]
|
||||
- max(0, g.max_power[t] - g.shutdown_limit) * vars.switch_off[gi, t+1])
|
||||
end
|
||||
|
||||
# Minimum up-time
|
||||
eqs.min_uptime[gi, t] =
|
||||
@constraint(mip,
|
||||
sum(vars.switch_on[gi, i]
|
||||
for i in (t - g.min_uptime + 1):t if i >= 1
|
||||
) <= vars.is_on[gi, t])
|
||||
|
||||
# # Minimum down-time
|
||||
eqs.min_downtime[gi, t] =
|
||||
@constraint(mip,
|
||||
sum(vars.switch_off[gi, i]
|
||||
for i in (t - g.min_downtime + 1):t if i >= 1
|
||||
) <= 1 - vars.is_on[gi, t])
|
||||
|
||||
# Minimum up/down-time for initial periods
|
||||
if t == 1
|
||||
if g.initial_status > 0
|
||||
eqs.min_uptime[gi, 0] =
|
||||
@constraint(mip, sum(vars.switch_off[gi, i]
|
||||
for i in 1:(g.min_uptime - g.initial_status) if i <= T) == 0)
|
||||
else
|
||||
eqs.min_downtime[gi, 0] =
|
||||
@constraint(mip, sum(vars.switch_on[gi, i]
|
||||
for i in 1:(g.min_downtime + g.initial_status) if i <= T) == 0)
|
||||
end
|
||||
end
|
||||
|
||||
# Add to net injection expression
|
||||
add_to_expression!(exprs.net_injection[g.bus.name, t], vars.prod_above[g.name, t], 1.0)
|
||||
add_to_expression!(exprs.net_injection[g.bus.name, t], vars.is_on[g.name, t], g.min_power[t])
|
||||
|
||||
# Add to reserves expression
|
||||
add_to_expression!(exprs.reserve[g.bus.name, t], vars.reserve[gi, t], 1.0)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function build_obj_function!(model::UnitCommitmentModel)
|
||||
@objective(model.mip, Min, model.obj)
|
||||
end
|
||||
|
||||
|
||||
function build_net_injection_eqs!(model::UnitCommitmentModel)
|
||||
T = model.instance.time
|
||||
for t in 1:T, b in model.instance.buses
|
||||
net = model.vars.net_injection[b.name, t] = @variable(model.mip)
|
||||
model.eqs.net_injection_def[t, b.name] =
|
||||
@constraint(model.mip, net == model.exprs.net_injection[b.name, t])
|
||||
end
|
||||
for t in 1:T
|
||||
model.eqs.power_balance[t] =
|
||||
@constraint(model.mip, sum(model.vars.net_injection[b.name, t]
|
||||
for b in model.instance.buses) == 0)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function build_reserve_eqs!(model::UnitCommitmentModel)
|
||||
reserves = model.instance.reserves
|
||||
for t in 1:model.instance.time
|
||||
model.eqs.min_reserve[t] =
|
||||
@constraint(model.mip, sum(model.exprs.reserve[b.name, t]
|
||||
for b in model.instance.buses) >= reserves.spinning[t])
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function enforce_transmission(;
|
||||
model::UnitCommitmentModel,
|
||||
violation::Violation,
|
||||
isf::Array{Float64,2},
|
||||
lodf::Array{Float64,2})::Nothing
|
||||
|
||||
instance, mip, vars = model.instance, model.mip, model.vars
|
||||
limit::Float64 = 0.0
|
||||
|
||||
if violation.outage_line == nothing
|
||||
limit = violation.monitored_line.normal_flow_limit[violation.time]
|
||||
@info @sprintf(" %8.3f MW overflow in %-5s time %3d (pre-contingency)",
|
||||
violation.amount,
|
||||
violation.monitored_line.name,
|
||||
violation.time)
|
||||
else
|
||||
limit = violation.monitored_line.emergency_flow_limit[violation.time]
|
||||
@info @sprintf(" %8.3f MW overflow in %-5s time %3d (outage: line %s)",
|
||||
violation.amount,
|
||||
violation.monitored_line.name,
|
||||
violation.time,
|
||||
violation.outage_line.name)
|
||||
end
|
||||
|
||||
fm = violation.monitored_line.name
|
||||
t = violation.time
|
||||
flow = @variable(mip, base_name="flow[$fm,$t]")
|
||||
|
||||
overflow = vars.overflow[violation.monitored_line.name, violation.time]
|
||||
@constraint(mip, flow <= limit + overflow)
|
||||
@constraint(mip, -flow <= limit + overflow)
|
||||
|
||||
if violation.outage_line == nothing
|
||||
@constraint(mip, flow == sum(vars.net_injection[b.name, violation.time] *
|
||||
isf[violation.monitored_line.offset, b.offset]
|
||||
for b in instance.buses
|
||||
if b.offset > 0))
|
||||
else
|
||||
@constraint(mip, flow == sum(vars.net_injection[b.name, violation.time] * (
|
||||
isf[violation.monitored_line.offset, b.offset] + (
|
||||
lodf[violation.monitored_line.offset, violation.outage_line.offset] *
|
||||
isf[violation.outage_line.offset, b.offset]
|
||||
)
|
||||
)
|
||||
for b in instance.buses
|
||||
if b.offset > 0))
|
||||
end
|
||||
nothing
|
||||
end
|
||||
|
||||
|
||||
function set_variable_names!(model::UnitCommitmentModel)
|
||||
@info "Setting variable and constraint names..."
|
||||
time_varnames = @elapsed begin
|
||||
set_jump_names!(model.vars)
|
||||
set_jump_names!(model.eqs)
|
||||
end
|
||||
@info @sprintf("Set names in %.2f seconds", time_varnames)
|
||||
end
|
||||
|
||||
|
||||
function set_jump_names!(dict)
|
||||
for name in keys(dict)
|
||||
for idx in keys(dict[name])
|
||||
idx_str = join(map(string, idx), ",")
|
||||
set_name(dict[name][idx], "$name[$idx_str]")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function get_solution(model::UnitCommitmentModel)
|
||||
instance, T = model.instance, model.instance.time
|
||||
function timeseries(vars, collection)
|
||||
return OrderedDict(b.name => [round(value(vars[b.name, t]), digits=5) for t in 1:T]
|
||||
for b in collection)
|
||||
end
|
||||
function production_cost(g)
|
||||
return [value(model.vars.is_on[g.name, t]) * g.min_power_cost[t] +
|
||||
sum(Float64[value(model.vars.segprod[g.name, t, k]) * g.cost_segments[k].cost[t]
|
||||
for k in 1:length(g.cost_segments)])
|
||||
for t in 1:T]
|
||||
end
|
||||
function production(g)
|
||||
return [value(model.vars.is_on[g.name, t]) * g.min_power[t] +
|
||||
sum(Float64[value(model.vars.segprod[g.name, t, k])
|
||||
for k in 1:length(g.cost_segments)])
|
||||
for t in 1:T]
|
||||
end
|
||||
function startup_cost(g)
|
||||
S = length(g.startup_categories)
|
||||
return [sum(g.startup_categories[s].cost * value(model.vars.startup[g.name, t, s])
|
||||
for s in 1:S)
|
||||
for t in 1:T]
|
||||
end
|
||||
sol = OrderedDict()
|
||||
sol["Production (MW)"] = OrderedDict(g.name => production(g) for g in instance.units)
|
||||
sol["Production cost (\$)"] = OrderedDict(g.name => production_cost(g) for g in instance.units)
|
||||
sol["Startup cost (\$)"] = OrderedDict(g.name => startup_cost(g) for g in instance.units)
|
||||
sol["Is on"] = timeseries(model.vars.is_on, instance.units)
|
||||
sol["Switch on"] = timeseries(model.vars.switch_on, instance.units)
|
||||
sol["Switch off"] = timeseries(model.vars.switch_off, instance.units)
|
||||
sol["Reserve (MW)"] = timeseries(model.vars.reserve, instance.units)
|
||||
sol["Net injection (MW)"] = timeseries(model.vars.net_injection, instance.buses)
|
||||
sol["Load curtail (MW)"] = timeseries(model.vars.curtail, instance.buses)
|
||||
if !isempty(instance.lines)
|
||||
sol["Line overflow (MW)"] = timeseries(model.vars.overflow, instance.lines)
|
||||
end
|
||||
if !isempty(instance.price_sensitive_loads)
|
||||
sol["Price-sensitive loads (MW)"] = timeseries(model.vars.loads, instance.price_sensitive_loads)
|
||||
end
|
||||
return sol
|
||||
end
|
||||
|
||||
|
||||
function fix!(model::UnitCommitmentModel, solution)::Nothing
|
||||
vars, instance, T = model.vars, model.instance, model.instance.time
|
||||
for g in instance.units
|
||||
for t in 1:T
|
||||
is_on = round(solution["Is on"][g.name][t])
|
||||
production = round(solution["Production (MW)"][g.name][t], digits=5)
|
||||
reserve = round(solution["Reserve (MW)"][g.name][t], digits=5)
|
||||
JuMP.fix(vars.is_on[g.name, t], is_on, force=true)
|
||||
JuMP.fix(vars.prod_above[g.name, t], production - is_on * g.min_power[t], force=true)
|
||||
JuMP.fix(vars.reserve[g.name, t], reserve, force=true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function set_warm_start!(model::UnitCommitmentModel, solution)::Nothing
|
||||
vars, instance, T = model.vars, model.instance, model.instance.time
|
||||
for g in instance.units
|
||||
for t in 1:T
|
||||
JuMP.set_start_value(vars.is_on[g.name, t], solution["Is on"][g.name][t])
|
||||
JuMP.set_start_value(vars.switch_on[g.name, t], solution["Switch on"][g.name][t])
|
||||
JuMP.set_start_value(vars.switch_off[g.name, t], solution["Switch off"][g.name][t])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function optimize!(model::UnitCommitmentModel;
|
||||
time_limit=3600,
|
||||
gap_limit=1e-4,
|
||||
two_phase_gap=true,
|
||||
)::Nothing
|
||||
|
||||
function set_gap(gap)
|
||||
try
|
||||
JuMP.set_optimizer_attribute(model.mip, "MIPGap", gap)
|
||||
@info @sprintf("MIP gap tolerance set to %f", gap)
|
||||
catch
|
||||
@warn "Could not change MIP gap tolerance"
|
||||
end
|
||||
end
|
||||
|
||||
instance = model.instance
|
||||
initial_time = time()
|
||||
|
||||
large_gap = false
|
||||
has_transmission = (length(model.isf) > 0)
|
||||
|
||||
if has_transmission && two_phase_gap
|
||||
set_gap(1e-2)
|
||||
large_gap = true
|
||||
else
|
||||
set_gap(gap_limit)
|
||||
end
|
||||
|
||||
while true
|
||||
time_elapsed = time() - initial_time
|
||||
time_remaining = time_limit - time_elapsed
|
||||
if time_remaining < 0
|
||||
@info "Time limit exceeded"
|
||||
break
|
||||
end
|
||||
|
||||
@info @sprintf("Setting MILP time limit to %.2f seconds", time_remaining)
|
||||
JuMP.set_time_limit_sec(model.mip, time_remaining)
|
||||
|
||||
@info "Solving MILP..."
|
||||
JuMP.optimize!(model.mip)
|
||||
|
||||
has_transmission || break
|
||||
|
||||
violations = find_violations(model)
|
||||
if isempty(violations)
|
||||
@info "No violations found"
|
||||
if large_gap
|
||||
large_gap = false
|
||||
set_gap(gap_limit)
|
||||
else
|
||||
break
|
||||
end
|
||||
else
|
||||
enforce_transmission(model, violations)
|
||||
end
|
||||
end
|
||||
|
||||
nothing
|
||||
end
|
||||
|
||||
|
||||
function find_violations(model::UnitCommitmentModel)
|
||||
instance, vars = model.instance, model.vars
|
||||
length(instance.buses) > 1 || return []
|
||||
violations = []
|
||||
@info "Verifying transmission limits..."
|
||||
time_screening = @elapsed begin
|
||||
non_slack_buses = [b for b in instance.buses if b.offset > 0]
|
||||
net_injections = [value(vars.net_injection[b.name, t])
|
||||
for b in non_slack_buses, t in 1:instance.time]
|
||||
overflow = [value(vars.overflow[lm.name, t])
|
||||
for lm in instance.lines, t in 1:instance.time]
|
||||
violations = UnitCommitment.find_violations(instance=instance,
|
||||
net_injections=net_injections,
|
||||
overflow=overflow,
|
||||
isf=model.isf,
|
||||
lodf=model.lodf)
|
||||
end
|
||||
@info @sprintf("Verified transmission limits in %.2f seconds", time_screening)
|
||||
return violations
|
||||
end
|
||||
|
||||
|
||||
function enforce_transmission(model::UnitCommitmentModel, violations::Array{Violation, 1})
|
||||
for v in violations
|
||||
enforce_transmission(model=model,
|
||||
violation=v,
|
||||
isf=model.isf,
|
||||
lodf=model.lodf)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
export UnitCommitmentModel, build_model, get_solution, optimize!
|
||||
198
src/screening.jl
Normal file
198
src/screening.jl
Normal file
@@ -0,0 +1,198 @@
|
||||
# UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment
|
||||
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
|
||||
# Released under the modified BSD license. See COPYING.md for more details.
|
||||
# Copyright (C) 2019 Argonne National Laboratory
|
||||
# Written by Alinson Santos Xavier <axavier@anl.gov>
|
||||
|
||||
|
||||
using DataStructures
|
||||
using Base.Threads
|
||||
|
||||
|
||||
struct Violation
|
||||
time::Int
|
||||
monitored_line::TransmissionLine
|
||||
outage_line::Union{TransmissionLine, Nothing}
|
||||
amount::Float64 # Violation amount (in MW)
|
||||
end
|
||||
|
||||
|
||||
function Violation(;
|
||||
time::Int,
|
||||
monitored_line::TransmissionLine,
|
||||
outage_line::Union{TransmissionLine, Nothing},
|
||||
amount::Float64,
|
||||
) :: Violation
|
||||
return Violation(time, monitored_line, outage_line, amount)
|
||||
end
|
||||
|
||||
|
||||
mutable struct ViolationFilter
|
||||
max_per_line::Int
|
||||
max_total::Int
|
||||
queues::Dict{Int, PriorityQueue{Violation, Float64}}
|
||||
end
|
||||
|
||||
|
||||
function ViolationFilter(;
|
||||
max_per_line::Int=1,
|
||||
max_total::Int=5,
|
||||
)::ViolationFilter
|
||||
return ViolationFilter(max_per_line, max_total, Dict())
|
||||
end
|
||||
|
||||
|
||||
function offer(filter::ViolationFilter, v::Violation)::Nothing
|
||||
if v.monitored_line.offset ∉ keys(filter.queues)
|
||||
filter.queues[v.monitored_line.offset] = PriorityQueue{Violation, Float64}()
|
||||
end
|
||||
q::PriorityQueue{Violation, Float64} = filter.queues[v.monitored_line.offset]
|
||||
if length(q) < filter.max_per_line
|
||||
enqueue!(q, v => v.amount)
|
||||
else
|
||||
if v.amount > peek(q)[1].amount
|
||||
dequeue!(q)
|
||||
enqueue!(q, v => v.amount)
|
||||
end
|
||||
end
|
||||
nothing
|
||||
end
|
||||
|
||||
|
||||
function query(filter::ViolationFilter)::Array{Violation, 1}
|
||||
violations = Array{Violation,1}()
|
||||
time_queue = PriorityQueue{Violation, Float64}()
|
||||
for l in keys(filter.queues)
|
||||
line_queue = filter.queues[l]
|
||||
while length(line_queue) > 0
|
||||
v = dequeue!(line_queue)
|
||||
if length(time_queue) < filter.max_total
|
||||
enqueue!(time_queue, v => v.amount)
|
||||
else
|
||||
if v.amount > peek(time_queue)[1].amount
|
||||
dequeue!(time_queue)
|
||||
enqueue!(time_queue, v => v.amount)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
while length(time_queue) > 0
|
||||
violations = [violations; dequeue!(time_queue)]
|
||||
end
|
||||
return violations
|
||||
end
|
||||
|
||||
|
||||
"""
|
||||
|
||||
function find_violations(instance::UnitCommitmentInstance,
|
||||
net_injections::Array{Float64, 2};
|
||||
isf::Array{Float64,2},
|
||||
lodf::Array{Float64,2},
|
||||
max_per_line::Int = 1,
|
||||
max_per_period::Int = 5,
|
||||
) :: Array{Violation, 1}
|
||||
|
||||
Find transmission constraint violations (both pre-contingency, as well as post-contingency).
|
||||
|
||||
The argument `net_injection` should be a (B-1) x T matrix, where B is the number of buses
|
||||
and T is the number of time periods. The arguments `isf` and `lodf` can be computed using
|
||||
UnitCommitment.injection_shift_factors and UnitCommitment.line_outage_factors.
|
||||
The argument `overflow` specifies how much flow above the transmission limits (in MW) is allowed.
|
||||
It should be an L x T matrix, where L is the number of transmission lines.
|
||||
"""
|
||||
function find_violations(;
|
||||
instance::UnitCommitmentInstance,
|
||||
net_injections::Array{Float64, 2},
|
||||
overflow::Array{Float64, 2},
|
||||
isf::Array{Float64,2},
|
||||
lodf::Array{Float64,2},
|
||||
max_per_line::Int = 1,
|
||||
max_per_period::Int = 5,
|
||||
)::Array{Violation, 1}
|
||||
|
||||
B = length(instance.buses) - 1
|
||||
L = length(instance.lines)
|
||||
T = instance.time
|
||||
K = nthreads()
|
||||
|
||||
size(net_injections) == (B, T) || error("net_injections has incorrect size")
|
||||
size(isf) == (L, B) || error("isf has incorrect size")
|
||||
size(lodf) == (L, L) || error("lodf has incorrect size")
|
||||
|
||||
filters = Dict(t => ViolationFilter(max_total=max_per_period,
|
||||
max_per_line=max_per_line)
|
||||
for t in 1:T)
|
||||
|
||||
pre_flow::Array{Float64} = zeros(L, K) # pre_flow[lm, thread]
|
||||
post_flow::Array{Float64} = zeros(L, L, K) # post_flow[lm, lc, thread]
|
||||
pre_v::Array{Float64} = zeros(L, K) # pre_v[lm, thread]
|
||||
post_v::Array{Float64} = zeros(L, L, K) # post_v[lm, lc, thread]
|
||||
|
||||
normal_limits::Array{Float64,2} = [l.normal_flow_limit[t] + overflow[l.offset, t]
|
||||
for l in instance.lines, t in 1:T]
|
||||
|
||||
emergency_limits::Array{Float64,2} = [l.emergency_flow_limit[t] + overflow[l.offset, t]
|
||||
for l in instance.lines, t in 1:T]
|
||||
|
||||
is_vulnerable::Array{Bool} = zeros(Bool, L)
|
||||
for c in instance.contingencies
|
||||
is_vulnerable[c.lines[1].offset] = true
|
||||
end
|
||||
|
||||
@threads for t in 1:T
|
||||
k = threadid()
|
||||
|
||||
# Pre-contingency flows
|
||||
pre_flow[:, k] = isf * net_injections[:, t]
|
||||
|
||||
# Post-contingency flows
|
||||
for lc in 1:L, lm in 1:L
|
||||
post_flow[lm, lc, k] = pre_flow[lm, k] + pre_flow[lc, k] * lodf[lm, lc]
|
||||
end
|
||||
|
||||
# Pre-contingency violations
|
||||
for lm in 1:L
|
||||
pre_v[lm, k] = max(0.0,
|
||||
pre_flow[lm, k] - normal_limits[lm, t],
|
||||
- pre_flow[lm, k] - normal_limits[lm, t])
|
||||
end
|
||||
|
||||
# Post-contingency violations
|
||||
for lc in 1:L, lm in 1:L
|
||||
post_v[lm, lc, k] = max(0.0,
|
||||
post_flow[lm, lc, k] - emergency_limits[lm, t],
|
||||
- post_flow[lm, lc, k] - emergency_limits[lm, t])
|
||||
end
|
||||
|
||||
# Offer pre-contingency violations
|
||||
for lm in 1:L
|
||||
if pre_v[lm, k] > 1e-5
|
||||
offer(filters[t], Violation(time=t,
|
||||
monitored_line=instance.lines[lm],
|
||||
outage_line=nothing,
|
||||
amount=pre_v[lm, k]))
|
||||
end
|
||||
end
|
||||
|
||||
# Offer post-contingency violations
|
||||
for lm in 1:L, lc in 1:L
|
||||
if post_v[lm, lc, k] > 1e-5 && is_vulnerable[lc]
|
||||
offer(filters[t], Violation(time=t,
|
||||
monitored_line=instance.lines[lm],
|
||||
outage_line=instance.lines[lc],
|
||||
amount=post_v[lm, lc, k]))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
violations = Violation[]
|
||||
for t in 1:instance.time
|
||||
append!(violations, query(filters[t]))
|
||||
end
|
||||
|
||||
return violations
|
||||
end
|
||||
|
||||
|
||||
export Violation, ViolationFilter, offer, query, find_violations
|
||||
80
src/sensitivity.jl
Normal file
80
src/sensitivity.jl
Normal file
@@ -0,0 +1,80 @@
|
||||
# UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment
|
||||
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
|
||||
# Released under the modified BSD license. See COPYING.md for more details.
|
||||
|
||||
using SparseArrays, Base.Threads, LinearAlgebra, JuMP
|
||||
|
||||
"""
|
||||
injection_shift_factors(; buses, lines)
|
||||
|
||||
Returns a (B-1)xL matrix M, where B is the number of buses and L is the number of transmission
|
||||
lines. For a given bus b and transmission line l, the entry M[l.offset, b.offset] indicates
|
||||
the amount of power (in MW) that flows through transmission line l when 1 MW of power is
|
||||
injected at the slack bus (the bus that has offset zero) and withdrawn from b.
|
||||
"""
|
||||
function injection_shift_factors(; buses, lines)
|
||||
susceptance = susceptance_matrix(lines)
|
||||
incidence = reduced_incidence_matrix(lines = lines, buses = buses)
|
||||
laplacian = transpose(incidence) * susceptance * incidence
|
||||
isf = susceptance * incidence * inv(Array(laplacian))
|
||||
return isf
|
||||
end
|
||||
|
||||
|
||||
"""
|
||||
reduced_incidence_matrix(; buses::Array{Bus}, lines::Array{TransmissionLine})
|
||||
|
||||
Returns the incidence matrix for the network, with the column corresponding to the slack
|
||||
bus is removed. More precisely, returns a (B-1) x L matrix, where B is the number of buses
|
||||
and L is the number of lines. For each row, there is a 1 element and a -1 element, indicating
|
||||
the source and target buses, respectively, for that line.
|
||||
"""
|
||||
function reduced_incidence_matrix(; buses::Array{Bus}, lines::Array{TransmissionLine})
|
||||
matrix = spzeros(Float64, length(lines), length(buses) - 1)
|
||||
for line in lines
|
||||
if line.source.offset > 0
|
||||
matrix[line.offset, line.source.offset] = 1
|
||||
end
|
||||
if line.target.offset > 0
|
||||
matrix[line.offset, line.target.offset] = -1
|
||||
end
|
||||
end
|
||||
matrix
|
||||
end
|
||||
|
||||
"""
|
||||
susceptance_matrix(lines::Array{TransmissionLine})
|
||||
|
||||
Returns a LxL diagonal matrix, where each diagonal entry is the susceptance of the
|
||||
corresponding transmission line.
|
||||
"""
|
||||
function susceptance_matrix(lines::Array{TransmissionLine})
|
||||
return Diagonal([l.susceptance for l in lines])
|
||||
end
|
||||
|
||||
|
||||
"""
|
||||
|
||||
line_outage_factors(; buses, lines, isf)
|
||||
|
||||
Returns a LxL matrix containing the Line Outage Distribution Factors (LODFs) for the
|
||||
given network. This matrix how does the pre-contingency flow change when each individual
|
||||
transmission line is removed.
|
||||
"""
|
||||
function line_outage_factors(;
|
||||
buses::Array{Bus, 1},
|
||||
lines::Array{TransmissionLine, 1},
|
||||
isf::Array{Float64,2},
|
||||
) :: Array{Float64,2}
|
||||
|
||||
n_lines, n_buses = size(isf)
|
||||
incidence = Array(reduced_incidence_matrix(lines=lines,
|
||||
buses=buses))
|
||||
lodf::Array{Float64,2} = isf * transpose(incidence)
|
||||
m, n = size(lodf)
|
||||
for i in 1:n
|
||||
lodf[:, i] *= 1.0 / (1.0 - lodf[i, i])
|
||||
lodf[i, i] = -1
|
||||
end
|
||||
return lodf
|
||||
end
|
||||
26
src/sysimage.jl
Normal file
26
src/sysimage.jl
Normal file
@@ -0,0 +1,26 @@
|
||||
# UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment
|
||||
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
|
||||
# Released under the modified BSD license. See COPYING.md for more details.
|
||||
|
||||
using PackageCompiler
|
||||
|
||||
using DataStructures
|
||||
using Documenter
|
||||
using GLPK
|
||||
using JSON
|
||||
using JuMP
|
||||
using MathOptInterface
|
||||
using SparseArrays
|
||||
using TimerOutputs
|
||||
|
||||
pkg = [:DataStructures,
|
||||
:Documenter,
|
||||
:GLPK,
|
||||
:JSON,
|
||||
:JuMP,
|
||||
:MathOptInterface,
|
||||
:SparseArrays,
|
||||
:TimerOutputs]
|
||||
|
||||
@info "Building system image..."
|
||||
create_sysimage(pkg, sysimage_path="build/sysimage.so")
|
||||
334
src/validate.jl
Normal file
334
src/validate.jl
Normal file
@@ -0,0 +1,334 @@
|
||||
# UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment
|
||||
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
|
||||
# Released under the modified BSD license. See COPYING.md for more details.
|
||||
|
||||
using Printf
|
||||
|
||||
bin(x) = [xi > 0.5 for xi in x]
|
||||
|
||||
"""
|
||||
fix!(instance)
|
||||
|
||||
Verifies that the given unit commitment instance is valid and automatically fixes
|
||||
some validation errors if possible, issuing a warning for each error found.
|
||||
If a validation error cannot be automatically fixed, issues an exception.
|
||||
|
||||
Returns the number of validation errors found.
|
||||
"""
|
||||
function fix!(instance::UnitCommitmentInstance)::Int
|
||||
n_errors = 0
|
||||
|
||||
for g in instance.units
|
||||
|
||||
# Startup costs and delays must be increasing
|
||||
for s in 2:length(g.startup_categories)
|
||||
if g.startup_categories[s].delay <= g.startup_categories[s-1].delay
|
||||
prev_value = g.startup_categories[s].delay
|
||||
new_value = g.startup_categories[s-1].delay + 1
|
||||
@warn "Generator $(g.name) has non-increasing startup delays (category $s). " *
|
||||
"Changing delay: $prev_value → $new_value"
|
||||
g.startup_categories[s].delay = new_value
|
||||
n_errors += 1
|
||||
end
|
||||
|
||||
if g.startup_categories[s].cost < g.startup_categories[s-1].cost
|
||||
prev_value = g.startup_categories[s].cost
|
||||
new_value = g.startup_categories[s-1].cost
|
||||
@warn "Generator $(g.name) has decreasing startup cost (category $s). " *
|
||||
"Changing cost: $prev_value → $new_value"
|
||||
g.startup_categories[s].cost = new_value
|
||||
n_errors += 1
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
for t in 1:instance.time
|
||||
# Production cost curve should be convex
|
||||
for k in 2:length(g.cost_segments)
|
||||
cost = g.cost_segments[k].cost[t]
|
||||
min_cost = g.cost_segments[k-1].cost[t]
|
||||
if cost < min_cost - 1e-5
|
||||
@warn "Generator $(g.name) has non-convex production cost curve " *
|
||||
"(segment $k, time $t). Changing cost: $cost → $min_cost"
|
||||
g.cost_segments[k].cost[t] = min_cost
|
||||
n_errors += 1
|
||||
end
|
||||
end
|
||||
|
||||
# Startup limit must be greater than min_power
|
||||
if g.startup_limit < g.min_power[t]
|
||||
new_limit = g.min_power[t]
|
||||
prev_limit = g.startup_limit
|
||||
@warn "Generator $(g.name) has startup limit lower than minimum power. " *
|
||||
"Changing startup limit: $prev_limit → $new_limit"
|
||||
g.startup_limit = new_limit
|
||||
n_errors += 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
return n_errors
|
||||
end
|
||||
|
||||
|
||||
function validate(instance_filename::String, solution_filename::String)
|
||||
instance = UnitCommitment.read(instance_filename)
|
||||
solution = JSON.parse(open(solution_filename))
|
||||
return validate(instance, solution)
|
||||
end
|
||||
|
||||
|
||||
"""
|
||||
validate(instance, solution)::Bool
|
||||
|
||||
Verifies that the given solution is feasible for the problem. If feasible,
|
||||
silently returns true. In infeasible, returns false and prints the validation
|
||||
errors to the screen.
|
||||
|
||||
This function is implemented independently from the optimization model in `model.jl`, and
|
||||
therefore can be used to verify that the model is indeed producing valid solutions. It
|
||||
can also be used to verify the solutions produced by other optimization packages.
|
||||
"""
|
||||
function validate(instance::UnitCommitmentInstance,
|
||||
solution::Union{Dict,OrderedDict};
|
||||
)::Bool
|
||||
err_count = 0
|
||||
err_count += validate_units(instance, solution)
|
||||
err_count += validate_reserve_and_demand(instance, solution)
|
||||
|
||||
if err_count > 0
|
||||
@error "Found $err_count validation errors"
|
||||
return false
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
function validate_units(instance, solution; tol=0.01)
|
||||
err_count = 0
|
||||
|
||||
for unit in instance.units
|
||||
production = solution["Production (MW)"][unit.name]
|
||||
reserve = solution["Reserve (MW)"][unit.name]
|
||||
actual_production_cost = solution["Production cost (\$)"][unit.name]
|
||||
actual_startup_cost = solution["Startup cost (\$)"][unit.name]
|
||||
is_on = bin(solution["Is on"][unit.name])
|
||||
|
||||
for t in 1:instance.time
|
||||
# Auxiliary variables
|
||||
if t == 1
|
||||
is_starting_up = (unit.initial_status < 0) && is_on[t]
|
||||
is_shutting_down = (unit.initial_status > 0) && !is_on[t]
|
||||
ramp_up = max(0, production[t] + reserve[t] - unit.initial_power)
|
||||
ramp_down = max(0, unit.initial_power - production[t])
|
||||
else
|
||||
is_starting_up = !is_on[t-1] && is_on[t]
|
||||
is_shutting_down = is_on[t-1] && !is_on[t]
|
||||
ramp_up = max(0, production[t] + reserve[t] - production[t-1])
|
||||
ramp_down = max(0, production[t-1] - production[t])
|
||||
end
|
||||
|
||||
# Compute production costs
|
||||
production_cost, startup_cost = 0, 0
|
||||
if is_on[t]
|
||||
production_cost += unit.min_power_cost[t]
|
||||
residual = max(0, production[t] - unit.min_power[t])
|
||||
for s in unit.cost_segments
|
||||
cleared = min(residual, s.mw[t])
|
||||
production_cost += cleared * s.cost[t]
|
||||
residual = max(0, residual - s.mw[t])
|
||||
end
|
||||
end
|
||||
|
||||
# Production should be non-negative
|
||||
if production[t] < -tol
|
||||
@error @sprintf("Unit %s produces negative amount of power at time %d (%.2f)",
|
||||
unit.name, t, production[t])
|
||||
err_count += 1
|
||||
end
|
||||
|
||||
# Verify must-run
|
||||
if !is_on[t] && unit.must_run[t]
|
||||
@error @sprintf("Must-run unit %s is offline at time %d",
|
||||
unit.name, t)
|
||||
err_count += 1
|
||||
end
|
||||
|
||||
# Verify reserve eligibility
|
||||
if !unit.provides_spinning_reserves[t] && reserve[t] > tol
|
||||
@error @sprintf("Unit %s is not eligible to provide spinning reserves at time %d",
|
||||
unit.name, t)
|
||||
err_count += 1
|
||||
end
|
||||
|
||||
# If unit is on, must produce at least its minimum power
|
||||
if is_on[t] && (production[t] < unit.min_power[t] - tol)
|
||||
@error @sprintf("Unit %s produces below its minimum limit at time %d (%.2f < %.2f)",
|
||||
unit.name, t, production[t], unit.min_power[t])
|
||||
err_count += 1
|
||||
end
|
||||
|
||||
# If unit is on, must produce at most its maximum power
|
||||
if is_on[t] && (production[t] + reserve[t] > unit.max_power[t] + tol)
|
||||
@error @sprintf("Unit %s produces above its maximum limit at time %d (%.2f + %.2f> %.2f)",
|
||||
unit.name, t, production[t], reserve[t], unit.max_power[t])
|
||||
err_count += 1
|
||||
end
|
||||
|
||||
# If unit is off, must produce zero
|
||||
if !is_on[t] && production[t] + reserve[t] > tol
|
||||
@error @sprintf("Unit %s produces power at time %d while off",
|
||||
unit.name, t)
|
||||
err_count += 1
|
||||
end
|
||||
|
||||
# Startup limit
|
||||
if is_starting_up && (ramp_up > unit.startup_limit + tol)
|
||||
@error @sprintf("Unit %s exceeds startup limit at time %d (%.2f > %.2f)",
|
||||
unit.name, t, ramp_up, unit.startup_limit)
|
||||
err_count += 1
|
||||
end
|
||||
|
||||
# Shutdown limit
|
||||
if is_shutting_down && (ramp_down > unit.shutdown_limit + tol)
|
||||
@error @sprintf("Unit %s exceeds shutdown limit at time %d (%.2f > %.2f)",
|
||||
unit.name, t, ramp_down, unit.shutdown_limit)
|
||||
err_count += 1
|
||||
end
|
||||
|
||||
# Ramp-up limit
|
||||
if !is_starting_up && !is_shutting_down && (ramp_up > unit.ramp_up_limit + tol)
|
||||
@error @sprintf("Unit %s exceeds ramp up limit at time %d (%.2f > %.2f)",
|
||||
unit.name, t, ramp_up, unit.ramp_up_limit)
|
||||
err_count += 1
|
||||
end
|
||||
|
||||
# Ramp-down limit
|
||||
if !is_starting_up && !is_shutting_down && (ramp_down > unit.ramp_down_limit + tol)
|
||||
@error @sprintf("Unit %s exceeds ramp down limit at time %d (%.2f > %.2f)",
|
||||
unit.name, t, ramp_down, unit.ramp_down_limit)
|
||||
err_count += 1
|
||||
end
|
||||
|
||||
# Verify startup costs & minimum downtime
|
||||
if is_starting_up
|
||||
|
||||
# Calculate how much time the unit has been offline
|
||||
time_down = 0
|
||||
for k in 1:(t-1)
|
||||
if !is_on[t - k]
|
||||
time_down += 1
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
if t == time_down + 1
|
||||
initial_down = unit.min_downtime
|
||||
if unit.initial_status < 0
|
||||
initial_down = -unit.initial_status
|
||||
end
|
||||
time_down += initial_down
|
||||
end
|
||||
|
||||
# Calculate startup costs
|
||||
for c in unit.startup_categories
|
||||
if time_down >= c.delay
|
||||
startup_cost = c.cost
|
||||
end
|
||||
end
|
||||
|
||||
# Check minimum downtime
|
||||
if time_down < unit.min_downtime
|
||||
@error @sprintf("Unit %s violates minimum downtime at time %d",
|
||||
unit.name, t)
|
||||
err_count += 1
|
||||
end
|
||||
end
|
||||
|
||||
# Verify minimum uptime
|
||||
if is_shutting_down
|
||||
|
||||
# Calculate how much time the unit has been online
|
||||
time_up = 0
|
||||
for k in 1:(t-1)
|
||||
if is_on[t - k]
|
||||
time_up += 1
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
if t == time_up + 1
|
||||
initial_up = unit.min_uptime
|
||||
if unit.initial_status > 0
|
||||
initial_up = unit.initial_status
|
||||
end
|
||||
time_up += initial_up
|
||||
end
|
||||
|
||||
if (t == time_up + 1) && (unit.initial_status > 0)
|
||||
time_up += unit.initial_status
|
||||
end
|
||||
|
||||
# Check minimum uptime
|
||||
if time_up < unit.min_uptime
|
||||
@error @sprintf("Unit %s violates minimum uptime at time %d",
|
||||
unit.name, t)
|
||||
err_count += 1
|
||||
end
|
||||
end
|
||||
|
||||
# Verify production costs
|
||||
if abs(actual_production_cost[t] - production_cost) > 1.00
|
||||
@error @sprintf("Unit %s has unexpected production cost at time %d (%.2f should be %.2f)",
|
||||
unit.name, t, actual_production_cost[t], production_cost)
|
||||
err_count += 1
|
||||
end
|
||||
|
||||
# Verify startup costs
|
||||
if abs(actual_startup_cost[t] - startup_cost) > 1.00
|
||||
@error @sprintf("Unit %s has unexpected startup cost at time %d (%.2f should be %.2f)",
|
||||
unit.name, t, actual_startup_cost[t], startup_cost)
|
||||
err_count += 1
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
return err_count
|
||||
end
|
||||
|
||||
|
||||
function validate_reserve_and_demand(instance, solution, tol=0.01)
|
||||
err_count = 0
|
||||
for t in 1:instance.time
|
||||
load_curtail = 0
|
||||
fixed_load = sum(b.load[t] for b in instance.buses)
|
||||
production = sum(solution["Production (MW)"][g.name][t]
|
||||
for g in instance.units)
|
||||
if "Load curtail (MW)" in keys(solution)
|
||||
load_curtail = sum(solution["Load curtail (MW)"][b.name][t]
|
||||
for b in instance.buses)
|
||||
end
|
||||
balance = fixed_load - load_curtail - production
|
||||
|
||||
# Verify that production equals demand
|
||||
if abs(balance) > tol
|
||||
@error @sprintf("Non-zero power balance at time %d (%.2f - %.2f - %.2f != 0)",
|
||||
t, fixed_load, load_curtail, production)
|
||||
err_count += 1
|
||||
end
|
||||
|
||||
# Verify spinning reserves
|
||||
reserve = sum(solution["Reserve (MW)"][g.name][t] for g in instance.units)
|
||||
if reserve < instance.reserves.spinning[t] - tol
|
||||
@error @sprintf("Insufficient spinning reserves at time %d (%.2f should be %.2f)",
|
||||
t, reserve, instance.reserves.spinning[t])
|
||||
err_count += 1
|
||||
end
|
||||
end
|
||||
|
||||
return err_count
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user