Skip to content

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:

  1. Validation: Verify your UC implementation produces feasible solutions
  2. Benchmarking: Compare solution quality and computational performance
  3. Initialization: Use as warm-start for advanced formulations
  4. 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 decisions

Additionally, 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.csv

File Format: commitment_decision_[day].csv

Each file contains binary commitment decisions (on/off) for all 122 generators over 24 hours.

hour,generator_id,status
1,1,1
1,2,0
1,3,1
1,4,1
...
24,122,1
ColumnDescriptionValuesMeaning
hourHour of the day1-24Hour 1 = 00:00-01:00, Hour 24 = 23:00-24:00
generator_idGenerator index1-122Corresponds to row in mpc.gen
statusCommitment decision0 or 10 = 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 type

Generator 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:

  1. Power balance: Generation = Demand + Losses
  2. Generator limits:
  3. Ramp rates:
  4. Minimum up time: Once started, must stay on
  5. Minimum down time: Once stopped, must stay off
  6. Startup/shutdown logic:
  7. Nuclear must-off: Scheduled outages enforced
  8. Renewable curtailment: Can reduce below available generation
  9. 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 context
mpc = matread("network/mat/KPG193_ver1_5.mat")["mpc"]
generators = mpc["gen"]
Pmax = generators[:, 9]
Pmin = generators[:, 10]
# Load commitment decisions for a specific day
day = 1
commitment = CSV.read("profile/commitment_decision/commitment_decision_$day.csv", DataFrame)
# Statistics
total_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 hour
hourly_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")
end

Python:

import pandas as pd
import scipy.io
import numpy as np
# Load network data
mat = scipy.io.loadmat('network/mat/KPG193_ver1_5.mat')
mpc = mat['mpc']
generators = mpc['gen'][0, 0]
Pmax = generators[:, 8] # 0-indexed
# Load commitment decisions
day = 1
commitment = pd.read_csv(f'profile/commitment_decision/commitment_decision_{day}.csv')
# Statistics
total_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 pattern
hourly_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 data
mpc = loadcase('network/m/KPG193_ver1_5.m');
Pmax = mpc.gen(:, 9);
% Load commitment decisions
day = 1;
commitment = readtable(sprintf('profile/commitment_decision/commitment_decision_%d.csv', day));
% Statistics
total_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 pattern
hourly_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 solution
your_solution = # ... your UC results as DataFrame with columns: hour, generator_id, status
# Load reference solution
day = 1
reference = CSV.read("profile/commitment_decision/commitment_decision_$day.csv", DataFrame)
# Compare commitment patterns
comparison = innerjoin(your_solution, reference,
on=[:hour, :generator_id],
makeunique=true)
# Count differences
n_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 most
comparison.differs = comparison.status .!= comparison.status_1
diff_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 solution
function 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_cost
end
# Compare
reference_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 events
function 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, shutdowns
end
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 costs

Warm-Start Initialization

Use reference solutions as an initial point for your UC solver:

using JuMP, HiGHS
# Load reference solution
day = 1
reference = CSV.read("profile/commitment_decision/commitment_decision_$day.csv", DataFrame)
# Build UC model
model = Model(HiGHS.Optimizer)
# Define variables
@variable(model, u[1:122, 1:24], Bin) # Commitment variables
# ... add constraints and objective ...
# Set initial values from reference
for 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,p
1,1,1,5.456000000000001
1,2,1,2.9957374675955317
1,3,1,2.0475354742249032
...
ColumnDescriptionUnit
generatorGenerator ID1-122
timeHour1-24
uCommitment status0 or 1
pPower outputMW

Usage:

# Load detailed results
detailed_uc = CSV.read("data/uc_results/KPG193_ver1_5/up_variables_3.csv", DataFrame)
# Compare dispatch against your solution
for 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")
end

Annual Statistics

Annual commitment analysis:

using CSV, DataFrames, Statistics
# Analyze all 365 days
annual_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
# Statistics
println("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 day
commitment = CSV.read("profile/commitment_decision/commitment_decision_$day.csv", DataFrame)
# Tag by technology
commitment.technology = ifelse.(commitment.generator_id .<= 54, "LNG",
ifelse.(commitment.generator_id .<= 100, "Coal", "Nuclear"))
# Summary by technology and hour
tech_hourly = combine(groupby(commitment, [:hour, :technology]),
:status => sum => :n_online)
# Pivot for easier viewing
using DataFrames
tech_pivot = unstack(tech_hourly, :hour, :technology, :n_online)
println("Online generators by technology (Day $day):")
println(tech_pivot)

Benchmarking Your UC Implementation

Performance metrics:

  1. Solution quality:

    • Objective value (total cost)
    • Feasibility (all constraints satisfied)
    • Agreement with reference (% matching decisions)
  2. Computational performance:

    • Solve time
    • MIP gap at termination
    • Number of branch-and-bound nodes
  3. Solution characteristics:

    • Number of startups/shutdowns
    • Capacity factor by technology
    • Load following capability

Example benchmark report:

# Generate benchmark report
function 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 results
end
# Run benchmark
println("Running benchmark on days 1-30...")
benchmark_results = benchmark_uc_solver(my_uc_solver, 1:30)
# Summary
println("\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:

  1. Nuclear plants: Run continuously except during scheduled outages (must-off periods)
  2. Coal plants: Provide baseload and mid-merit, typically run 18-24 hours/day
  3. LNG plants: Load following, commitment varies 12-20 hours/day based on demand
  4. 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

  1. Optimality: Solutions are 99.5% optimal (0.5% MIP gap), not necessarily globally optimal
  2. Transmission: Simplified representation; detailed AC power flow not enforced in UC
  3. Reserves: Spinning reserve requirements included but may differ from your formulation
  4. Renewables: Curtailment allowed; reference may differ if you impose must-take constraints
  5. 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