Reference UC Solutions
Reference UC Solutions
The KPG 193 test system includes pre-computed unit commitment (UC) solutions for all 365 days, providing reference results for validation and benchmarking of UC algorithms.
flowchart LR
A[KPG 193
Test System] --> B[UC Solver
Tight MILP]
B --> C[Reference Solutions
365 days
122 generators]
C --> D[Your UC
Implementation]
D --> E[Compare
Decisions]
D --> F[Validate
Costs]
D --> G[Benchmark
Performance]
These reference solutions serve multiple purposes:
- Validation: Verify your UC implementation produces feasible solutions
- Benchmarking: Compare solution quality and computational performance
- Initialization: Use as warm-start for advanced formulations
- Analysis: Study commitment patterns under realistic conditions
Directory Structure
kpg193_v1_5/└── profile/ └── commitment_decision/ # Reference UC solutions ├── commitment_decision_1.csv # Day 1 on/off decisions ├── commitment_decision_2.csv # Day 2 on/off decisions ├── ... └── commitment_decision_365.csv # Day 365 on/off decisionsAdditionally, supplementary validation data is available in:
data/└── uc_results/ └── KPG193_ver1_5/ ├── up_variables_3.csv # Extended UC results for selected days └── up_variables_4.csvFile Format: commitment_decision_[day].csv
Each file contains binary commitment decisions (on/off) for all 122 generators over 24 hours.
hour,generator_id,status1,1,11,2,01,3,11,4,1...24,122,1| Column | Description | Values | Meaning |
|---|---|---|---|
hour | Hour of the day | 1-24 | Hour 1 = 00:00-01:00, Hour 24 = 23:00-24:00 |
generator_id | Generator index | 1-122 | Corresponds to row in mpc.gen |
status | Commitment decision | 0 or 1 | 0 = off (unavailable), 1 = on (available for dispatch) |
Data structure:
- Rows per file: 24 hours × 122 generators = 2,928 rows
- Total rows (all files): 2,928 × 365 = 1,068,720 rows
- Binary decisions: Only values are 0 and 1
Generator ordering:
using MAT
mpc = matread("network/mat/KPG193_ver1_5.mat")["mpc"]generators = mpc["gen"]
# Generator 1 = generators[1, :]# Generator 2 = generators[2, :]# ...# Generator 122 = generators[122, :]
# Get generator type (from comment in .m file or by bus location)# Generators are ordered by bus, then by typeGenerator types in order:
- Generators 1-54: LNG (Natural Gas)
- Generators 55-100: Coal
- Generators 101-122: Nuclear
Solution Methodology
The reference solutions were computed using a tight MILP formulation for UC with the following characteristics.
Objective function:
Where:
: Power output of generator at hour : Startup indicator (1 if starting up at ) : Shutdown indicator (1 if shutting down at ) : Quadratic fuel cost function : Startup cost : Shutdown cost
Constraints included:
- Power balance: Generation = Demand + Losses
- Generator limits:
- Ramp rates:
- Minimum up time: Once started, must stay on
- Minimum down time: Once stopped, must stay off
- Startup/shutdown logic:
- Nuclear must-off: Scheduled outages enforced
- Renewable curtailment: Can reduce below available generation
- Transmission limits: Simplified (aggregated areas)
Solver configuration:
- Software: HiGHS or Gurobi
- MIP gap: 0.5% (99.5% optimal)
- Time limit: 3600 seconds per day
- Formulation: Tight MILP (Morales-España et al., 2013)
Reference:
Morales-España, G., Latorre, J. M., & Ramos, A. (2013). “Tight and compact MILP formulation for the thermal unit commitment problem.” IEEE Transactions on Power Systems, 28(4), 4897-4908.
Loading Reference Solutions
Julia:
using CSV, DataFrames, MAT
# Load network data for contextmpc = matread("network/mat/KPG193_ver1_5.mat")["mpc"]generators = mpc["gen"]Pmax = generators[:, 9]Pmin = generators[:, 10]
# Load commitment decisions for a specific dayday = 1commitment = CSV.read("profile/commitment_decision/commitment_decision_$day.csv", DataFrame)
# Statisticstotal_decisions = nrow(commitment)generators_on_total = sum(commitment.status)capacity_on = sum(Pmax[commitment[commitment.status .== 1, :generator_id]])
println("Day $day Commitment Summary:")println(" Total decisions: $total_decisions (24 hours × 122 generators)")println(" Generator-hours online: $generators_on_total")println(" Average online generators: $(round(generators_on_total / 24, digits=1))")println(" Average online capacity: $(round(capacity_on / 24, digits=0)) MW")
# Analyze by hourhourly_stats = combine(groupby(commitment, :hour), :status => sum => :n_online)
println("\nHourly commitment pattern:")for row in eachrow(hourly_stats) println(" Hour $(row.hour): $(row.n_online) generators online")endPython:
import pandas as pdimport scipy.ioimport numpy as np
# Load network datamat = scipy.io.loadmat('network/mat/KPG193_ver1_5.mat')mpc = mat['mpc']generators = mpc['gen'][0, 0]Pmax = generators[:, 8] # 0-indexed
# Load commitment decisionsday = 1commitment = pd.read_csv(f'profile/commitment_decision/commitment_decision_{day}.csv')
# Statisticstotal_on = commitment['status'].sum()avg_online = total_on / 24
print(f"Day {day} Commitment Summary:")print(f" Generator-hours online: {total_on}")print(f" Average online generators: {avg_online:.1f}")
# Hourly patternhourly_online = commitment.groupby('hour')['status'].sum()print(f"\nHourly online generators:")print(f" Minimum: {hourly_online.min()} at hour {hourly_online.idxmin()}")print(f" Maximum: {hourly_online.max()} at hour {hourly_online.idxmax()}")MATLAB:
% Load network datampc = loadcase('network/m/KPG193_ver1_5.m');Pmax = mpc.gen(:, 9);
% Load commitment decisionsday = 1;commitment = readtable(sprintf('profile/commitment_decision/commitment_decision_%d.csv', day));
% Statisticstotal_on = sum(commitment.status);avg_online = total_on / 24;
fprintf('Day %d Commitment Summary:\n', day);fprintf(' Generator-hours online: %d\n', total_on);fprintf(' Average online generators: %.1f\n', avg_online);
% Hourly patternhourly_on = grpstats(commitment.status, commitment.hour, 'sum');fprintf('\nOnline generators by hour:\n');fprintf(' Min: %d\n', min(hourly_on));fprintf(' Max: %d\n', max(hourly_on));Validation Use Cases
1. Verify commitment feasibility:
using CSV, DataFrames
# Load your solutionyour_solution = # ... your UC results as DataFrame with columns: hour, generator_id, status
# Load reference solutionday = 1reference = CSV.read("profile/commitment_decision/commitment_decision_$day.csv", DataFrame)
# Compare commitment patternscomparison = innerjoin(your_solution, reference, on=[:hour, :generator_id], makeunique=true)
# Count differencesn_differences = sum(comparison.status .!= comparison.status_1)agreement_pct = (1 - n_differences / nrow(comparison)) * 100
println("Commitment Comparison:")println(" Agreements: $(nrow(comparison) - n_differences) / $(nrow(comparison))")println(" Agreement rate: $(round(agreement_pct, digits=2))%")println(" Differences: $n_differences decisions")
# Analyze which generators differ mostcomparison.differs = comparison.status .!= comparison.status_1diff_by_gen = combine(groupby(comparison[comparison.differs, :], :generator_id), nrow => :n_diff_hours)sort!(diff_by_gen, :n_diff_hours, rev=true)
println("\nGenerators with most differences:")println(first(diff_by_gen, 10))2. Cost comparison:
# Calculate cost of reference solutionfunction calculate_uc_cost(commitment_df, mpc, demand) # Load generator cost data gencost = mpc["gencost"] genthermal = mpc["genthermal"]
total_cost = 0.0
for hour in 1:24 hour_commitment = filter(row -> row.hour == hour, commitment_df)
# Solve economic dispatch given commitment # (simplified - actual ED would optimize power output)
for row in eachrow(hour_commitment) if row.status == 1 gen_id = row.generator_id
# Approximate generation cost at mid-point Pmin = mpc["gen"][gen_id, 10] Pmax = mpc["gen"][gen_id, 9] P_est = (Pmin + Pmax) / 2
# Quadratic cost c2 = gencost[gen_id, 5] c1 = gencost[gen_id, 6] c0 = gencost[gen_id, 7]
fuel_cost = c2 * P_est^2 + c1 * P_est + c0 total_cost += fuel_cost end end end
return total_costend
# Comparereference_cost = calculate_uc_cost(reference, mpc, demand)your_cost = calculate_uc_cost(your_solution, mpc, demand)
cost_diff_pct = (your_cost - reference_cost) / reference_cost * 100
println("Cost Comparison:")println(" Reference cost: \$$(round(reference_cost, digits=0))")println(" Your cost: \$$(round(your_cost, digits=0))")println(" Difference: $(round(cost_diff_pct, digits=2))%")3. Startup/shutdown pattern analysis:
# Analyze startup and shutdown eventsfunction analyze_transitions(commitment_df) startups = zeros(Int, 122) shutdowns = zeros(Int, 122)
for gen_id in 1:122 gen_schedule = sort(filter(row -> row.generator_id == gen_id, commitment_df), :hour)
for i in 2:24 if gen_schedule.status[i] == 1 && gen_schedule.status[i-1] == 0 startups[gen_id] += 1 elseif gen_schedule.status[i] == 0 && gen_schedule.status[i-1] == 1 shutdowns[gen_id] += 1 end end end
return startups, shutdownsend
ref_starts, ref_stops = analyze_transitions(reference)your_starts, your_stops = analyze_transitions(your_solution)
println("Transition Events:")println(" Reference - Startups: $(sum(ref_starts)), Shutdowns: $(sum(ref_stops))")println(" Your solution - Startups: $(sum(your_starts)), Shutdowns: $(sum(your_stops))")
# More transitions typically means higher startup costsWarm-Start Initialization
Use reference solutions as an initial point for your UC solver:
using JuMP, HiGHS
# Load reference solutionday = 1reference = CSV.read("profile/commitment_decision/commitment_decision_$day.csv", DataFrame)
# Build UC modelmodel = Model(HiGHS.Optimizer)
# Define variables@variable(model, u[1:122, 1:24], Bin) # Commitment variables
# ... add constraints and objective ...
# Set initial values from referencefor row in eachrow(reference) set_start_value(u[row.generator_id, row.hour], row.status)end
# Solve (may converge faster with good initial point)optimize!(model)Extended Validation Data
Files: up_variables_3.csv and up_variables_4.csv (located in data/uc_results/KPG193_ver1_5/)
These files provide more detailed UC results for selected days, including:
- Continuous power output variables
- Ramping behavior
- Reserve provision
- Exact cost breakdown
generator,time,u,p1,1,1,5.4560000000000011,2,1,2.99573746759553171,3,1,2.0475354742249032...| Column | Description | Unit |
|---|---|---|
generator | Generator ID | 1-122 |
time | Hour | 1-24 |
u | Commitment status | 0 or 1 |
p | Power output | MW |
Usage:
# Load detailed resultsdetailed_uc = CSV.read("data/uc_results/KPG193_ver1_5/up_variables_3.csv", DataFrame)
# Compare dispatch against your solutionfor gen_id in 1:122 gen_dispatch = filter(row -> row.generator == gen_id, detailed_uc)
# Analyze ramping power_output = gen_dispatch.p ramp_up = maximum(diff(power_output)) ramp_down = minimum(diff(power_output))
println("Generator $gen_id:") println(" Max ramp up: $(round(ramp_up, digits=2)) MW/h") println(" Max ramp down: $(round(abs(ramp_down), digits=2)) MW/h")endAnnual Statistics
Annual commitment analysis:
using CSV, DataFrames, Statistics
# Analyze all 365 daysannual_stats = DataFrame( day = Int[], n_online_avg = Float64[], n_startups = Int[], n_shutdowns = Int[])
for day in 1:365 commitment = CSV.read("profile/commitment_decision/commitment_decision_$day.csv", DataFrame)
# Average online generators avg_online = mean(combine(groupby(commitment, :hour), :status => sum).status_sum)
# Count transitions starts, stops = analyze_transitions(commitment)
push!(annual_stats, (day, avg_online, sum(starts), sum(stops)))end
# Statisticsprintln("Annual Commitment Statistics:")println(" Avg generators online: $(round(mean(annual_stats.n_online_avg), digits=1))")println(" Min generators online: $(round(minimum(annual_stats.n_online_avg), digits=1)) on day $(annual_stats.day[argmin(annual_stats.n_online_avg)])")println(" Max generators online: $(round(maximum(annual_stats.n_online_avg), digits=1)) on day $(annual_stats.day[argmax(annual_stats.n_online_avg)])")println(" Total annual startups: $(sum(annual_stats.n_startups))")println(" Total annual shutdowns: $(sum(annual_stats.n_shutdowns))")Technology-specific patterns:
# Analyze by technology# Assuming: LNG = 1-54, Coal = 55-100, Nuclear = 101-122
day = 150 # Summer daycommitment = CSV.read("profile/commitment_decision/commitment_decision_$day.csv", DataFrame)
# Tag by technologycommitment.technology = ifelse.(commitment.generator_id .<= 54, "LNG", ifelse.(commitment.generator_id .<= 100, "Coal", "Nuclear"))
# Summary by technology and hourtech_hourly = combine(groupby(commitment, [:hour, :technology]), :status => sum => :n_online)
# Pivot for easier viewingusing DataFramestech_pivot = unstack(tech_hourly, :hour, :technology, :n_online)
println("Online generators by technology (Day $day):")println(tech_pivot)Benchmarking Your UC Implementation
Performance metrics:
-
Solution quality:
- Objective value (total cost)
- Feasibility (all constraints satisfied)
- Agreement with reference (% matching decisions)
-
Computational performance:
- Solve time
- MIP gap at termination
- Number of branch-and-bound nodes
-
Solution characteristics:
- Number of startups/shutdowns
- Capacity factor by technology
- Load following capability
Example benchmark report:
# Generate benchmark reportfunction benchmark_uc_solver(your_solver_func, day_range) results = DataFrame( day = Int[], ref_cost = Float64[], your_cost = Float64[], cost_diff_pct = Float64[], agreement_pct = Float64[], solve_time = Float64[] )
for day in day_range # Load reference reference = CSV.read("profile/commitment_decision/commitment_decision_$day.csv", DataFrame)
# Solve with your implementation t_start = time() your_solution, your_cost = your_solver_func(day) solve_time = time() - t_start
# Compare ref_cost = calculate_uc_cost(reference, mpc, demand) cost_diff = (your_cost - ref_cost) / ref_cost * 100
# Agreement comparison = innerjoin(your_solution, reference, on=[:hour, :generator_id]) agreement = sum(comparison.status_your .== comparison.status_ref) / nrow(comparison) * 100
push!(results, (day, ref_cost, your_cost, cost_diff, agreement, solve_time)) end
return resultsend
# Run benchmarkprintln("Running benchmark on days 1-30...")benchmark_results = benchmark_uc_solver(my_uc_solver, 1:30)
# Summaryprintln("\nBenchmark Summary:")println(" Avg cost difference: $(round(mean(benchmark_results.cost_diff_pct), digits=2))%")println(" Avg agreement: $(round(mean(benchmark_results.agreement_pct), digits=2))%")println(" Avg solve time: $(round(mean(benchmark_results.solve_time), digits=2)) seconds")println(" Max solve time: $(round(maximum(benchmark_results.solve_time), digits=2)) seconds")Known Characteristics
Commitment patterns:
- Nuclear plants: Run continuously except during scheduled outages (must-off periods)
- Coal plants: Provide baseload and mid-merit, typically run 18-24 hours/day
- LNG plants: Load following, commitment varies 12-20 hours/day based on demand
- Seasonal variation: More units online in summer (high demand) than spring/fall
Typical daily pattern:
Hours 1-6 (Night): ~70-80 generators online (baseload)Hours 7-9 (Morning): ~85-95 generators online (ramp up)Hours 10-18 (Day): ~95-105 generators online (peak period)Hours 19-22 (Evening): ~90-100 generators online (evening peak)Hours 23-24 (Night): ~75-85 generators online (ramp down)Limitations and Notes
- Optimality: Solutions are 99.5% optimal (0.5% MIP gap), not necessarily globally optimal
- Transmission: Simplified representation; detailed AC power flow not enforced in UC
- Reserves: Spinning reserve requirements included but may differ from your formulation
- Renewables: Curtailment allowed; reference may differ if you impose must-take constraints
- Inter-day coupling: Each day solved independently; no multi-day optimization
References
For detailed information on the validation methodology:
- KPG 193 Paper: Song & Kim (2024), arXiv:2411.14756
- Section III: Validation
- UC Formulation: Morales-España et al. (2013), “Tight and compact MILP formulation for the thermal unit commitment problem,” IEEE Transactions on Power Systems
Next Steps
- Implement your own UC: KPG Run UC Documentation →
- Review data: Network Structure →
- Renewables & profiles: Renewables → and Profiles & Time Series →
- Scheduled outages: Scheduled Outages →
- Limitations: Limitations →
- Compare formulations: Solver Comparison →