From 2fce60e1855fc0807b3a47b174e73be72484bbf0 Mon Sep 17 00:00:00 2001 From: samtalki Date: Mon, 5 Jan 2026 12:56:21 -0500 Subject: [PATCH 1/7] Updated matrix F in Palma Ratio Linearization --- script/relaxed_palma_ratio.jl | 170 +++++------ src/implementation/palma_relaxation.jl | 353 ++++++++++------------- test/test_matrix_f.jl | 372 +++++++++++++++++++++++++ 3 files changed, 588 insertions(+), 307 deletions(-) create mode 100644 test/test_matrix_f.jl diff --git a/script/relaxed_palma_ratio.jl b/script/relaxed_palma_ratio.jl index c50c721..fcd8bb2 100644 --- a/script/relaxed_palma_ratio.jl +++ b/script/relaxed_palma_ratio.jl @@ -333,129 +333,111 @@ P = pd @variable(model, x_raw[1:n] >= 0) @variable(model, σ >= 1e-6) @variable(model, w[1:n] >= 0) -# First permutation matrix constraint to sort the rows Ax == 1 -A = zeros(n,n^2) +# Permutation matrix row sum constraint: Σ_j a_{ij} = 1 for all i +# Using column-major indexing: a[k] = a_{i,j} where k = i + (j-1)*n +A_row = zeros(n, n^2) for i in 1:n - if i == 1 - A[i, i:n] .= 1.0 - else - A[i, (i-1)*n+1:i*n] .= 1.0 + for j in 1:n + idx = i + (j-1)*n # column-major index + A_row[i, idx] = 1.0 end end -#@constraint(model, A*a.<= 1.0) -#@constraint(model, -A*a .<= -1.0) -# Second permutation matrix constraint to sort the columns A^T * x == 1 -AT = zeros(n,n^2) -#γ = 1 -for i in 1:n - for j in 1:n - row = zeros(n) - row[i] = 1.0 - if j == 1 - AT[i,j:n] = row - else - AT[i,(j-1)*n+1:j*n] = row - end +# Permutation matrix column sum constraint: Σ_i a_{ij} = 1 for all j +A_col = zeros(n, n^2) +for j in 1:n + for i in 1:n + idx = i + (j-1)*n # column-major index + A_col[j, idx] = 1.0 end - #γ += 1 end -#@constraint(model, AT*a .<= 1.0) -#@constraint(model, -AT*a .<= -1.0) -# Create the ascending sorting constraint matrix -T ⋅ \tilde{x} ≤ 0 -# This is the difference matrix -T = zeros(n,n^2) -for i in 1:n - if i == 1 - T[i, i:n] .= 1.0 - T[i, n+1:(i+1)*n] .-= 1.0 - else - T[i, (i-1)*n+1:i*n] .= 1.0 - if i == n - T[i, (i-1)*n+1:i*n] .= 1.0 - else - T[i, i*n+1:(i+1)*n] .-= 1.0 - end +# Sorting constraint matrix: enforces ascending order S_k <= S_{k+1} +# T * x_hat <= 0 means sorted[k] - sorted[k+1] <= 0 +# Using column-major indexing for consistency +T = zeros(n-1, n^2) +for k in 1:n-1 + for j in 1:n + idx_k = k + (j-1)*n + idx_k1 = (k+1) + (j-1)*n + T[k, idx_k] = 1.0 + T[k, idx_k1] = -1.0 end end -#@constraint(model, -T*x_hat.<= 0.0) ############################################################# -# Implement th McCormick envelopes for the bilinear terms +# Implement the McCormick envelopes for the bilinear terms +# u_ij = a_ij * x_j where a_ij ∈ {0,1} and x_j ∈ [0, P_j] +# Envelope 1: u >= 0 (eq. 19) +# Envelope 2: u >= x̄·a + x - x̄ (eq. 20) +# Envelope 3: u <= x̄·a (eq. 21) +# Envelope 4: u <= x (eq. 22) ############################################################## -# Start with \hat{x}_{ij} ≤ 0 -# Stagger In matrices -# lower_bound_x = zeros(n^2,n^2) -# for i in 1:n -# if i == 1 -# lower_bound_x[i:n, i:n] .= Matrix(I, n, n) -# else -# lower_bound_x[(i-1)*n+1:i*n, (i-1)*n+1:i*n] .= Matrix(I, n, n) -# end -# end n_squared_identity = Diagonal(ones(n^2)) -#@constraint(model, -n_squared_identity * x_hat .<= 0) - -# second McCormick envelope -\hat{x}_{ij} +x_j +a_ijP_j ≤ P_j -n_identity = Diagonal(ones(n)) -# Construct the a_ijP_j matrix -A_ij_P_j = zeros(n^2,n^2) -for i in 1:n - A_ij_P_j[(i-1)*n+1:i*n, (i-1)*n+1:i*n] .= Matrix(I,n,n).*(P) +# Construct A_ij_P_j matrix for McCormick: represents a_ij * P_j term +# Using column-major indexing: entry (i,j) maps to index i + (j-1)*n +A_ij_P_j = zeros(n^2, n^2) +for j in 1:n + for i in 1:n + idx = i + (j-1)*n # column-major index for (i,j) + A_ij_P_j[idx, idx] = P[j] # P_j is the upper bound for x_j + end end -A_ij_P_j -# Construct the x_j matrix so that each block corresponds with the appropriate x_ij -x_j = zeros(n^2,n) + +# Construct x_j matrix: maps x[j] to all entries (i,j) in the bilinear term +# x_j[idx, j] = 1 where idx = i + (j-1)*n for all i +x_j = zeros(n^2, n) P_out = zeros(n^2) for j in 1:n - if j == 1 - x_j[j:n,j:n] = Matrix(I, n, n) - P_out[j:n] .= P - else - x_j[(j-1)*n+1:j*n, 1:n] = Matrix(I, n, n) - P_out[(j-1)*n+1:j*n] .= P + for i in 1:n + idx = i + (j-1)*n + x_j[idx, j] = 1.0 + P_out[idx] = P[j] end end -x_j -#@constraint(model, -n_squared_identity * x_hat .+ x_j .+ A_ij_P_j * a .<= P_out) - -# third McCormick envelope -\hat{x}_{ij} - a_ijP_j ≤ 0 -#@constraint(model, n_squared_identity * x_hat .- A_ij_P_j * a .<= 0) - -# fourth McCormick envelope \hat{x}_{ij} - x_j ≤ 0 -#@constraint(model, n_squared_identity * x_hat .- x_j .<= 0) # Create the big F matrix for the Palma ratio constraints -F = [zeros(n,n^2) A zeros(n,n) - zeros(n,n^2) -A zeros(n,n) - zeros(n,n^2) AT zeros(n,n) - zeros(n,n^2) -AT zeros(n,n) - -T zeros(n,n^2) zeros(n,n) - # -n_squared_identity zeros(n^2,n^2) zeros(n^2,n) - n_squared_identity -A_ij_P_j zeros(n^2,n) - -n_squared_identity A_ij_P_j x_j - n_squared_identity zeros(n^2,n^2) -x_j +# Variable vector: y = [x_hat, a, x_raw] +# Constraints: +# Row 1-2: Row sum of permutation matrix = 1 (A_row * a = 1) +# Row 3-4: Column sum of permutation matrix = 1 (A_col * a = 1) +# Row 5: Sorting constraint (T * x_hat <= 0) +# Row 6-9: McCormick envelopes +F = [zeros(n,n^2) A_row zeros(n,n) # A_row * a <= 1 + zeros(n,n^2) -A_row zeros(n,n) # -A_row * a <= -1 (i.e., A_row * a >= 1) + zeros(n,n^2) A_col zeros(n,n) # A_col * a <= 1 + zeros(n,n^2) -A_col zeros(n,n) # -A_col * a <= -1 (i.e., A_col * a >= 1) + -T zeros(n-1,n^2) zeros(n-1,n) # -T * x_hat <= 0 (ascending order) + -n_squared_identity zeros(n^2,n^2) zeros(n^2,n) # -u <= 0 (envelope 1: u >= 0) + n_squared_identity -A_ij_P_j zeros(n^2,n) # u - a*P <= 0 (envelope 3: u <= a*P) + -n_squared_identity A_ij_P_j x_j # -u + a*P + x <= P (envelope 2) + n_squared_identity zeros(n^2,n^2) -x_j # u - x <= 0 (envelope 4: u <= x) ] x_tilde = vcat(x_hat, a, x_raw) y = x_tilde -# Store the right hand side of the constraints into a vector named g -g = [ones(n) - -ones(n) - ones(n) - -ones(n) - zeros(n) - # zeros(n^2) - P_out - zeros(n^2) - zeros(n^2) + +# Right-hand side vector g +# Matches the constraint structure of F +g = [ones(n) # A_row * a <= 1 + -ones(n) # -A_row * a <= -1 + ones(n) # A_col * a <= 1 + -ones(n) # -A_col * a <= -1 + zeros(n-1) # -T * x_hat <= 0 (n-1 rows now) + zeros(n^2) # -u <= 0 (envelope 1) + zeros(n^2) # u - a*P <= 0 (envelope 3) - FIXED + P_out # -u + a*P + x <= P (envelope 2) - FIXED + zeros(n^2) # u - x <= 0 (envelope 4) ] +# Verify dimensions before adding constraint +@assert size(F, 2) == length(y) "F columns ($(size(F, 2))) must match y length ($(length(y)))" +@assert size(F, 1) == length(g) "F rows ($(size(F, 1))) must match g length ($(length(g)))" + @constraint(model, F * y .<= g*σ) @constraint(model, σ >= 1e-6) -A_long = [A zeros(n,n^2) zeros(n,n)] +A_long = [A_row zeros(n,n^2) zeros(n,n)] # FIXED: use A_row instead of undefined A # Create the vectors to extract the top 10% of load shed top_10_percent_indices = zeros(n) top_10_percent_indices[ceil(Int, 0.9*n):n] .= 1.0 diff --git a/src/implementation/palma_relaxation.jl b/src/implementation/palma_relaxation.jl index a468274..a2d7c01 100644 --- a/src/implementation/palma_relaxation.jl +++ b/src/implementation/palma_relaxation.jl @@ -22,129 +22,111 @@ function lin_palma(n::Int, P::Vector{Float64}) @constraint(model, x_raw .== P) @variable(model, σ >= 1e-6) - # First permutation matrix constraint to sort the rows Ax == 1 - A = zeros(n,n^2) + # Permutation matrix row sum constraint: Σ_j a_{ij} = 1 for all i + # Using column-major indexing: a[k] = a_{i,j} where k = i + (j-1)*n + A_row = zeros(n, n^2) for i in 1:n - if i == 1 - A[i, i:n] .= 1.0 - else - A[i, (i-1)*n+1:i*n] .= 1.0 + for j in 1:n + idx = i + (j-1)*n # column-major index + A_row[i, idx] = 1.0 end end - #@constraint(model, A*a.<= 1.0) - #@constraint(model, -A*a .<= -1.0) - # Second permutation matrix constraint to sort the columns A^T * x == 1 - AT = zeros(n,n^2) - #γ = 1 - for i in 1:n - for j in 1:n - row = zeros(n) - row[i] = 1.0 - if j == 1 - AT[i,j:n] = row - else - AT[i,(j-1)*n+1:j*n] = row - end + # Permutation matrix column sum constraint: Σ_i a_{ij} = 1 for all j + A_col = zeros(n, n^2) + for j in 1:n + for i in 1:n + idx = i + (j-1)*n # column-major index + A_col[j, idx] = 1.0 end - #γ += 1 end - #@constraint(model, AT*a .<= 1.0) - #@constraint(model, -AT*a .<= -1.0) - # Create the ascending sorting constraint matrix -T ⋅ \tilde{x} ≤ 0 - # This is the difference matrix - T = zeros(n,n^2) - for i in 1:n - if i == 1 - T[i, i:n] .= 1.0 - T[i, n+1:(i+1)*n] .-= 1.0 - else - T[i, (i-1)*n+1:i*n] .= 1.0 - if i == n - T[i, (i-1)*n+1:i*n] .= 1.0 - else - T[i, i*n+1:(i+1)*n] .-= 1.0 - end + # Sorting constraint matrix: enforces ascending order S_k <= S_{k+1} + # T * x_hat <= 0 means sorted[k] - sorted[k+1] <= 0 + # Using column-major indexing for consistency + T = zeros(n-1, n^2) + for k in 1:n-1 + for j in 1:n + idx_k = k + (j-1)*n + idx_k1 = (k+1) + (j-1)*n + T[k, idx_k] = 1.0 + T[k, idx_k1] = -1.0 end end - #@constraint(model, -T*x_hat.<= 0.0) ############################################################# - # Implement th McCormick envelopes for the bilinear terms + # Implement the McCormick envelopes for the bilinear terms + # u_ij = a_ij * x_j where a_ij ∈ {0,1} and x_j ∈ [0, P_j] + # Envelope 1: u >= 0 (eq. 19) + # Envelope 2: u >= x̄·a + x - x̄ (eq. 20) + # Envelope 3: u <= x̄·a (eq. 21) + # Envelope 4: u <= x (eq. 22) ############################################################## - # Start with \hat{x}_{ij} ≤ 0 - # Stagger In matrices - # lower_bound_x = zeros(n^2,n^2) - # for i in 1:n - # if i == 1 - # lower_bound_x[i:n, i:n] .= Matrix(I, n, n) - # else - # lower_bound_x[(i-1)*n+1:i*n, (i-1)*n+1:i*n] .= Matrix(I, n, n) - # end - # end n_squared_identity = LinearAlgebra.Diagonal(ones(n^2)) #@constraint(model, -n_squared_identity * x_hat .<= 0) - # second McCormick envelope -\hat{x}_{ij} +x_j +a_ijP_j ≤ P_j - n_identity = LinearAlgebra.Diagonal(ones(n)) - # Construct the a_ijP_j matrix - A_ij_P_j = zeros(n^2,n^2) - - for i in 1:n - A_ij_P_j[(i-1)*n+1:i*n, (i-1)*n+1:i*n] .= LinearAlgebra.Diagonal(P) + # Construct A_ij_P_j matrix for McCormick: represents a_ij * P_j term + # Using column-major indexing: entry (i,j) maps to index i + (j-1)*n + A_ij_P_j = zeros(n^2, n^2) + for j in 1:n + for i in 1:n + idx = i + (j-1)*n # column-major index for (i,j) + A_ij_P_j[idx, idx] = P[j] # P_j is the upper bound for x_j + end end - A_ij_P_j - # Construct the x_j matrix so that each block corresponds with the appropriate x_ij - x_j = zeros(n^2,n) + + # Construct x_j matrix: maps x[j] to all entries (i,j) in the bilinear term + # x_j[idx, j] = 1 where idx = i + (j-1)*n for all i + x_j = zeros(n^2, n) P_out = zeros(n^2) for j in 1:n - if j == 1 - x_j[j:n,j:n] = Matrix(I, n, n) - P_out[j:n] .= P - else - x_j[(j-1)*n+1:j*n, 1:n] = Matrix(I, n, n) - P_out[(j-1)*n+1:j*n] .= P + for i in 1:n + idx = i + (j-1)*n + x_j[idx, j] = 1.0 + P_out[idx] = P[j] end end - x_j - #@constraint(model, -n_squared_identity * x_hat .+ x_j .+ A_ij_P_j * a .<= P_out) - - # third McCormick envelope -\hat{x}_{ij} - a_ijP_j ≤ 0 - #@constraint(model, n_squared_identity * x_hat .- A_ij_P_j * a .<= 0) - - # fourth McCormick envelope \hat{x}_{ij} - x_j ≤ 0 - #@constraint(model, n_squared_identity * x_hat .- x_j .<= 0) # Create the big F matrix for the Palma ratio constraints - F = [zeros(n,n^2) A zeros(n,n) - zeros(n,n^2) -A zeros(n,n) - zeros(n,n^2) AT zeros(n,n) - zeros(n,n^2) -AT zeros(n,n) - T zeros(n,n^2) zeros(n,n) - -n_squared_identity zeros(n^2,n^2) zeros(n^2,n) - n_squared_identity -A_ij_P_j zeros(n^2,n) - -n_squared_identity A_ij_P_j x_j - n_squared_identity zeros(n^2,n^2) -x_j + # Variable vector: y = [x_hat, a, x_raw] + # Constraints: + # Row 1-2: Row sum of permutation matrix = 1 (A_row * a = 1) + # Row 3-4: Column sum of permutation matrix = 1 (A_col * a = 1) + # Row 5: Sorting constraint (T * x_hat <= 0) + # Row 6-9: McCormick envelopes + F = [zeros(n,n^2) A_row zeros(n,n) # A_row * a <= 1 + zeros(n,n^2) -A_row zeros(n,n) # -A_row * a <= -1 (i.e., A_row * a >= 1) + zeros(n,n^2) A_col zeros(n,n) # A_col * a <= 1 + zeros(n,n^2) -A_col zeros(n,n) # -A_col * a <= -1 (i.e., A_col * a >= 1) + T zeros(n-1,n^2) zeros(n-1,n) # T * x_hat <= 0 (ascending order) + -n_squared_identity zeros(n^2,n^2) zeros(n^2,n) # -u <= 0 (envelope 1: u >= 0) + n_squared_identity -A_ij_P_j zeros(n^2,n) # u - a*P <= 0 (envelope 3: u <= a*P) + -n_squared_identity A_ij_P_j x_j # -u + a*P + x <= P (envelope 2: u >= a*P + x - P) + n_squared_identity zeros(n^2,n^2) -x_j # u - x <= 0 (envelope 4: u <= x) ] x_tilde = vcat(x_hat, a, x_raw) y = x_tilde - #value.(y) - # Store the right hand side of the constraints into a vector named g - g = [ones(n) - -ones(n) - ones(n) - -ones(n) - zeros(n) - zeros(n^2) - zeros(n^2) - P_out - zeros(n^2) + + # Right-hand side vector g + # Matches the constraint structure of F + g = [ones(n) # A_row * a <= 1 + -ones(n) # -A_row * a <= -1 + ones(n) # A_col * a <= 1 + -ones(n) # -A_col * a <= -1 + zeros(n-1) # T * x_hat <= 0 (n-1 rows now) + zeros(n^2) # -u <= 0 (envelope 1) + zeros(n^2) # u - a*P <= 0 (envelope 3) + P_out # -u + a*P + x <= P (envelope 2) + zeros(n^2) # u - x <= 0 (envelope 4) ] + # Verify dimensions before adding constraint + @assert size(F, 2) == length(y) "F columns ($(size(F, 2))) must match y length ($(length(y)))" + @assert size(F, 1) == length(g) "F rows ($(size(F, 1))) must match g length ($(length(g)))" + @constraint(model, F * y .<= g) - A_long = [A zeros(n,n^2) zeros(n,n)] + A_long = [A_row zeros(n,n^2) zeros(n,n)] # Create the vectors to extract the top 10% of load shed top_10_percent_indices = zeros(n) top_10_percent_indices[ceil(Int, 0.9*n):n] .= 1.0 @@ -231,101 +213,53 @@ function lin_palma_w_grad_input(dpshed_dw::Matrix{Float64}, pshed_prev::Vector{F # end # end - # First permutation matrix constraint to sort the rows Ax == 1 - A = zeros(n,n^2) - for i in 1:n - if i == 1 - A[i, i:n] .= 1.0 - else - A[i, (i-1)*n+1:i*n] .= 1.0 - end - end - #@constraint(model, A*a.<= 1.0) - #@constraint(model, -A*a .<= -1.0) - - # Second permutation matrix constraint to sort the columns A^T * x == 1 - AT = zeros(n,n^2) - #γ = 1 - for i in 1:n + # Sorting constraint matrix: enforces ascending order S_k <= S_{k+1} + # T * x_hat <= 0 means sorted[k] - sorted[k+1] <= 0 + # Using column-major indexing for consistency with A_row, A_col + T = zeros(n-1, n^2) + for k in 1:n-1 for j in 1:n - row = zeros(n) - row[i] = 1.0 - if j == 1 - AT[i,j:n] = row - else - AT[i,(j-1)*n+1:j*n] = row - end + idx_k = k + (j-1)*n + idx_k1 = (k+1) + (j-1)*n + T[k, idx_k] = 1.0 + T[k, idx_k1] = -1.0 end - #γ += 1 end - #@constraint(model, AT*a .<= 1.0) - #@constraint(model, -AT*a .<= -1.0) - - # Create the ascending sorting constraint matrix -T ⋅ \tilde{x} ≤ 0 - # This is the difference matrix - T = zeros(n,n^2) - for i in 1:n - if i == 1 - T[i, i:n] .= 1.0 - T[i, n+1:(i+1)*n] .-= 1.0 - else - T[i, (i-1)*n+1:i*n] .= 1.0 - if i == n - T[i, (i-1)*n+1:i*n] .= 1.0 - else - T[i, i*n+1:(i+1)*n] .-= 1.0 - end - end - end - #@constraint(model, -T*x_hat.<= 0.0) ############################################################# - # Implement th McCormick envelopes for the bilinear terms + # Implement the McCormick envelopes for the bilinear terms + # u_ij = a_ij * x_j where a_ij ∈ {0,1} and x_j ∈ [0, P_j] + # Envelope 1: u >= 0 (eq. 19) + # Envelope 2: u >= x̄·a + x - x̄ (eq. 20) + # Envelope 3: u <= x̄·a (eq. 21) + # Envelope 4: u <= x (eq. 22) ############################################################## - # Start with \hat{x}_{ij} ≤ 0 - # Stagger In matrices - # lower_bound_x = zeros(n^2,n^2) - # for i in 1:n - # if i == 1 - # lower_bound_x[i:n, i:n] .= Matrix(I, n, n) - # else - # lower_bound_x[(i-1)*n+1:i*n, (i-1)*n+1:i*n] .= Matrix(I, n, n) - # end - # end n_squared_identity = Diagonal(ones(n^2)) - #@constraint(model, -n_squared_identity * x_hat .<= 0) - - # second McCormick envelope -\hat{x}_{ij} +x_j +a_ijP_j ≤ P_j - n_identity = Diagonal(ones(n)) - # Construct the a_ijP_j matrix - A_ij_P_j = zeros(n^2,n^2) - # Set the upper limit for the sorted load shed for each node (x_raw) to be the demand (pd) at that node + # Upper bound for load shed is the demand (pd) at each node P = pd - for i in 1:n - A_ij_P_j[(i-1)*n+1:i*n, (i-1)*n+1:i*n] .= Diagonal(P) + + # Construct A_ij_P_j matrix for McCormick: represents a_ij * P_j term + # Using column-major indexing: entry (i,j) maps to index i + (j-1)*n + A_ij_P_j = zeros(n^2, n^2) + for j in 1:n + for i in 1:n + idx = i + (j-1)*n # column-major index for (i,j) + A_ij_P_j[idx, idx] = P[j] # P_j is the upper bound for x_j + end end - A_ij_P_j - # Construct the x_j matrix so that each block corresponds with the appropriate x_ij - x_j = zeros(n^2,n) + + # Construct x_j matrix: maps x[j] to all entries (i,j) in the bilinear term + # x_j[idx, j] = 1 where idx = i + (j-1)*n for all i + x_j = zeros(n^2, n) P_out = zeros(n^2) for j in 1:n - if j == 1 - x_j[j:n,j:n] = Matrix(I, n, n) - P_out[j:n] .= P - else - x_j[(j-1)*n+1:j*n, 1:n] = Matrix(I, n, n) - P_out[(j-1)*n+1:j*n] .= P + for i in 1:n + idx = i + (j-1)*n + x_j[idx, j] = 1.0 + P_out[idx] = P[j] end end - x_j - #@constraint(model, -n_squared_identity * x_hat .+ x_j .+ A_ij_P_j * a .<= P_out) - - # third McCormick envelope -\hat{x}_{ij} - a_ijP_j ≤ 0 - #@constraint(model, n_squared_identity * x_hat .- A_ij_P_j * a .<= 0) - - # fourth McCormick envelope \hat{x}_{ij} - x_j ≤ 0 - #@constraint(model, n_squared_identity * x_hat .- x_j .<= 0) A_row = zeros(n, n^2) for i in 1:n @@ -344,50 +278,43 @@ function lin_palma_w_grad_input(dpshed_dw::Matrix{Float64}, pshed_prev::Vector{F # Create the big F matrix for the Palma ratio constraints - F = [zeros(n,n^2) A_row zeros(n,n) zeros(n,n) - zeros(n,n^2) -A_row zeros(n,n) zeros(n,n) - zeros(n,n^2) A_col zeros(n,n) zeros(n,n) - zeros(n,n^2) -A_col zeros(n,n) zeros(n,n) - -T zeros(n,n^2) zeros(n,n) zeros(n,n) - ############# + # Variable vector: y = [y_xhat, y_a, y_pshed, (y_w - weights_prev)] + # Constraints: + # Row 1-2: Row sum of permutation matrix = 1 (A_row * a = 1) + # Row 3-4: Column sum of permutation matrix = 1 (A_col * a = 1) + # Row 5: Sorting constraint (T * x_hat <= 0) + # Row 6-9: McCormick envelopes + F = [zeros(n,n^2) A_row zeros(n,n) zeros(n,n) # A_row * a <= 1 + zeros(n,n^2) -A_row zeros(n,n) zeros(n,n) # -A_row * a <= -1 + zeros(n,n^2) A_col zeros(n,n) zeros(n,n) # A_col * a <= 1 + zeros(n,n^2) -A_col zeros(n,n) zeros(n,n) # -A_col * a <= -1 + -T zeros(n-1,n^2) zeros(n-1,n) zeros(n-1,n) # -T * x_hat <= 0 (note: T is n-1 rows now) # McCormick envelopes - ############ - -n_squared_identity zeros(n^2,n^2) zeros(n^2,n) zeros(n^2,n) - n_squared_identity -A_ij_P_j zeros(n^2,n) zeros(n^2,n) - -n_squared_identity A_ij_P_j x_j zeros(n^2,n) - n_squared_identity zeros(n^2,n^2) -x_j zeros(n^2,n) - ############# - # BIG M - ############## - #-n_squared_identity zeros(n^2,n^2) zeros(n^2,n) zeros(n^2,n) - #n_squared_identity -A_ij_P_j -x_j zeros(n^2,n) - #-n_squared_identity -A_ij_P_j x_j zeros(n^2,n) - #n_squared_identity -A_ij_P_j zeros(n^2,n) zeros(n^2,n) - #zeros(n,2*n^2) n_identity -dpshed_dw - #zeros(n,2*n^2) -n_identity dpshed_dw + -n_squared_identity zeros(n^2,n^2) zeros(n^2,n) zeros(n^2,n) # -u <= 0 (envelope 1: u >= 0) + n_squared_identity -A_ij_P_j zeros(n^2,n) zeros(n^2,n) # u - a*P <= 0 (envelope 3: u <= a*P) + -n_squared_identity A_ij_P_j x_j zeros(n^2,n) # -u + a*P + x <= P (envelope 2) + n_squared_identity zeros(n^2,n^2) -x_j zeros(n^2,n) # u - x <= 0 (envelope 4: u <= x) ] - # x_tilde = vcat(x_hat, a, pshed_new, (weights_new.-weights_prev)) - # Store the right hand side of the constraints into a vector named g - g = [ones(n) - -ones(n) - ones(n) - -ones(n) - zeros(n) - zeros(n^2) - -P_out - zeros(n^2) - zeros(n^2) - #zeros(n^2) - #-P_out - #-P_out - #zeros(n^2) - #pshed_prev - #-pshed_prev + # Right-hand side vector g (FIXED: McCormick envelope RHS values) + # Matches the constraint structure of F + g = [ones(n) # A_row * a <= 1 + -ones(n) # -A_row * a <= -1 + ones(n) # A_col * a <= 1 + -ones(n) # -A_col * a <= -1 + zeros(n-1) # -T * x_hat <= 0 (n-1 rows now) + zeros(n^2) # -u <= 0 (envelope 1) + zeros(n^2) # u - a*P <= 0 (envelope 3) - FIXED: was -P_out + P_out # -u + a*P + x <= P (envelope 2) - FIXED: was zeros + zeros(n^2) # u - x <= 0 (envelope 4) ] + # Verify dimensions before adding constraint + @assert size(F, 2) == length(y) "F columns ($(size(F, 2))) must match y length ($(length(y)))" + @assert size(F, 1) == length(g) "F rows ($(size(F, 1))) must match g length ($(length(g)))" + @constraint(model, F * y .<= g*σ) - A_long = [A zeros(n,n^2) zeros(n,2*n)] + A_long = [A_row zeros(n,n^2) zeros(n,2*n)] # FIXED: use A_row instead of undefined A # Create the vectors to extract the top 10% of load shed top_10_percent_indices = zeros(n) top_10_percent_indices[ceil(Int, 0.9*n):n] .= 1.0 diff --git a/test/test_matrix_f.jl b/test/test_matrix_f.jl new file mode 100644 index 0000000..2b93d28 --- /dev/null +++ b/test/test_matrix_f.jl @@ -0,0 +1,372 @@ +# Test script to verify the correctness of Matrix F construction +# for the Palma ratio linearization with McCormick envelopes +# +# This script tests: +# 1. Matrix dimension consistency +# 2. Permutation matrix constraints (doubly stochastic) +# 3. Sorting constraint produces ascending order +# 4. McCormick envelope bounds are correct + +using LinearAlgebra +using Test + +println("="^60) +println("Testing Matrix F Construction for Palma Ratio Linearization") +println("="^60) + +# Test with small n for easy verification +n = 3 +P = [1.0, 2.0, 3.0] # Upper bounds for x_j + +println("\nTest parameters: n = $n, P = $P") + +# ============================================================ +# Build matrices using the FIXED column-major indexing +# ============================================================ + +println("\n--- Building matrices with column-major indexing ---") + +# Permutation matrix row sum constraint +A_row = zeros(n, n^2) +for i in 1:n + for j in 1:n + idx = i + (j-1)*n # column-major + A_row[i, idx] = 1.0 + end +end + +# Permutation matrix column sum constraint +A_col = zeros(n, n^2) +for j in 1:n + for i in 1:n + idx = i + (j-1)*n # column-major + A_col[j, idx] = 1.0 + end +end + +# Sorting constraint (n-1 rows) +T = zeros(n-1, n^2) +for k in 1:n-1 + for j in 1:n + idx_k = k + (j-1)*n + idx_k1 = (k+1) + (j-1)*n + T[k, idx_k] = 1.0 + T[k, idx_k1] = -1.0 + end +end + +# McCormick matrices +n_squared_identity = Diagonal(ones(n^2)) + +A_ij_P_j = zeros(n^2, n^2) +for j in 1:n + for i in 1:n + idx = i + (j-1)*n + A_ij_P_j[idx, idx] = P[j] + end +end + +x_j = zeros(n^2, n) +P_out = zeros(n^2) +for j in 1:n + for i in 1:n + idx = i + (j-1)*n + x_j[idx, j] = 1.0 + P_out[idx] = P[j] + end +end + +# Build F matrix (for lin_palma with 3-column variable vector) +F = [zeros(n,n^2) A_row zeros(n,n) + zeros(n,n^2) -A_row zeros(n,n) + zeros(n,n^2) A_col zeros(n,n) + zeros(n,n^2) -A_col zeros(n,n) + T zeros(n-1,n^2) zeros(n-1,n) + -n_squared_identity zeros(n^2,n^2) zeros(n^2,n) + n_squared_identity -A_ij_P_j zeros(n^2,n) + -n_squared_identity A_ij_P_j x_j + n_squared_identity zeros(n^2,n^2) -x_j +] + +# Build g vector (FIXED) +g = [ones(n) + -ones(n) + ones(n) + -ones(n) + zeros(n-1) + zeros(n^2) + zeros(n^2) # envelope 3: u - a*P <= 0 + P_out # envelope 2: -u + a*P + x <= P + zeros(n^2) +] + +# Variable vector dimensions +y_dim = 2*n^2 + n # x_hat (n²) + a (n²) + x_raw (n) + +println("A_row size: $(size(A_row))") +println("A_col size: $(size(A_col))") +println("T size: $(size(T))") +println("F size: $(size(F))") +println("g size: $(size(g))") +println("y dimension: $y_dim") + +# ============================================================ +# Test 1: Dimension consistency +# ============================================================ +println("\n--- Test 1: Dimension Consistency ---") + +@testset "Dimension Consistency" begin + @test size(F, 2) == y_dim + @test size(F, 1) == length(g) + @test size(A_row) == (n, n^2) + @test size(A_col) == (n, n^2) + @test size(T) == (n-1, n^2) + @test size(A_ij_P_j) == (n^2, n^2) + @test size(x_j) == (n^2, n) + @test length(P_out) == n^2 +end + +# ============================================================ +# Test 2: Permutation constraints (doubly stochastic) +# ============================================================ +println("\n--- Test 2: Permutation Constraints ---") + +# Create a valid permutation matrix (identity) +# In column-major vectorization: a[k] = 1 if k = i + (i-1)*n (diagonal) +a_identity = zeros(n^2) +for i in 1:n + idx = i + (i-1)*n # diagonal in column-major + a_identity[idx] = 1.0 +end + +println("Identity permutation vector (column-major):") +println(" a = $a_identity") + +@testset "Permutation Constraints" begin + # Row sums should equal 1 + row_sums = A_row * a_identity + @test all(isapprox.(row_sums, 1.0, atol=1e-10)) + println(" Row sums: $row_sums ✓") + + # Column sums should equal 1 + col_sums = A_col * a_identity + @test all(isapprox.(col_sums, 1.0, atol=1e-10)) + println(" Column sums: $col_sums ✓") +end + +# Test with a different permutation: swap elements 1 and 2 +# Permutation: [2, 1, 3] means position 1 gets element 2, position 2 gets element 1 +# In matrix form: a[1,2]=1, a[2,1]=1, a[3,3]=1 +a_swap = zeros(n^2) +a_swap[1 + (2-1)*n] = 1.0 # a[1,2] = 1 (row 1, col 2) +a_swap[2 + (1-1)*n] = 1.0 # a[2,1] = 1 (row 2, col 1) +a_swap[3 + (3-1)*n] = 1.0 # a[3,3] = 1 (row 3, col 3) + +println("\nSwap permutation (swap pos 1 and 2):") +println(" a = $a_swap") + +@testset "Swap Permutation" begin + row_sums = A_row * a_swap + @test all(isapprox.(row_sums, 1.0, atol=1e-10)) + println(" Row sums: $row_sums ✓") + + col_sums = A_col * a_swap + @test all(isapprox.(col_sums, 1.0, atol=1e-10)) + println(" Column sums: $col_sums ✓") +end + +# ============================================================ +# Test 3: Sorting constraint +# ============================================================ +println("\n--- Test 3: Sorting Constraint ---") + +# For x = [3.0, 1.0, 2.0], the sorted version is [1.0, 2.0, 3.0] +# This requires permutation a where: +# sorted[1] = x[2] → a[1,2] = 1 +# sorted[2] = x[3] → a[2,3] = 1 +# sorted[3] = x[1] → a[3,1] = 1 + +x_unsorted = [3.0, 1.0, 2.0] +a_sort = zeros(n^2) +a_sort[1 + (2-1)*n] = 1.0 # a[1,2] = 1 → sorted[1] gets x[2] = 1.0 +a_sort[2 + (3-1)*n] = 1.0 # a[2,3] = 1 → sorted[2] gets x[3] = 2.0 +a_sort[3 + (1-1)*n] = 1.0 # a[3,1] = 1 → sorted[3] gets x[1] = 3.0 + +# Compute x_hat = a * x (bilinear product, represented as n² vector) +# x_hat[idx] = a[i,j] * x[j] where idx = i + (j-1)*n +x_hat = zeros(n^2) +for j in 1:n + for i in 1:n + idx = i + (j-1)*n + x_hat[idx] = a_sort[idx] * x_unsorted[j] + end +end + +# The sorted values are obtained by summing x_hat over each row i +sorted_values = zeros(n) +for i in 1:n + for j in 1:n + idx = i + (j-1)*n + sorted_values[i] += x_hat[idx] + end +end + +println("Unsorted x: $x_unsorted") +println("Sorted values: $sorted_values") + +@testset "Sorting Constraint" begin + # Verify sorted values are [1.0, 2.0, 3.0] + @test sorted_values ≈ [1.0, 2.0, 3.0] + + # Verify T * x_hat <= 0 (ascending order) + sort_constraint = T * x_hat + println("T * x_hat = $sort_constraint (should be <= 0)") + @test all(sort_constraint .<= 1e-10) + println(" Sorting constraint satisfied ✓") +end + +# ============================================================ +# Test 4: McCormick Envelope Bounds +# ============================================================ +println("\n--- Test 4: McCormick Envelope Bounds ---") + +# Test McCormick envelopes for a specific (i,j) pair +# For a[i,j] ∈ {0,1} and x[j] ∈ [0, P[j]], u[i,j] = a[i,j] * x[j] +# Envelopes: +# u >= 0 +# u >= P[j]*a + x - P[j] +# u <= P[j]*a +# u <= x + +@testset "McCormick Envelopes" begin + for (a_val, x_val, j) in [(0.0, 0.5, 1), (1.0, 0.5, 1), (0.0, 1.5, 2), (1.0, 1.5, 2)] + u_true = a_val * x_val + P_j = P[j] + + # Envelope bounds + lower1 = 0.0 + lower2 = P_j * a_val + x_val - P_j + upper1 = P_j * a_val + upper2 = x_val + + lower_bound = max(lower1, lower2) + upper_bound = min(upper1, upper2) + + println("\na=$a_val, x=$x_val, j=$j, P_j=$P_j") + println(" True u = a*x = $u_true") + println(" Envelope 1 (u >= 0): $lower1") + println(" Envelope 2 (u >= P*a + x - P): $lower2") + println(" Envelope 3 (u <= P*a): $upper1") + println(" Envelope 4 (u <= x): $upper2") + println(" Bounds: [$lower_bound, $upper_bound]") + + @test lower_bound <= u_true + 1e-10 + @test u_true <= upper_bound + 1e-10 + + # For binary a, the bounds should be tight + if a_val == 0.0 || a_val == 1.0 + @test isapprox(lower_bound, u_true, atol=1e-10) || isapprox(upper_bound, u_true, atol=1e-10) + end + end +end + +# ============================================================ +# Test 5: Full constraint system F * y <= g +# ============================================================ +println("\n--- Test 5: Full Constraint System ---") + +# Build a feasible y vector +# y = [x_hat, a, x_raw] where x_hat = bilinear terms, a = permutation, x_raw = loads + +# Use x_raw values within bounds [0, P[j]] for each j +# P = [1.0, 2.0, 3.0], so use x_raw = [0.5, 1.5, 2.5] +x_raw_bounded = [0.5, 1.5, 2.5] + +# Use identity permutation (simpler for testing) +a = a_identity + +# Compute x_hat as the McCormick relaxation (for binary a, x_hat = a * x) +x_hat_full = zeros(n^2) +for j in 1:n + for i in 1:n + idx = i + (j-1)*n + x_hat_full[idx] = a[idx] * x_raw_bounded[j] + end +end + +y = vcat(x_hat_full, a, x_raw_bounded) + +println("y vector:") +println(" x_hat = $x_hat_full") +println(" a = $a") +println(" x_raw = $x_raw_bounded") + +# Evaluate F * y +Fy = F * y + +@testset "Full Constraint System" begin + violations = Fy .- g + max_violation = maximum(violations) + + println("\nConstraint violations (F*y - g):") + println(" Max violation: $max_violation") + + # All constraints should be satisfied (F*y <= g) + @test max_violation <= 1e-10 + println(" All constraints satisfied ✓") + + # Check specific constraint blocks + offset = 0 + + # Row sum constraints: A_row * a = 1 + row_block = Fy[offset+1:offset+n] + @test all(row_block .<= 1.0 + 1e-10) + offset += n + + # -A_row * a <= -1 (i.e., A_row * a >= 1) + negrow_block = Fy[offset+1:offset+n] + @test all(negrow_block .<= -1.0 + 1e-10) + offset += n + + # Column sum constraints + col_block = Fy[offset+1:offset+n] + @test all(col_block .<= 1.0 + 1e-10) + offset += n + + negcol_block = Fy[offset+1:offset+n] + @test all(negcol_block .<= -1.0 + 1e-10) + offset += n + + # Sorting constraint: T * x_hat <= 0 + sort_block = Fy[offset+1:offset+n-1] + @test all(sort_block .<= 1e-10) + println(" Sorting constraints: max = $(maximum(sort_block)) ✓") + offset += n-1 + + # McCormick envelope 1: -u <= 0 (i.e., u >= 0) + env1_block = Fy[offset+1:offset+n^2] + @test all(env1_block .<= 1e-10) + println(" McCormick envelope 1 (u >= 0): max = $(maximum(env1_block)) ✓") + offset += n^2 + + # McCormick envelope 3: u - a*P <= 0 + env3_block = Fy[offset+1:offset+n^2] + @test all(env3_block .<= 1e-10) + println(" McCormick envelope 3 (u <= a*P): max = $(maximum(env3_block)) ✓") + offset += n^2 + + # McCormick envelope 2: -u + a*P + x <= P + env2_block = Fy[offset+1:offset+n^2] + @test all(env2_block .<= P_out .+ 1e-10) + println(" McCormick envelope 2 (u >= a*P + x - P): max violation = $(maximum(env2_block .- P_out)) ✓") + offset += n^2 + + # McCormick envelope 4: u - x <= 0 + env4_block = Fy[offset+1:offset+n^2] + @test all(env4_block .<= 1e-10) + println(" McCormick envelope 4 (u <= x): max = $(maximum(env4_block)) ✓") +end + +println("\n" * "="^60) +println("All tests passed!") +println("="^60) From 0e4745f5e51cde0e9a0c50dee01093aed52d9dd8 Mon Sep 17 00:00:00 2001 From: samtalki Date: Mon, 5 Jan 2026 13:30:31 -0500 Subject: [PATCH 2/7] Sorting constraint sign fix? --- script/relaxed_palma_ratio.jl | 4 ++-- src/implementation/palma_relaxation.jl | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/script/relaxed_palma_ratio.jl b/script/relaxed_palma_ratio.jl index fcd8bb2..217d04a 100644 --- a/script/relaxed_palma_ratio.jl +++ b/script/relaxed_palma_ratio.jl @@ -408,7 +408,7 @@ F = [zeros(n,n^2) A_row zeros(n,n) # A_row * a <= 1 zeros(n,n^2) -A_row zeros(n,n) # -A_row * a <= -1 (i.e., A_row * a >= 1) zeros(n,n^2) A_col zeros(n,n) # A_col * a <= 1 zeros(n,n^2) -A_col zeros(n,n) # -A_col * a <= -1 (i.e., A_col * a >= 1) - -T zeros(n-1,n^2) zeros(n-1,n) # -T * x_hat <= 0 (ascending order) + T zeros(n-1,n^2) zeros(n-1,n) # T * x_hat <= 0 (ascending order) -n_squared_identity zeros(n^2,n^2) zeros(n^2,n) # -u <= 0 (envelope 1: u >= 0) n_squared_identity -A_ij_P_j zeros(n^2,n) # u - a*P <= 0 (envelope 3: u <= a*P) -n_squared_identity A_ij_P_j x_j # -u + a*P + x <= P (envelope 2) @@ -424,7 +424,7 @@ g = [ones(n) # A_row * a <= 1 -ones(n) # -A_row * a <= -1 ones(n) # A_col * a <= 1 -ones(n) # -A_col * a <= -1 - zeros(n-1) # -T * x_hat <= 0 (n-1 rows now) + zeros(n-1) # T * x_hat <= 0 (ascending order) zeros(n^2) # -u <= 0 (envelope 1) zeros(n^2) # u - a*P <= 0 (envelope 3) - FIXED P_out # -u + a*P + x <= P (envelope 2) - FIXED diff --git a/src/implementation/palma_relaxation.jl b/src/implementation/palma_relaxation.jl index a2d7c01..b900c70 100644 --- a/src/implementation/palma_relaxation.jl +++ b/src/implementation/palma_relaxation.jl @@ -288,7 +288,7 @@ function lin_palma_w_grad_input(dpshed_dw::Matrix{Float64}, pshed_prev::Vector{F zeros(n,n^2) -A_row zeros(n,n) zeros(n,n) # -A_row * a <= -1 zeros(n,n^2) A_col zeros(n,n) zeros(n,n) # A_col * a <= 1 zeros(n,n^2) -A_col zeros(n,n) zeros(n,n) # -A_col * a <= -1 - -T zeros(n-1,n^2) zeros(n-1,n) zeros(n-1,n) # -T * x_hat <= 0 (note: T is n-1 rows now) + T zeros(n-1,n^2) zeros(n-1,n) zeros(n-1,n) # T * x_hat <= 0 (ascending order) # McCormick envelopes -n_squared_identity zeros(n^2,n^2) zeros(n^2,n) zeros(n^2,n) # -u <= 0 (envelope 1: u >= 0) n_squared_identity -A_ij_P_j zeros(n^2,n) zeros(n^2,n) # u - a*P <= 0 (envelope 3: u <= a*P) @@ -302,7 +302,7 @@ function lin_palma_w_grad_input(dpshed_dw::Matrix{Float64}, pshed_prev::Vector{F -ones(n) # -A_row * a <= -1 ones(n) # A_col * a <= 1 -ones(n) # -A_col * a <= -1 - zeros(n-1) # -T * x_hat <= 0 (n-1 rows now) + zeros(n-1) # T * x_hat <= 0 (ascending order) zeros(n^2) # -u <= 0 (envelope 1) zeros(n^2) # u - a*P <= 0 (envelope 3) - FIXED: was -P_out P_out # -u + a*P + x <= P (envelope 2) - FIXED: was zeros From bbf38f1680ae76a0b74f91ebc9dd47aab7222c13 Mon Sep 17 00:00:00 2001 From: samtalki Date: Wed, 14 Jan 2026 15:56:22 -0500 Subject: [PATCH 3/7] Reformulate Palma ratio optimization with pshed as JuMP expression Key changes: - Add script/reformulation/load_shed_as_parameter.jl with new formulation where P_shed is a JuMP @expression (not @variable), eliminating equality constraints and simplifying the model structure - Add script/reformulation/validate_reformulation.jl with tests - Remove duplicate fairness functions from src/core/objective.jl (gini_index, jains_index, palma_ratio, alpha_fairness) that were causing precompilation errors due to method overwriting The reformulation preserves full dynamic sorting via permutation matrix optimization while using McCormick envelopes for bilinear terms and Charnes-Cooper transformation for the ratio objective. Co-Authored-By: Claude Opus 4.5 --- .../reformulation/load_shed_as_parameter.jl | 532 ++++++++++++++++++ .../reformulation/validate_reformulation.jl | 264 +++++++++ src/core/objective.jl | 36 +- 3 files changed, 799 insertions(+), 33 deletions(-) create mode 100644 script/reformulation/load_shed_as_parameter.jl create mode 100644 script/reformulation/validate_reformulation.jl diff --git a/script/reformulation/load_shed_as_parameter.jl b/script/reformulation/load_shed_as_parameter.jl new file mode 100644 index 0000000..fe4f396 --- /dev/null +++ b/script/reformulation/load_shed_as_parameter.jl @@ -0,0 +1,532 @@ +#= +Reformulated Palma Ratio Fair Load Prioritization +================================================== + +This implementation reformulates the Palma ratio optimization by treating P_shed +as a JuMP expression derived from first-order Taylor expansion, rather than as +optimization variables with equality constraints. + +Key simplification: +- P_shed_new is an EXPRESSION: pshed_new[i] = pshed_prev[i] + Σ_j J[i,j]·Δw[j] +- Eliminates n variables and their equality constraints +- Full dynamic sorting is PRESERVED via permutation matrix optimization + +Mathematical formulation: + min_{Δw, A} [Σ_{i∈Top10%} sorted[i]] / [Σ_{i∈Bot40%} sorted[i]] + + s.t. sorted[i] = Σ_j a[i,j] · pshed_new[j] (sorting via permutation) + pshed_new[j] = pshed_prev[j] + Σ_k J[j,k]·Δw[k] (Taylor expression) + Σ_j a[i,j] = 1, Σ_i a[i,j] = 1 (doubly stochastic) + sorted[k] ≤ sorted[k+1] (ascending order) + |Δw| ≤ trust_radius (trust region) + +Uses Charnes-Cooper transformation to convert ratio to linear objective. +Uses McCormick envelopes for bilinear terms a[i,j] * pshed_new[j]. + +Author: Claude (with guidance from Sam) +Date: 2026-01-14 +=# + +using JuMP +using LinearAlgebra + +# Try to load solvers +# Gurobi is REQUIRED for the quadratic Charnes-Cooper constraint +# HiGHS can be used as fallback with Dinkelbach's algorithm (see alternative function) +const GUROBI_AVAILABLE = try + using Gurobi + true +catch + false +end + +const HIGHS_AVAILABLE = try + using HiGHS + true +catch + false +end + +const IPOPT_AVAILABLE = try + using Ipopt + true +catch + false +end + +#============================================================================= + Helper Functions +=============================================================================# + +""" + compute_palma_indices(n::Int) -> (top_10_idx, bottom_40_idx) + +Compute indices for top 10% and bottom 40% in SORTED space. +These are fixed positions in the sorted array, not indices into the original array. + +For Palma ratio: top 10% = largest values, bottom 40% = smallest values. +In ascending sorted order: bottom 40% are first floor(0.4n) positions, + top 10% are last ceil(0.1n) positions. + +# Example +For n=10: bottom_40_idx = [1,2,3,4], top_10_idx = [10] +For n=20: bottom_40_idx = [1,2,3,4,5,6,7,8], top_10_idx = [19,20] +""" +function compute_palma_indices(n::Int) + # Bottom 40%: indices 1 to floor(0.4n) in sorted order + n_bottom = max(1, floor(Int, 0.4 * n)) + bottom_40_idx = collect(1:n_bottom) + + # Top 10%: indices ceil(0.9n) to n in sorted order + n_top_start = max(n, ceil(Int, 0.9 * n)) # Ensure at least last element + top_10_idx = collect(n_top_start:n) + + # Handle edge case where ranges might be empty + if isempty(top_10_idx) + top_10_idx = [n] + end + if isempty(bottom_40_idx) + bottom_40_idx = [1] + end + + return top_10_idx, bottom_40_idx +end + +""" + palma_ratio(pshed::Vector{Float64}) -> Float64 + +Compute the Palma ratio: sum(top 10%) / sum(bottom 40%) after sorting. +Returns Inf if denominator is zero or negative. +""" +function palma_ratio(pshed::Vector{Float64}) + n = length(pshed) + sorted_pshed = sort(pshed) # ascending order + + top_10_idx, bottom_40_idx = compute_palma_indices(n) + + numerator = sum(sorted_pshed[i] for i in top_10_idx) + denominator = sum(sorted_pshed[i] for i in bottom_40_idx) + + if denominator <= 0 + return Inf + end + return numerator / denominator +end + +""" + get_default_solver() + +Return the best available solver optimizer. + +NOTE: The Charnes-Cooper transformation creates quadratic constraints (sorted[i] * σ), +so a QP-capable solver like Gurobi is required. HiGHS only supports LP/MILP. +""" +function get_default_solver() + if GUROBI_AVAILABLE + return Gurobi.Optimizer + elseif IPOPT_AVAILABLE + @warn "Using Ipopt (NLP solver). Gurobi is recommended for better performance." + return Ipopt.Optimizer + else + error("No QP-capable solver available. Please install Gurobi or Ipopt.\n" * + "HiGHS cannot handle the quadratic Charnes-Cooper constraints.") + end +end + +#============================================================================= + Main Optimization: Palma Ratio Minimization +=============================================================================# + +""" + palma_ratio_minimization( + dpshed_dw::Matrix{Float64}, + pshed_prev::Vector{Float64}, + weights_prev::Vector{Float64}, + pd::Vector{Float64}; + trust_radius::Float64 = 0.1, + w_bounds::Tuple{Float64,Float64} = (0.0, 10.0), + solver = get_default_solver(), + silent::Bool = true, + relax_binary::Bool = true + ) + +Solve the Palma ratio minimization problem with P_shed as an expression. + +# Key Innovation +P_shed is represented as a JuMP @expression (not @variable): +```julia +@expression(model, pshed_new[i], pshed_prev[i] + Σ_j J[i,j]·Δw[j]) +``` +This eliminates the need for equality constraints linking pshed variables +to the Taylor expansion, reducing model complexity. + +# Arguments +- `dpshed_dw`: Jacobian matrix ∂P_shed/∂w from lower-level implicit differentiation (n×n) +- `pshed_prev`: Load shed values from previous iteration (n) +- `weights_prev`: Weight values from previous iteration (n) +- `pd`: Load demands (upper bounds on load shed) (n) +- `trust_radius`: Maximum absolute weight change per iteration (default 0.1) +- `w_bounds`: (w_min, w_max) tuple for weight bounds (default (0.0, 10.0)) +- `solver`: JuMP optimizer (default: Gurobi if available, else HiGHS) +- `silent`: Suppress solver output (default true) +- `relax_binary`: If true, relax a[i,j] to [0,1]; if false, use binary (default true) + +# Returns +NamedTuple with fields: +- `weights_new::Vector{Float64}`: Updated weights +- `pshed_new::Vector{Float64}`: Predicted load shed values +- `delta_w::Vector{Float64}`: Weight changes +- `palma_ratio::Float64`: Achieved Palma ratio +- `status::TerminationStatusCode`: Solver termination status +- `solve_time::Float64`: Solver time in seconds +- `permutation::Matrix{Float64}`: Optimal permutation matrix (or relaxation) +- `sorted_values::Vector{Float64}`: Sorted load shed values + +# Mathematical Formulation + +## Decision Variables +- Δw[j] ∈ [-trust_radius, trust_radius]: weight changes +- a[i,j] ∈ {0,1} (or [0,1] if relaxed): permutation matrix +- u[i,j] ≥ 0: McCormick auxiliary for a[i,j] * pshed_new[j] +- σ ≥ ε: Charnes-Cooper scaling variable + +## P_shed as Expression +``` +pshed_new[j] = pshed_prev[j] + Σ_k dpshed_dw[j,k] * Δw[k] +``` + +## McCormick Envelopes for u[i,j] = a[i,j] * pshed_new[j] +Since a[i,j] ∈ {0,1} and pshed_new[j] ∈ [0, P_j]: +1. u[i,j] ≥ 0 +2. u[i,j] ≥ pshed_new[j] + a[i,j]*P_j - P_j +3. u[i,j] ≤ a[i,j] * P_j +4. u[i,j] ≤ pshed_new[j] + +## Charnes-Cooper Transformation +Transform min(num/denom) to: min(num*σ) s.t. denom*σ = 1 +where σ = 1/denom > 0. +""" +function palma_ratio_minimization( + dpshed_dw::Matrix{Float64}, + pshed_prev::Vector{Float64}, + weights_prev::Vector{Float64}, + pd::Vector{Float64}; + trust_radius::Float64 = 0.1, + w_bounds::Tuple{Float64, Float64} = (0.0, 10.0), + solver = get_default_solver(), + silent::Bool = true, + relax_binary::Bool = true +) + n = length(pshed_prev) + w_min, w_max = w_bounds + ε = 1e-8 # Small positive for σ lower bound + + # Validate inputs + @assert size(dpshed_dw) == (n, n) "Jacobian must be n×n" + @assert length(weights_prev) == n "weights_prev must have length n" + @assert length(pd) == n "pd must have length n" + @assert all(pd .>= 0) "Load demands must be non-negative" + + # Create model + model = Model(solver) + if silent + set_silent(model) + end + + # Solver-specific settings + if GUROBI_AVAILABLE && solver == Gurobi.Optimizer + set_optimizer_attribute(model, "DualReductions", 0) + set_optimizer_attribute(model, "MIPGap", 1e-6) + set_optimizer_attribute(model, "NonConvex", 2) # Allow non-convex QP + elseif IPOPT_AVAILABLE && solver == Ipopt.Optimizer + set_optimizer_attribute(model, "print_level", 0) + end + + #========================================================================= + # Decision Variables + =========================================================================# + + # Weight changes (the primary decision variable) + @variable(model, Δw[1:n]) + + # Permutation matrix (binary or relaxed to doubly stochastic) + if relax_binary + @variable(model, 0 <= a[1:n, 1:n] <= 1) + else + @variable(model, a[1:n, 1:n], Bin) + end + + # McCormick auxiliary variables for bilinear products + @variable(model, u[1:n, 1:n] >= 0) + + # Charnes-Cooper scaling variable + @variable(model, σ >= ε) + + #========================================================================= + # P_shed as EXPRESSION (Core Simplification) + =========================================================================# + + # This is the key innovation: pshed_new is an expression, not a variable + # It's defined by the first-order Taylor expansion around the previous solution + @expression(model, pshed_new[j=1:n], + pshed_prev[j] + sum(dpshed_dw[j, k] * Δw[k] for k in 1:n) + ) + + #========================================================================= + # Trust Region and Weight Bounds + =========================================================================# + + @constraint(model, trust_lb[j=1:n], Δw[j] >= -trust_radius) + @constraint(model, trust_ub[j=1:n], Δw[j] <= trust_radius) + @constraint(model, weight_lb[j=1:n], weights_prev[j] + Δw[j] >= w_min) + @constraint(model, weight_ub[j=1:n], weights_prev[j] + Δw[j] <= w_max) + + #========================================================================= + # Permutation Matrix Constraints (Doubly Stochastic) + =========================================================================# + + # Row sums = 1: each sorted position gets exactly one load + @constraint(model, row_sum[i=1:n], sum(a[i, j] for j in 1:n) == 1) + + # Column sums = 1: each load appears in exactly one sorted position + @constraint(model, col_sum[j=1:n], sum(a[i, j] for i in 1:n) == 1) + + #========================================================================= + # McCormick Envelopes for u[i,j] = a[i,j] * pshed_new[j] + =========================================================================# + + # pshed_new[j] is bounded by [0, pd[j]] (can't shed more than demand) + # a[i,j] is bounded by [0, 1] + + for i in 1:n, j in 1:n + P_j = pd[j] # Upper bound on pshed_new[j] + + # Envelope 1: u >= 0 (already in variable bounds) + + # Envelope 2: u >= pshed_new + a*P - P (active when a=1) + @constraint(model, u[i, j] >= pshed_new[j] + a[i, j] * P_j - P_j) + + # Envelope 3: u <= a*P + @constraint(model, u[i, j] <= a[i, j] * P_j) + + # Envelope 4: u <= pshed_new (active when a=1) + @constraint(model, u[i, j] <= pshed_new[j]) + end + + #========================================================================= + # Sorted Values Expression + =========================================================================# + + # sorted[i] = Σ_j a[i,j] * pshed_new[j] = Σ_j u[i,j] + @expression(model, sorted[i=1:n], sum(u[i, j] for j in 1:n)) + + #========================================================================= + # Ascending Order Constraint + =========================================================================# + + # Enforce sorted[k] <= sorted[k+1] for ascending order + @constraint(model, ascending[k=1:n-1], sorted[k] <= sorted[k+1]) + + #========================================================================= + # Charnes-Cooper Transformation for Palma Ratio + =========================================================================# + + # Get indices in sorted space + top_10_idx, bottom_40_idx = compute_palma_indices(n) + + # Denominator normalization: Σ_{i∈Bottom40%} sorted[i] * σ = 1 + @constraint(model, denom_norm, + sum(sorted[i] * σ for i in bottom_40_idx) == 1) + + # Objective: minimize Σ_{i∈Top10%} sorted[i] * σ (scaled numerator) + @objective(model, Min, sum(sorted[i] * σ for i in top_10_idx)) + + #========================================================================= + # Solve + =========================================================================# + + solve_time = @elapsed optimize!(model) + status = termination_status(model) + + #========================================================================= + # Extract Solution + =========================================================================# + + if status in [OPTIMAL, LOCALLY_SOLVED, ALMOST_OPTIMAL, TIME_LIMIT] + Δw_val = value.(Δw) + weights_new = weights_prev .+ Δw_val + + # Compute pshed_new from the expression + pshed_new_val = pshed_prev .+ dpshed_dw * Δw_val + + # Get permutation matrix + a_val = value.(a) + + # Compute sorted values + sorted_val = [value(sorted[i]) for i in 1:n] + + # Compute actual Palma ratio (from unsorted pshed_new) + actual_palma = palma_ratio(pshed_new_val) + + return ( + weights_new = weights_new, + pshed_new = pshed_new_val, + delta_w = Δw_val, + palma_ratio = actual_palma, + status = status, + solve_time = solve_time, + permutation = a_val, + sorted_values = sorted_val + ) + else + @warn "Solver failed with status: $status" + return ( + weights_new = weights_prev, + pshed_new = pshed_prev, + delta_w = zeros(n), + palma_ratio = palma_ratio(pshed_prev), + status = status, + solve_time = solve_time, + permutation = Matrix{Float64}(I, n, n), + sorted_values = sort(pshed_prev) + ) + end +end + +#============================================================================= + Simplified Interface (matches existing lin_palma_w_grad_input signature) +=============================================================================# + +""" + lin_palma_reformulated( + dpshed_dw::Matrix{Float64}, + pshed_prev::Vector{Float64}, + weights_prev::Vector{Float64}, + pd::Vector{Float64} + ) -> (pshed_new, weights_new, σ) + +Drop-in replacement for lin_palma_w_grad_input from palma_relaxation.jl. +Returns the same tuple format for compatibility. +""" +function lin_palma_reformulated( + dpshed_dw::Matrix{Float64}, + pshed_prev::Vector{Float64}, + weights_prev::Vector{Float64}, + pd::Vector{Float64} +) + result = palma_ratio_minimization( + dpshed_dw, pshed_prev, weights_prev, pd; + trust_radius = 0.1, + w_bounds = (0.0, 10.0), + relax_binary = true + ) + + # Compute σ from result (for compatibility) + n = length(pshed_prev) + _, bottom_40_idx = compute_palma_indices(n) + sorted_pshed = sort(result.pshed_new) + denom = sum(sorted_pshed[i] for i in bottom_40_idx) + σ = denom > 0 ? 1.0 / denom : 1e-8 + + return result.pshed_new, result.weights_new, σ +end + +#============================================================================= + Validation / Testing Functions +=============================================================================# + +using Random + +""" + test_with_synthetic_data(; n=5, seed=42) + +Test the reformulation with synthetic data to verify correctness. +""" +function test_with_synthetic_data(; n::Int=5, seed::Int=42) + Random.seed!(seed) + + println("="^60) + println("Testing Palma Ratio Reformulation with Synthetic Data") + println("="^60) + println("n = $n loads") + println() + + # Generate synthetic data + pd = rand(n) .* 10 .+ 1 # Demands between 1 and 11 + pshed_prev = pd .* (0.3 .+ 0.4 .* rand(n)) # 30-70% of demand + weights_prev = ones(n) .* 5.0 # Start at middle weights + + # Generate a realistic Jacobian (mostly diagonal with some coupling) + dpshed_dw = zeros(n, n) + for i in 1:n + dpshed_dw[i, i] = -pd[i] * 0.1 # Increasing weight reduces shed + for j in 1:n + if i != j + dpshed_dw[i, j] = pd[i] * 0.01 * randn() # Small coupling + end + end + end + + println("Input data:") + println(" pd (demands): ", round.(pd, digits=3)) + println(" pshed_prev: ", round.(pshed_prev, digits=3)) + println(" weights_prev: ", weights_prev) + println(" Initial Palma: ", round(palma_ratio(pshed_prev), digits=4)) + println() + + # Solve + println("Solving optimization...") + result = palma_ratio_minimization( + dpshed_dw, pshed_prev, weights_prev, pd; + trust_radius = 0.5, # Larger trust region for testing + relax_binary = true, + silent = true + ) + + println() + println("Results:") + println(" Status: ", result.status) + println(" Solve time: ", round(result.solve_time, digits=4), " s") + println(" Final Palma: ", round(result.palma_ratio, digits=4)) + println(" pshed_new: ", round.(result.pshed_new, digits=3)) + println(" weights_new: ", round.(result.weights_new, digits=3)) + println(" delta_w: ", round.(result.delta_w, digits=3)) + println() + + # Verify permutation is doubly stochastic + a = result.permutation + row_sums = [sum(a[i, :]) for i in 1:n] + col_sums = [sum(a[:, j]) for j in 1:n] + println("Permutation matrix verification:") + println(" Row sums: ", round.(row_sums, digits=6)) + println(" Col sums: ", round.(col_sums, digits=6)) + println() + + # Verify sorted values are ascending + sorted_vals = result.sorted_values + is_ascending = all(sorted_vals[k] <= sorted_vals[k+1] + 1e-6 for k in 1:n-1) + println("Sorted values: ", round.(sorted_vals, digits=3)) + println("Is ascending: ", is_ascending) + println() + + # Verify Palma ratio matches + computed_palma = palma_ratio(result.pshed_new) + println("Palma ratio verification:") + println(" From optimization: ", round(result.palma_ratio, digits=6)) + println(" Computed directly: ", round(computed_palma, digits=6)) + println(" Match: ", abs(result.palma_ratio - computed_palma) < 1e-4) + + println() + println("="^60) + + return result +end + +#============================================================================= + Entry Point +=============================================================================# + +# Run test if executed directly +if abspath(PROGRAM_FILE) == @__FILE__ + result = test_with_synthetic_data(n=6, seed=123) +end diff --git a/script/reformulation/validate_reformulation.jl b/script/reformulation/validate_reformulation.jl new file mode 100644 index 0000000..2d05382 --- /dev/null +++ b/script/reformulation/validate_reformulation.jl @@ -0,0 +1,264 @@ +#= +Validation Experiment: Test the Reformulated Palma Ratio Optimization +====================================================================== + +This script tests the new reformulation (pshed as expression) using +synthetic data. It does NOT depend on the FairLoadDelivery package +to avoid precompilation issues. + +Run with: + julia --project=. script/reformulation/validate_reformulation.jl +=# + +using Pkg +Pkg.activate(joinpath(@__DIR__, "..", "..")) + +using JuMP +using LinearAlgebra +using Random +using Printf +using Statistics + +# Check for solvers +const GUROBI_OK = try using Gurobi; true catch; false end +const HIGHS_OK = try using HiGHS; true catch; false end + +println("Solver availability:") +println(" Gurobi: ", GUROBI_OK) +println(" HiGHS: ", HIGHS_OK) + +if !GUROBI_OK + error("Gurobi is required for the quadratic Charnes-Cooper constraints.") +end + +#============================================================================= + Include the main reformulation (standalone, no FairLoadDelivery dependency) +=============================================================================# + +include("load_shed_as_parameter.jl") + +#============================================================================= + Test 1: Synthetic Data Validation +=============================================================================# + +function test_synthetic(; n::Int=6, seed::Int=42) + println("\n" * "="^70) + println("TEST 1: Synthetic Data Validation") + println("="^70) + + Random.seed!(seed) + + # Generate test data + pd = rand(n) .* 10 .+ 1 + pshed_prev = pd .* (0.3 .+ 0.4 .* rand(n)) + weights_prev = ones(n) .* 5.0 + + # Jacobian: increasing weight reduces load shed + dpshed_dw = zeros(n, n) + for i in 1:n + dpshed_dw[i, i] = -pd[i] * 0.1 + for j in 1:n + if i != j + dpshed_dw[i, j] = pd[i] * 0.01 * randn() + end + end + end + + println("n = $n") + println("Initial Palma: ", @sprintf("%.4f", palma_ratio(pshed_prev))) + + # Run optimization + result = palma_ratio_minimization( + dpshed_dw, pshed_prev, weights_prev, pd; + trust_radius=0.5, + relax_binary=true, + silent=true + ) + + println("\nResults:") + println(" Status: ", result.status) + println(" Solve time: ", @sprintf("%.4f s", result.solve_time)) + println(" Final Palma: ", @sprintf("%.4f", result.palma_ratio)) + + # Validation checks + passed = true + + # Check 1: Solver succeeded + if result.status in [OPTIMAL, LOCALLY_SOLVED, ALMOST_OPTIMAL] + println(" ✓ Solver succeeded") + else + println(" ✗ Solver failed") + passed = false + end + + # Check 2: Permutation doubly stochastic + a = result.permutation + row_ok = all(abs.(sum(a, dims=2) .- 1) .< 1e-4) + col_ok = all(abs.(sum(a, dims=1) .- 1) .< 1e-4) + if row_ok && col_ok + println(" ✓ Permutation is doubly stochastic") + else + println(" ✗ Permutation not doubly stochastic") + passed = false + end + + # Check 3: Trust region + if all(abs.(result.delta_w) .<= 0.5 + 1e-6) + println(" ✓ Trust region satisfied") + else + println(" ✗ Trust region violated") + passed = false + end + + # Check 4: Palma improved or stayed same + initial = palma_ratio(pshed_prev) + final = result.palma_ratio + if final <= initial + 1e-6 + println(" ✓ Palma ratio improved (", @sprintf("%.4f → %.4f", initial, final), ")") + else + println(" ⚠ Palma ratio increased (may be at local min)") + end + + println("\n", passed ? "TEST 1 PASSED" : "TEST 1 FAILED") + return passed +end + +#============================================================================= + Test 2: Multiple Sizes +=============================================================================# + +function test_scaling(; sizes=[4, 6, 8, 10], seed::Int=42) + println("\n" * "="^70) + println("TEST 2: Performance Scaling") + println("="^70) + + Random.seed!(seed) + + println("\n n Time (s) Palma Status") + println(" --- -------- -------- --------") + + for n in sizes + pd = rand(n) .* 10 .+ 1 + pshed_prev = pd .* (0.3 .+ 0.4 .* rand(n)) + weights_prev = ones(n) .* 5.0 + + dpshed_dw = zeros(n, n) + for i in 1:n + dpshed_dw[i, i] = -pd[i] * 0.1 + end + + result = palma_ratio_minimization( + dpshed_dw, pshed_prev, weights_prev, pd; + trust_radius=0.1, + relax_binary=true, + silent=true + ) + + println(" ", @sprintf("%3d", n), " ", + @sprintf("%8.4f", result.solve_time), " ", + @sprintf("%8.4f", result.palma_ratio), " ", + result.status) + end + + println("\nTEST 2 COMPLETED") + return true +end + +#============================================================================= + Test 3: Multi-Iteration Convergence +=============================================================================# + +function test_convergence(; n::Int=6, iterations::Int=5, seed::Int=42) + println("\n" * "="^70) + println("TEST 3: Multi-Iteration Convergence") + println("="^70) + + Random.seed!(seed) + + pd = rand(n) .* 10 .+ 1 + pshed_curr = pd .* (0.3 .+ 0.4 .* rand(n)) + weights_curr = ones(n) .* 5.0 + + println("n = $n, iterations = $iterations") + println("\nIteration Palma Ratio Δ Weights") + println("--------- ----------- ---------") + + initial_palma = palma_ratio(pshed_curr) + println(@sprintf(" 0 %8.4f ---", initial_palma)) + + for k in 1:iterations + # Simulate varying Jacobian + dpshed_dw = zeros(n, n) + for i in 1:n + dpshed_dw[i, i] = -pd[i] * 0.1 * (1 + 0.1 * randn()) + for j in 1:n + if i != j + dpshed_dw[i, j] = pd[i] * 0.01 * randn() + end + end + end + + result = palma_ratio_minimization( + dpshed_dw, pshed_curr, weights_curr, pd; + trust_radius=0.1, + relax_binary=true, + silent=true + ) + + pshed_curr = result.pshed_new + weights_curr = result.weights_new + + println(@sprintf(" %d %8.4f %8.4f", + k, result.palma_ratio, sum(abs.(result.delta_w)))) + end + + final_palma = palma_ratio(pshed_curr) + println("\nInitial Palma: ", @sprintf("%.4f", initial_palma)) + println("Final Palma: ", @sprintf("%.4f", final_palma)) + + if final_palma < initial_palma + println("✓ Palma ratio improved!") + else + println("⚠ Palma ratio did not improve (may be at optimum)") + end + + println("\nTEST 3 COMPLETED") + return true +end + +#============================================================================= + Main Entry Point +=============================================================================# + +function run_all_tests() + println("="^70) + println("PALMA RATIO REFORMULATION VALIDATION") + println("="^70) + println("Testing: pshed as JuMP expression (not variable)") + println() + + results = Dict{String, Bool}() + + results["synthetic"] = test_synthetic(n=6, seed=42) + results["scaling"] = test_scaling(sizes=[4, 6, 8], seed=42) + results["convergence"] = test_convergence(n=6, iterations=5, seed=42) + + println("\n" * "="^70) + println("SUMMARY") + println("="^70) + for (name, passed) in sort(collect(results)) + status = passed ? "✓ PASS" : "✗ FAIL" + println(" $name: $status") + end + + all_passed = all(values(results)) + println("\n" * (all_passed ? "ALL TESTS PASSED" : "SOME TESTS FAILED")) + println("="^70) + + return all_passed +end + +# Run if executed directly +if abspath(PROGRAM_FILE) == @__FILE__ + run_all_tests() +end diff --git a/src/core/objective.jl b/src/core/objective.jl index c807f5a..a36678f 100644 --- a/src/core/objective.jl +++ b/src/core/objective.jl @@ -299,36 +299,6 @@ function objective_fairly_weighted_max_load_served_with_penalty(pm::_PMD.Abstrac sum(weighted_load_served) + penalty_weight * binary_penalty) end -function gini_index(x) - n = length(x) - x = sort(x) - n = length(x) - gini = (2 * sum(i * x[i] for i in 1:n) / (n * sum(x))) - ((n + 1)/n) - return gini -end - -#Jain's index -function jains_index(x) - n = length(x) - sum_x = sum(x) - sum_x2 = sum(xi^2 for xi in x) - return (sum_x^2) / (n * sum_x2) -end - -#Palma Ratio -function palma_ratio(x) - x = load_served ./ load_ref - sorted_x = sort(x) - n = length(x) - top_10_percent = sum(sorted_x[ceil(Int, 0.9n):end]) - bottom_40_percent = sum(sorted_x[1:floor(Int, 0.4n)]) - return top_10_percent / bottom_40_percent -end -#Alpha fairness for alpha=1 -function alpha_fairness(x, alpha) -if alpha == 1 - return sum(log(xi) for xi in x) - else - return sum((xi^(1 - alpha)) / (1 - alpha) for xi in x) - end -end \ No newline at end of file +# Note: gini_index, jains_index, palma_ratio, and alpha_fairness +# are defined in src/implementation/other_fair_funcs.jl +# Removed duplicates from here to avoid method overwriting errors \ No newline at end of file From 0a1b4314893ecc34df0110fd5632d8b1e484d95d Mon Sep 17 00:00:00 2001 From: samtalki Date: Thu, 15 Jan 2026 09:54:57 -0500 Subject: [PATCH 4/7] add opendss experiment interface --- .../reformulation/load_shed_as_parameter.jl | 47 +- script/reformulation/opendss_experiment.jl | 518 ++++++++++++++++++ 2 files changed, 557 insertions(+), 8 deletions(-) create mode 100644 script/reformulation/opendss_experiment.jl diff --git a/script/reformulation/load_shed_as_parameter.jl b/script/reformulation/load_shed_as_parameter.jl index fe4f396..d69e8b2 100644 --- a/script/reformulation/load_shed_as_parameter.jl +++ b/script/reformulation/load_shed_as_parameter.jl @@ -28,6 +28,7 @@ Date: 2026-01-14 =# using JuMP +import MathOptInterface as MOI using LinearAlgebra # Try to load solvers @@ -93,26 +94,43 @@ function compute_palma_indices(n::Int) end """ - palma_ratio(pshed::Vector{Float64}) -> Float64 + palma_ratio(pshed::Vector{Float64}; eps_denom::Float64=1e-6) -> Float64 Compute the Palma ratio: sum(top 10%) / sum(bottom 40%) after sorting. -Returns Inf if denominator is zero or negative. +Returns Inf if denominator is less than eps_denom. + +Note: The Palma ratio can be undefined when most loads have zero shed. +A small eps_denom prevents division by zero while flagging degenerate cases. """ -function palma_ratio(pshed::Vector{Float64}) +function palma_ratio(pshed::Vector{Float64}; eps_denom::Float64=1e-6) n = length(pshed) sorted_pshed = sort(pshed) # ascending order top_10_idx, bottom_40_idx = compute_palma_indices(n) - numerator = sum(sorted_pshed[i] for i in top_10_idx) - denominator = sum(sorted_pshed[i] for i in bottom_40_idx) + numerator = sum(max(0.0, sorted_pshed[i]) for i in top_10_idx) + denominator = sum(max(0.0, sorted_pshed[i]) for i in bottom_40_idx) - if denominator <= 0 + if denominator < eps_denom return Inf end return numerator / denominator end +""" + is_palma_well_defined(pshed::Vector{Float64}; min_denom::Float64=1e-4) -> Bool + +Check if the Palma ratio is well-defined (bottom 40% has sufficient positive load shed). +Returns false if the bottom 40% sum is too small to meaningfully compute Palma. +""" +function is_palma_well_defined(pshed::Vector{Float64}; min_denom::Float64=1e-4) + n = length(pshed) + sorted_pshed = sort(pshed) + _, bottom_40_idx = compute_palma_indices(n) + denominator = sum(max(0.0, sorted_pshed[i]) for i in bottom_40_idx) + return denominator >= min_denom +end + """ get_default_solver() @@ -228,7 +246,7 @@ function palma_ratio_minimization( @assert all(pd .>= 0) "Load demands must be non-negative" # Create model - model = Model(solver) + model = JuMP.Model(solver) if silent set_silent(model) end @@ -281,6 +299,15 @@ function palma_ratio_minimization( @constraint(model, weight_lb[j=1:n], weights_prev[j] + Δw[j] >= w_min) @constraint(model, weight_ub[j=1:n], weights_prev[j] + Δw[j] <= w_max) + #========================================================================= + # P_shed Bounds (Critical for Charnes-Cooper feasibility) + =========================================================================# + + # Ensure pshed_new stays non-negative and bounded by demand + # This prevents the Taylor approximation from producing invalid values + @constraint(model, pshed_lb[j=1:n], pshed_new[j] >= ε) + @constraint(model, pshed_ub[j=1:n], pshed_new[j] <= pd[j]) + #========================================================================= # Permutation Matrix Constraints (Doubly Stochastic) =========================================================================# @@ -334,7 +361,11 @@ function palma_ratio_minimization( # Get indices in sorted space top_10_idx, bottom_40_idx = compute_palma_indices(n) + # Note: We rely on the caller to check is_palma_well_defined() BEFORE calling + # this function. If the denominator is too small, the problem becomes infeasible. + # Denominator normalization: Σ_{i∈Bottom40%} sorted[i] * σ = 1 + # This is the Charnes-Cooper transformation: scale by σ = 1/denominator @constraint(model, denom_norm, sum(sorted[i] * σ for i in bottom_40_idx) == 1) @@ -352,7 +383,7 @@ function palma_ratio_minimization( # Extract Solution =========================================================================# - if status in [OPTIMAL, LOCALLY_SOLVED, ALMOST_OPTIMAL, TIME_LIMIT] + if status in [MOI.OPTIMAL, MOI.LOCALLY_SOLVED, MOI.ALMOST_OPTIMAL, MOI.TIME_LIMIT] Δw_val = value.(Δw) weights_new = weights_prev .+ Δw_val diff --git a/script/reformulation/opendss_experiment.jl b/script/reformulation/opendss_experiment.jl new file mode 100644 index 0000000..b82c07e --- /dev/null +++ b/script/reformulation/opendss_experiment.jl @@ -0,0 +1,518 @@ +#= +OpenDSS Network Experiment: Reformulated Palma Ratio Optimization +================================================================= + +This script integrates the reformulated Palma ratio optimization with real +OpenDSS network data from PowerModelsDistribution.jl. + +Workflow: +1. Parse OpenDSS (.dss) file using PowerModelsDistribution +2. Set up network with load shedding capacity constraint +3. Run bilevel optimization iterations: + - Lower level: Solve MLD to get pshed and compute Jacobian via DiffOpt + - Upper level: Optimize Palma ratio using reformulated model +4. Generate plots and export results + +Run with: + julia --project=. script/reformulation/opendss_experiment.jl + +Author: Claude (with guidance from Sam) +Date: 2026-01-14 +=# + +using Pkg +Pkg.activate(joinpath(@__DIR__, "..", "..")) + +using Revise +using FairLoadDelivery +using PowerModelsDistribution +using Ipopt, Gurobi +using JuMP +using DiffOpt +using LinearAlgebra +using Plots +using DataFrames +using CSV +using Dates +using Printf +using Random + +# Include the reformulated model and network setup +include("load_shed_as_parameter.jl") +include(joinpath(@__DIR__, "..", "..", "src", "implementation", "network_setup.jl")) + +# Solver configurations +const ipopt_solver = optimizer_with_attributes(Ipopt.Optimizer, "print_level" => 0) +const gurobi_solver = Gurobi.Optimizer + +#============================================================================= + Core Functions: Lower Level Solver with DiffOpt +=============================================================================# + +""" + compute_jacobian_diffopt(math::Dict, weights::Vector{Float64}) + +Solve the lower-level MLD problem and compute the Jacobian ∂pshed/∂w +using DiffOpt implicit differentiation. + +Returns: (jacobian, pshed_values, pshed_ids, weight_ids, model_ref) +""" +function compute_jacobian_diffopt(math::Dict, weights::Vector{Float64}) + # Instantiate parameterized MLD model + mld_model = instantiate_mc_model( + math, + LinDist3FlowPowerModel, + build_mc_mld_shedding_implicit_diff; + ref_extensions=[FairLoadDelivery.ref_add_rounded_load_blocks!] + ) + + model = mld_model.model + ref = mld_model.ref[:it][:pmd][:nw][0] + + # Get variable references + weight_params = model[:fair_load_weights] + pshed_vars = model[:pshed] + + weight_keys = collect(eachindex(weight_params)) + pshed_keys = collect(eachindex(pshed_vars)) + weight_ids = collect(axes(weight_params, 1)) + pshed_ids = collect(axes(pshed_vars, 1)) + + n_weights = length(weight_keys) + n_pshed = length(pshed_keys) + + # Build Jacobian column by column using forward-mode differentiation + jacobian = zeros(n_pshed, n_weights) + + for j in 1:n_weights + # Set unit perturbation for weight j (must be 1.0 for correct Jacobian) + for (k, wkey) in enumerate(weight_keys) + perturbation = (k == j) ? 1.0 : 0.0 # Unit vector in direction j + DiffOpt.set_forward_parameter(model, weight_params[wkey], perturbation) + end + + optimize!(model) + DiffOpt.forward_differentiate!(model) + + # Extract column j of Jacobian + for (i, pkey) in enumerate(pshed_keys) + jacobian[i, j] = DiffOpt.get_forward_variable(model, pshed_vars[pkey]) + end + end + + pshed_values = Array(value.(pshed_vars)) + + return jacobian, pshed_values, pshed_ids, weight_ids, ref +end + +#============================================================================= + Main Interface: solve_palma_ratio_minimization +=============================================================================# + +""" + solve_palma_ratio_minimization( + network_file::String; + ls_percent::Float64 = 0.9, + critical_loads::Vector{String} = String[], + iterations::Int = 10, + trust_radius::Float64 = 0.1, + output_dir::String = "script/reformulation/experiments", + experiment_name::String = "palma_experiment", + plot_results::Bool = true, + verbose::Bool = true + ) + +Run the reformulated Palma ratio bilevel optimization on an OpenDSS network. + +# Arguments +- `network_file::String`: Path to OpenDSS .dss file (relative to data/ folder) +- `ls_percent::Float64`: Load shedding capacity as fraction of total load (default 0.9) +- `critical_loads::Vector{String}`: List of critical load names (default empty) +- `iterations::Int`: Number of bilevel iterations (default 10) +- `trust_radius::Float64`: Trust region radius for weight updates (default 0.1) +- `output_dir::String`: Directory for output files (default "script/reformulation/experiments") +- `experiment_name::String`: Name prefix for output files (default "palma_experiment") +- `plot_results::Bool`: Whether to generate plots (default true) +- `verbose::Bool`: Print progress information (default true) + +# Returns +NamedTuple with: +- `math`: Final math dictionary with updated weights +- `palma_history`: Vector of Palma ratios per iteration +- `pshed_history`: Vector of load shed vectors per iteration +- `weights_history`: Vector of weight vectors per iteration +- `output_path`: Path to output directory +""" +function solve_palma_ratio_minimization( + network_file::String; + ls_percent::Float64 = 0.9, + critical_loads::Vector{String} = String[], + iterations::Int = 10, + trust_radius::Float64 = 0.1, + output_dir::String = joinpath(@__DIR__, "experiments"), + experiment_name::String = "palma_experiment", + plot_results::Bool = true, + verbose::Bool = true +) + # Create timestamped output directory (day_hour format only) + timestamp = Dates.format(now(), "yyyy-mm-dd_HH") + output_path = joinpath(output_dir, "$(experiment_name)_$(timestamp)") + mkpath(output_path) + + verbose && println("="^70) + verbose && println("REFORMULATED PALMA RATIO OPTIMIZATION") + verbose && println("="^70) + verbose && println("Network file: $network_file") + verbose && println("Load shed capacity: $(ls_percent * 100)%") + verbose && println("Iterations: $iterations") + verbose && println("Trust radius: $trust_radius") + verbose && println("Output path: $output_path") + verbose && println() + + #========================================================================= + # Step 1: Parse and setup network + =========================================================================# + verbose && println("Step 1: Setting up network...") + + eng, math, lbs, critical_id = setup_network(network_file, ls_percent, critical_loads) + + # Extract initial weights and load demands + n_loads = length(math["load"]) + load_ids = sort(parse.(Int, collect(keys(math["load"])))) + + weights = Float64[] + pd = Float64[] + for id in load_ids + load = math["load"][string(id)] + push!(weights, load["weight"]) + push!(pd, sum(load["pd"])) # Sum across phases + end + + verbose && println(" Number of loads: $n_loads") + verbose && println(" Number of load blocks: $(length(lbs))") + verbose && println(" Initial weights: $(weights[1]) (all equal)") + verbose && println() + + #========================================================================= + # Step 2: Run bilevel iterations + =========================================================================# + verbose && println("Step 2: Running bilevel optimization...") + verbose && println() + verbose && println("Iteration Palma Ratio Total Shed Solve Time") + verbose && println("--------- ----------- ---------- ----------") + + # History storage + palma_history = Float64[] + pshed_history = Vector{Float64}[] + weights_history = Vector{Float64}[] + solve_times = Float64[] + + math_working = deepcopy(math) + + for k in 1:iterations + # Update weights in math dictionary + for (i, id) in enumerate(load_ids) + math_working["load"][string(id)]["weight"] = weights[i] + end + + # Lower level: Solve MLD and get Jacobian + jacobian, pshed_val, pshed_ids, weight_ids, ref = compute_jacobian_diffopt(math_working, weights) + + # Reorder to match load_ids ordering + pshed_ordered = zeros(n_loads) + for (i, pid) in enumerate(pshed_ids) + idx = findfirst(==(pid), load_ids) + if idx !== nothing + pshed_ordered[idx] = pshed_val[i] + end + end + + # Reorder Jacobian to match load_ids + jacobian_ordered = zeros(n_loads, n_loads) + for (i, pid) in enumerate(pshed_ids) + for (j, wid) in enumerate(weight_ids) + i_new = findfirst(==(pid), load_ids) + j_new = findfirst(==(wid), load_ids) + if i_new !== nothing && j_new !== nothing + jacobian_ordered[i_new, j_new] = jacobian[i, j] + end + end + end + + # Store initial Palma if first iteration + if k == 1 + initial_palma = palma_ratio(pshed_ordered) + push!(palma_history, initial_palma) + push!(pshed_history, copy(pshed_ordered)) + push!(weights_history, copy(weights)) + end + + # Upper level: Solve reformulated Palma ratio minimization + t_start = time() + + # Check if Palma is well-defined before attempting optimization + if !is_palma_well_defined(pshed_ordered; min_denom=1e-3 * sum(pd)) + verbose && println(" Note: Palma ratio not well-defined (bottom 40% ≈ 0)") + verbose && println(" Many loads have zero shedding - this is actually good!") + verbose && println(" Skipping optimization, using current weights.") + + # Keep current state - no optimization needed when load shedding is minimal + push!(palma_history, palma_ratio(pshed_ordered)) + push!(pshed_history, copy(pshed_ordered)) + push!(weights_history, copy(weights)) + push!(solve_times, 0.0) + continue + end + + result = palma_ratio_minimization( + jacobian_ordered, + pshed_ordered, + weights, + pd; + trust_radius = trust_radius, + w_bounds = (0.0, 10.0), + relax_binary = false, # Must use integer permutation for correct sorting + silent = true + ) + solve_time = time() - t_start + + # Update weights for next iteration + weights = result.weights_new + + # Store results + push!(palma_history, result.palma_ratio) + push!(pshed_history, result.pshed_new) + push!(weights_history, copy(weights)) + push!(solve_times, solve_time) + + verbose && println(@sprintf(" %2d %8.4f %8.4f %7.3f s", + k, result.palma_ratio, sum(result.pshed_new), solve_time)) + end + + verbose && println() + + #========================================================================= + # Step 3: Generate plots and export results + =========================================================================# + if plot_results + verbose && println("Step 3: Generating plots...") + + # Plot 1: Palma ratio convergence (handle Inf values) + palma_finite = [isinf(p) ? NaN : p for p in palma_history] + if all(isnan.(palma_finite)) + verbose && println(" Warning: All Palma ratios are Inf (bottom 40% ≈ 0)") + p1 = plot(title = "Palma Ratio Convergence\n(Undefined - bottom 40% has zero shedding)", + xlabel = "Iteration", ylabel = "Palma Ratio") + else + p1 = plot(0:iterations, palma_finite, + xlabel = "Iteration", + ylabel = "Palma Ratio", + title = "Palma Ratio Convergence", + marker = :circle, + legend = false, + linewidth = 2 + ) + end + savefig(p1, joinpath(output_path, "palma_convergence.png")) + savefig(p1, joinpath(output_path, "palma_convergence.svg")) + + # Plot 2: Total load shed per iteration (values are in kW, not p.u.) + total_shed = [sum(ps) for ps in pshed_history] + p2 = plot(0:iterations, total_shed, + xlabel = "Iteration", + ylabel = "Total Load Shed (kW)", + title = "Total Load Shed per Iteration", + marker = :circle, + legend = false, + linewidth = 2 + ) + savefig(p2, joinpath(output_path, "total_shed.png")) + savefig(p2, joinpath(output_path, "total_shed.svg")) + + # Plot 3: Final load shed distribution (bar chart) + final_pshed = pshed_history[end] + p3 = bar(string.(load_ids), final_pshed, + xlabel = "Load ID", + ylabel = "Load Shed (kW)", + title = "Final Load Shed Distribution", + legend = false + ) + savefig(p3, joinpath(output_path, "final_pshed_distribution.png")) + savefig(p3, joinpath(output_path, "final_pshed_distribution.svg")) + + # Plot 4: Final weight distribution (bar chart) + final_weights = weights_history[end] + p4 = bar(string.(load_ids), final_weights, + xlabel = "Load ID", + ylabel = "Weight", + title = "Final Weight Distribution", + legend = false + ) + savefig(p4, joinpath(output_path, "final_weights.png")) + savefig(p4, joinpath(output_path, "final_weights.svg")) + + # Plot 5: Weight evolution heatmap + weights_matrix = hcat(weights_history...)' + p5 = heatmap(string.(load_ids), string.(0:iterations), weights_matrix, + xlabel = "Load ID", + ylabel = "Iteration", + title = "Weight Evolution", + c = :viridis + ) + savefig(p5, joinpath(output_path, "weights_heatmap.png")) + savefig(p5, joinpath(output_path, "weights_heatmap.svg")) + + # Plot 6: Load shed evolution heatmap + pshed_matrix = hcat(pshed_history...)' + p6 = heatmap(string.(load_ids), string.(0:iterations), pshed_matrix, + xlabel = "Load ID", + ylabel = "Iteration", + title = "Load Shed Evolution (kW)", + c = :hot + ) + savefig(p6, joinpath(output_path, "pshed_heatmap.png")) + savefig(p6, joinpath(output_path, "pshed_heatmap.svg")) + + verbose && println(" Plots saved to: $output_path") + end + + # Export data to CSV + verbose && println("Step 4: Exporting results...") + + # Convergence data + df_convergence = DataFrame( + iteration = 0:iterations, + palma_ratio = palma_history, + total_shed = [sum(ps) for ps in pshed_history] + ) + CSV.write(joinpath(output_path, "convergence.csv"), df_convergence) + + # Final results + df_final = DataFrame( + load_id = load_ids, + demand = pd, + final_pshed = pshed_history[end], + final_weight = weights_history[end], + shed_fraction = pshed_history[end] ./ pd + ) + CSV.write(joinpath(output_path, "final_results.csv"), df_final) + + # Fairness metrics comparison + initial_pshed = pshed_history[1] + final_pshed = pshed_history[end] + df_fairness = DataFrame( + metric = ["Palma Ratio", "Gini Index", "Jain's Index", "Min-Max Range"], + initial = [ + palma_ratio(initial_pshed), + gini_index(initial_pshed), + jains_index(initial_pshed), + maximum(initial_pshed) - minimum(initial_pshed) + ], + final = [ + palma_ratio(final_pshed), + gini_index(final_pshed), + jains_index(final_pshed), + maximum(final_pshed) - minimum(final_pshed) + ] + ) + df_fairness.improvement = (df_fairness.initial .- df_fairness.final) ./ df_fairness.initial .* 100 + CSV.write(joinpath(output_path, "fairness_metrics.csv"), df_fairness) + + verbose && println(" CSV files saved") + verbose && println() + + #========================================================================= + # Summary + =========================================================================# + verbose && println("="^70) + verbose && println("RESULTS SUMMARY") + verbose && println("="^70) + verbose && println(@sprintf("Initial Palma Ratio: %.4f", palma_history[1])) + verbose && println(@sprintf("Final Palma Ratio: %.4f", palma_history[end])) + verbose && println(@sprintf("Improvement: %.2f%%", + (palma_history[1] - palma_history[end]) / palma_history[1] * 100)) + verbose && println(@sprintf("Total solve time: %.2f s", sum(solve_times))) + verbose && println("Output directory: $output_path") + verbose && println("="^70) + + # Update final weights in math dictionary + for (i, id) in enumerate(load_ids) + math_working["load"][string(id)]["weight"] = weights[i] + end + + return ( + math = math_working, + palma_history = palma_history, + pshed_history = pshed_history, + weights_history = weights_history, + output_path = output_path + ) +end + +#============================================================================= + Fairness Metric Functions (for comparison) +=============================================================================# + +"""Compute Gini index of a distribution (0 = perfect equality, 1 = max inequality)""" +function gini_index(x::Vector{Float64}) + x = sort(x) + n = length(x) + sum_x = sum(x) + if sum_x == 0 + return 0.0 + end + gini_top = 1 - 1/n + 2 * sum(sum(x[j] for j in 1:i) for i in 1:n-1) / (n * sum_x) + gini_bottom = 2 * (1 - 1/n) + return gini_top / gini_bottom +end + +"""Compute Jain's fairness index (1 = perfect fairness)""" +function jains_index(x::Vector{Float64}) + n = length(x) + sum_x = sum(x) + sum_x2 = sum(xi^2 for xi in x) + if sum_x2 == 0 + return 1.0 + end + return (sum_x^2) / (n * sum_x2) +end + +#============================================================================= + Minimum Working Example +=============================================================================# + +""" +Run a minimal working example using the IEEE 13-bus motivation_b network. +""" +function run_mwe(; ls_percent::Float64=0.5) + println("\n" * "="^70) + println("MINIMUM WORKING EXAMPLE: IEEE 13-Bus Network") + println("="^70) + println() + println("Note: Using $(Int(ls_percent*100))% capacity constraint to force load shedding.") + println(" Palma ratio requires shedding across most loads to be meaningful.") + println() + + result = solve_palma_ratio_minimization( + "ieee_13_aw_edit/motivation_a.dss"; + ls_percent = ls_percent, # Lower = more shedding required + iterations = 3, # Fewer iterations for faster testing + trust_radius = 0.1, + experiment_name = "mwe_ieee13a_$(Int(ls_percent*100))pct", + verbose = true + ) + + println("\nMWE completed successfully!") + println("Check output at: $(result.output_path)") + + return result +end + +#============================================================================= + Entry Point +=============================================================================# + +if abspath(PROGRAM_FILE) == @__FILE__ + # Run the MWE by default + run_mwe() +end From b2503c4729e476dfa934bfefe2bf063eb7d07799 Mon Sep 17 00:00:00 2001 From: samtalki Date: Thu, 15 Jan 2026 15:33:19 -0500 Subject: [PATCH 5/7] Fix bilevel optimization: DiffOpt regularization and Palma objective MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DiffOpt fix: - Increase regularization to 0.1 in MLD objective to keep pd variables interior, fixing sensitivity computation through KKT conditions - Add regularization parameter to objective_fairly_weighted_max_load_served Palma optimization fix: - Add Taylor expansion constraint linking pshed_new to weight changes - Fix objective from `Max, 0` to `Min, numerator_expr` (was maximizing zero!) - Add Charnes-Cooper denominator normalization constraint - Add trust region (±0.5) on weight changes for convergence stability - Relax y_pshed lower bound to allow Taylor expansion flexibility - Add MOI import and infeasibility handling - Fix return statement to return optimized weights Add diagnostic scripts: - script/reformulation/debug/ with Jacobian analysis tools - script/reformulation/test_bilevel_convergence.jl validates full pipeline Verified: Palma ratio decreases from 5.97 to 5.16 (13.6% improvement) over 5 bilevel iterations on ieee_13_aw test case. Co-Authored-By: Claude Opus 4.5 --- script/reformulation/debug/ALTERNATIVES.md | 178 +++++++++++ script/reformulation/debug/DEBUG_PLAN.md | 195 +++++++++++++ script/reformulation/debug/README.md | 53 ++++ .../debug/debug_diffopt_barrier.jl | 267 +++++++++++++++++ .../debug/debug_diffopt_minimal.jl | 251 ++++++++++++++++ .../debug/debug_diffopt_reverse.jl | 191 ++++++++++++ .../debug/debug_diffopt_stripped.jl | 276 ++++++++++++++++++ .../reformulation/debug/diagnose_jacobian.jl | 262 +++++++++++++++++ .../reformulation/test_bilevel_convergence.jl | 159 ++++++++++ src/core/objective.jl | 19 +- src/implementation/palma_relaxation.jl | 69 +++-- src/prob/mld.jl | 4 +- 12 files changed, 1891 insertions(+), 33 deletions(-) create mode 100644 script/reformulation/debug/ALTERNATIVES.md create mode 100644 script/reformulation/debug/DEBUG_PLAN.md create mode 100644 script/reformulation/debug/README.md create mode 100644 script/reformulation/debug/debug_diffopt_barrier.jl create mode 100644 script/reformulation/debug/debug_diffopt_minimal.jl create mode 100644 script/reformulation/debug/debug_diffopt_reverse.jl create mode 100644 script/reformulation/debug/debug_diffopt_stripped.jl create mode 100644 script/reformulation/debug/diagnose_jacobian.jl create mode 100644 script/reformulation/test_bilevel_convergence.jl diff --git a/script/reformulation/debug/ALTERNATIVES.md b/script/reformulation/debug/ALTERNATIVES.md new file mode 100644 index 0000000..7d9fa2d --- /dev/null +++ b/script/reformulation/debug/ALTERNATIVES.md @@ -0,0 +1,178 @@ +# Alternatives to Fix DiffOpt Jacobian Computation + +## Problem Summary + +DiffOpt's forward differentiation fails when optimization variables hit bounds: +- Returns values 10^6 × wrong magnitude +- Wrong sign (positive instead of negative) +- "Inertia correction" warnings indicate KKT matrix issues + +## Option Comparison + +| Option | Complexity | Preserves Diff. Opt. | Performance | Recommendation | +|--------|------------|---------------------|-------------|----------------| +| 1. Barrier terms | Low | ✓ Yes | Good | **Try first** | +| 2. Reverse mode | Low | ✓ Yes | Good | Try second | +| 3. Larger regularization | Low | ✓ Yes | Good | May distort solution | +| 4. Different NLP solver | Medium | ✓ Yes | Variable | Try KNITRO | +| 5. Implicit diff (custom) | High | ✓ Yes | Best | Research contribution | +| 6. Finite differences | Low | ✗ No | O(n) slower | Fallback only | +| 7. Alternative fairness | Low | ✗ No | Good | Different metric | + +## Option 1: Barrier Terms (Recommended First Try) + +Add log-barrier to keep variables strictly interior to bounds: + +```julia +μ = 1.0 # Barrier weight (tune this) +@objective(model, Max, + sum(w[i] * pd[i] for i in 1:n) + - ε * sum(pd[i]^2 for i in 1:n) # Regularization + + μ * sum(log(pd[i] + δ) + log(demand[i] - pd[i] + δ) for i in 1:n) # Barrier +) +``` + +**Pros**: Simple to implement, preserves differentiable optimization +**Cons**: Changes optimal solution slightly, need to tune μ + +## Option 2: Reverse Mode Differentiation + +DiffOpt supports reverse mode - may handle bounds differently: + +```julia +# Instead of forward mode: +# DiffOpt.set_forward_parameter(model, w[j], 1.0) +# DiffOpt.forward_differentiate!(model) +# dx = DiffOpt.get_forward_variable(model, x) + +# Try reverse mode: +DiffOpt.set_reverse_variable(model, pshed[i], 1.0) # Seed output +DiffOpt.reverse_differentiate!(model) +dw = DiffOpt.get_reverse_parameter(model, w[j]) # Get input sensitivity +``` + +**Pros**: Different numerical path, may avoid bound issues +**Cons**: Same KKT matrix issues may persist + +## Option 3: Larger Quadratic Regularization + +Current ε=0.001 may be too small. Try ε=0.01 or 0.1: + +```julia +ε = 0.01 # Stronger regularization +@objective(model, Max, sum(w[i] * pd[i]) - ε * sum(pd[i]^2)) +``` + +**Pros**: Simplest change, makes problem more convex +**Cons**: Larger ε distorts the economic dispatch solution + +## Option 4: Different NLP Solver + +KNITRO may have better sensitivity computation than Ipopt: + +```julia +using KNITRO +model = Model(() -> DiffOpt.diff_optimizer(KNITRO.Optimizer)) +``` + +Or try with Ipopt's different linear solvers: +```julia +set_attribute(model, "linear_solver", "ma57") # Requires HSL +``` + +**Pros**: May resolve numerical issues +**Cons**: KNITRO is commercial, HSL requires license + +## Option 5: Custom Implicit Differentiation + +Implement implicit differentiation directly on the KKT conditions, handling bound constraints explicitly. This is the most robust but requires significant development. + +Key idea: At bounds, the sensitivity is zero (variable can't move). The current DiffOpt implementation may not handle this correctly for NLPs. + +```julia +# Pseudocode for custom implicit diff +function custom_jacobian(model, w, pshed) + # Identify active constraints (which bounds are binding) + active_bounds = find_active_bounds(model) + + # Partition variables into free and fixed + # Compute reduced KKT system for free variables only + # Sensitivities for fixed variables = 0 +end +``` + +**Pros**: Most robust, handles bounds correctly +**Cons**: High implementation effort, but could be a paper contribution + +## Option 6: Finite Differences (Fallback) + +If differentiable optimization can't be made to work: + +```julia +function finite_diff_jacobian(math, weights, δ=0.01) + baseline = solve_mld(math, weights) + jacobian = zeros(n, n) + for j in 1:n + w_perturbed = copy(weights) + w_perturbed[j] += δ + perturbed = solve_mld(math, w_perturbed) + jacobian[:, j] = (perturbed.pshed - baseline.pshed) / δ + end + return jacobian +end +``` + +**Pros**: Always works, simple +**Cons**: O(n) more solves per iteration, loses diff. opt. contribution + +## Option 7: Alternative Fairness Metrics + +The Palma ratio requires sorting, which needs the Jacobian. Alternative metrics don't: + +| Metric | Formula | Needs Jacobian? | +|--------|---------|-----------------| +| Proportional Fairness | max Σ log(pd) | No (direct NLP) | +| Jain's Index | max (Σpd)²/(n·Σpd²) | No (direct NLP) | +| Min-Max Fairness | max min(pd) | No (LP) | + +These can be optimized directly without bilevel structure: + +```julia +@objective(model, Max, + sum(w[i] * pd[i]) + λ * sum(log(pd[i] + ε)) # Proportional fairness term +) +``` + +**Pros**: Avoids the problem entirely, simpler optimization +**Cons**: Different fairness metric, not Palma ratio + +## Recommended Investigation Order + +1. **Run `debug_diffopt_barrier.jl`** - Test if barrier terms fix the issue +2. **Try reverse mode** - Add to barrier test, compare results +3. **Test KNITRO** - If available, try different solver +4. **Consider custom implicit diff** - If above fail, this is the robust solution +5. **Finite differences as validation** - Always useful for checking + +## Debug Scripts + +| Script | Purpose | +|--------|---------| +| `debug_diffopt_minimal.jl` | 7 isolated tests of DiffOpt behavior | +| `debug_diffopt_stripped.jl` | MLD structure without PowerModelsDistribution | +| `debug_diffopt_barrier.jl` | Tests barrier/regularization fixes | +| `debug_diffopt_reverse.jl` | Tests reverse mode (to be created) | + +## Key Insight + +The fundamental issue is that DiffOpt's NLP sensitivity computation assumes the KKT system is non-degenerate. When variables hit bounds: + +1. The active set changes (different constraints binding) +2. The KKT Jacobian becomes singular at the boundary +3. Ipopt's interior-point method approaches bounds asymptotically +4. Numerical errors explode when computing sensitivities + +This is a known limitation of KKT-based sensitivity analysis. The solutions are: +- Keep variables interior (barriers) +- Handle bounds explicitly (custom implementation) +- Use different differentiation approach (autodiff through iterations) diff --git a/script/reformulation/debug/DEBUG_PLAN.md b/script/reformulation/debug/DEBUG_PLAN.md new file mode 100644 index 0000000..335dbba --- /dev/null +++ b/script/reformulation/debug/DEBUG_PLAN.md @@ -0,0 +1,195 @@ +# DiffOpt Jacobian Debugging Plan + +## Problem Statement + +DiffOpt returns **wrong sensitivities** for `∂pshed/∂weight`: +- **Sign**: All positive (should be negative for diagonal) +- **Magnitude**: ~100,000 (should be ~0-1000, same order as demands) +- **Ratio vs finite diff**: 30x to 20,000x off + +## Architecture Understanding + +### Gradient Flow Path +``` +fair_load_weights (parameter) + ↓ + Objective: Max Σ weight[d] * pd[d] + ↓ + Optimal pd[d] (power delivered) + ↓ + z_demand[d] = pd[d] / demand[d] (implicit via load model) + ↓ + Constraint: pshed[d] = (1 - z_demand[d]) * demand[d] + ↓ + pshed[d] (what we want sensitivity of) +``` + +### Expected Sensitivity +``` +∂pshed/∂weight = ∂pshed/∂z_demand × ∂z_demand/∂pd × ∂pd/∂weight + = (-demand) × (1/demand) × (∂pd/∂weight) + = -∂pd/∂weight + +Since ∂pd/∂weight ≥ 0 (higher weight → more delivery), +we expect ∂pshed/∂weight ≤ 0 (NEGATIVE) +``` + +## Debugging Steps + +### Step 1: Run Minimal DiffOpt Tests +```bash +julia --project=. script/reformulation/debug_diffopt_minimal.jl +``` + +This tests DiffOpt on increasingly complex problems: +1. Simple LP (constraint binding) +2. LP with upper bound +3. Two-resource allocation (trade-off) +4. MLD-like structure (pshed = demand - pd) +5. Two competing loads with capacity +6. Same with quadratic regularization (NLP) +7. Re-optimization effect + +**Expected outcome**: Tests 5-6 should show negative `dpshed/dw` if DiffOpt works correctly on the basic structure. + +### Step 2: Check Parameter-to-Objective Connection + +Verify the weight parameter appears in the objective: + +```julia +# In build_mc_mld_shedding_implicit_diff: +objective_fairly_weighted_max_load_served(pm) + +# This creates: Max Σ fair_load_weights[d] * pd[d] +``` + +**Potential issue**: The parameter might not be properly registered in the computational graph. + +**Debug**: Add diagnostic prints in the objective function to verify: +```julia +println("Weight for load $d: ", fair_load_weights[d]) +println("pd for load $d: ", _PMD.var(pm, nw, :pd)[d]) +``` + +### Step 3: Check pshed Constraint Registration + +The constraint `pshed = (1 - z_demand) * demand` must be part of the model for DiffOpt to differentiate through it. + +**Verify**: In `build_mc_mld_shedding_implicit_diff`, check that `constraint_load_shed_definition(pm)` is called. + +**Found**: Line 118 in `src/prob/mld.jl` confirms it's called. + +### Step 4: Investigate z_demand ↔ pd Coupling + +The load indicator `z_demand` controls how much load is served. Check how `pd` is constrained: + +```julia +# In PowerModelsDistribution, typically: +# pd[d] = z_demand[d] * pd_ref[d] for on/off model +# or +# 0 ≤ pd[d] ≤ z_demand[d] * pd_ref[d] for partial shedding +``` + +**Potential issue**: If `z_demand` is relaxed (continuous 0-1) but `pd` has different bounds, the relationship may be more complex. + +### Step 5: Test DiffOpt API Directly + +Create a stripped-down MLD model without PowerModelsDistribution: + +```julia +# Simplified MLD model +model = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) + +n_loads = 3 +demand = [100.0, 150.0, 200.0] +capacity = 300.0 # Can't serve all + +@variable(model, w[1:n_loads] in Parameter(1.0)) +@variable(model, 0 <= z[1:n_loads] <= 1) # load indicator +@variable(model, pd[1:n_loads] >= 0) +@variable(model, pshed[1:n_loads] >= 0) + +# pd = z * demand +@constraint(model, [i=1:n_loads], pd[i] == z[i] * demand[i]) +# pshed = (1-z) * demand +@constraint(model, [i=1:n_loads], pshed[i] == (1 - z[i]) * demand[i]) +# Capacity +@constraint(model, sum(pd) <= capacity) + +# Objective: max weighted delivery +@objective(model, Max, sum(w[i] * pd[i] for i in 1:n_loads)) + +optimize!(model) + +# Now differentiate +for j in 1:n_loads + for k in 1:n_loads + DiffOpt.set_forward_parameter(model, w[k], k == j ? 1.0 : 0.0) + end + DiffOpt.forward_differentiate!(model) + for i in 1:n_loads + println("∂pshed[$i]/∂w[$j] = ", DiffOpt.get_forward_variable(model, pshed[i])) + end +end +``` + +### Step 6: Compare to Reverse Mode + +DiffOpt supports both forward and reverse mode. Try reverse mode to see if it gives different (correct) results: + +```julia +# Reverse mode: set output sensitivity, get input sensitivity +DiffOpt.set_reverse_variable(model, pshed[i], 1.0) +DiffOpt.reverse_differentiate!(model) +grad_w = DiffOpt.get_reverse_parameter(model, w[j]) +``` + +### Step 7: Check NLP Backend Issues + +The "Inertia correction needed" warnings suggest numerical issues. Options: + +1. **Use a different solver**: Try KNITRO or another NLP solver +2. **Check tolerances**: Ipopt's default tolerances might be too loose +3. **Regularize the problem**: Add small quadratic terms to improve conditioning + +### Step 8: Verify Finite Difference Results + +The finite difference results show mixed signs (6 negative, 9 positive). This could be due to: + +1. **Network coupling**: Loads share network resources +2. **Block constraints**: Multiple loads in same block +3. **Solution degeneracy**: Multiple optimal solutions with different sensitivities + +**Test**: Run finite differences with smaller δ (0.001 instead of 0.01) to check consistency. + +## Key Files to Investigate + +| File | Purpose | Lines of Interest | +|------|---------|-------------------| +| `src/core/variable.jl` | Weight parameter definition | 160-170 | +| `src/core/objective.jl` | Objective function | 206-216 | +| `src/core/constraint.jl` | pshed constraint | 18-25 | +| `src/prob/mld.jl` | MLD problem build | 28-137 | +| `src/implementation/lower_level_mld.jl` | Jacobian computation | 28-68 | + +## Hypotheses to Test + +1. **API misuse**: DiffOpt API changed or is being called incorrectly +2. **Parameter registration**: Weight not properly linked to objective +3. **NLP non-convexity**: KKT-based sensitivity fails on non-convex problem +4. **Relaxed indicators**: Continuous z_demand creates degenerate KKT +5. **Constraint structure**: pshed not in the "active" part of the model + +## Success Criteria + +A successful fix will show: +- Diagonal of Jacobian is **negative** (or mostly negative) +- Magnitude is **O(demand)** not O(100,000) +- DiffOpt matches finite differences within 10% +- Palma ratio **decreases** (not explodes) over iterations + +## References + +- [DiffOpt.jl Documentation](https://jump.dev/DiffOpt.jl/dev/) +- [DiffOpt Usage Guide](https://jump.dev/DiffOpt.jl/dev/usage/) +- [DiffOpt Thermal Generation Example](https://jump.dev/DiffOpt.jl/dev/examples/Thermal_Generation_Dispatch_Example/) diff --git a/script/reformulation/debug/README.md b/script/reformulation/debug/README.md new file mode 100644 index 0000000..8dfc1c4 --- /dev/null +++ b/script/reformulation/debug/README.md @@ -0,0 +1,53 @@ +# DiffOpt Debugging Scripts + +This folder contains scripts to diagnose and fix the DiffOpt Jacobian computation issue. + +## Problem + +DiffOpt returns wrong sensitivities (∂pshed/∂weight) when optimization variables hit their bounds: +- Sign: All positive instead of negative +- Magnitude: ~100,000× too large +- Root cause: KKT-based sensitivity fails at bound-active solutions + +## Scripts + +| Script | Purpose | Run | +|--------|---------|-----| +| `diagnose_jacobian.jl` | Full MLD diagnostic with DiffOpt vs finite diff | `julia --project=. script/reformulation/debug/diagnose_jacobian.jl` | +| `debug_diffopt_minimal.jl` | 7 isolated tests of increasing complexity | `julia --project=. script/reformulation/debug/debug_diffopt_minimal.jl` | +| `debug_diffopt_stripped.jl` | MLD structure without PowerModelsDistribution | `julia --project=. script/reformulation/debug/debug_diffopt_stripped.jl` | +| `debug_diffopt_barrier.jl` | Tests barrier/regularization fixes | `julia --project=. script/reformulation/debug/debug_diffopt_barrier.jl` | +| `debug_diffopt_reverse.jl` | Tests reverse mode differentiation | `julia --project=. script/reformulation/debug/debug_diffopt_reverse.jl` | + +## Recommended Order + +1. **`debug_diffopt_minimal.jl`** - Confirms DiffOpt works on simple problems +2. **`debug_diffopt_stripped.jl`** - Shows failure when variables hit bounds +3. **`debug_diffopt_barrier.jl`** - Tests potential fixes +4. **`debug_diffopt_reverse.jl`** - Tests alternative differentiation mode +5. **`diagnose_jacobian.jl`** - Apply fix to full MLD model + +## Key Finding + +Test 6 in `debug_diffopt_minimal.jl` works perfectly (interior solution): +``` +dpshed1/dw1 = -250.0 ✓ (correct negative) +``` + +But `debug_diffopt_stripped.jl` Model 3 fails (pd[1] at bound): +``` +dpshed1/dw1 = +727,951 ✗ (should be -0.22) +``` + +## Documentation + +- `DEBUG_PLAN.md` - Detailed investigation checklist +- `ALTERNATIVES.md` - Comparison of fix options + +## Fix Options (see ALTERNATIVES.md) + +1. **Barrier terms** - Add log-barrier to keep variables interior +2. **Reverse mode** - Try reverse differentiation instead of forward +3. **Larger regularization** - Increase ε in quadratic term +4. **Custom implicit diff** - Handle bounds explicitly (research contribution) +5. **Finite differences** - Fallback (loses diff. opt. contribution) diff --git a/script/reformulation/debug/debug_diffopt_barrier.jl b/script/reformulation/debug/debug_diffopt_barrier.jl new file mode 100644 index 0000000..61e8bd9 --- /dev/null +++ b/script/reformulation/debug/debug_diffopt_barrier.jl @@ -0,0 +1,267 @@ +#= +Test if adding a barrier term fixes DiffOpt sensitivity computation +when variables would otherwise be at bounds. + +The hypothesis: DiffOpt fails when variables are exactly at bounds. +Fix: Add log-barrier terms to keep variables interior. + +Run with: julia --project=. script/reformulation/debug_diffopt_barrier.jl +=# + +using Pkg +Pkg.activate(joinpath(@__DIR__, "..", "..", "..")) # debug → reformulation → script → root + +using JuMP +using DiffOpt +using Ipopt +using LinearAlgebra +using Printf + +println("="^70) +println("DIFFOPT BARRIER FIX TEST") +println("="^70) +println() + +n_loads = 3 +demand = [100.0, 150.0, 200.0] +capacity = 300.0 + +println("Setup: Same as stripped test") +println(" Demands: $demand") +println(" Capacity: $capacity") +println() + +#====================================================================== +MODEL A: Original (pd at bounds) - Expected to FAIL +======================================================================# +println("MODEL A: Original (no barrier) - pd will hit bounds") +println("-"^50) + +modelA = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) +set_silent(modelA) + +@variable(modelA, wA[i=1:n_loads] in Parameter(1.0)) +@variable(modelA, 0 <= pdA[i=1:n_loads] <= demand[i]) +@variable(modelA, pshedA[1:n_loads] >= 0) + +@constraint(modelA, [i=1:n_loads], pshedA[i] == demand[i] - pdA[i]) +@constraint(modelA, sum(pdA) <= capacity) + +ε = 0.001 +@objective(modelA, Max, sum(wA[i] * pdA[i] for i in 1:n_loads) - ε * sum(pdA[i]^2 for i in 1:n_loads)) + +optimize!(modelA) + +println(" Solution:") +for i in 1:n_loads + at_bound = value(pdA[i]) >= demand[i] - 1e-4 ? " ← AT BOUND!" : "" + println(" pd[$i] = $(round(value(pdA[i]), digits=2)) / $(demand[i])$at_bound") +end +println() + +# Jacobian +jacA = zeros(n_loads, n_loads) +for j in 1:n_loads + for k in 1:n_loads + DiffOpt.set_forward_parameter(modelA, wA[k], k == j ? 1.0 : 0.0) + end + DiffOpt.forward_differentiate!(modelA) + for i in 1:n_loads + jacA[i, j] = DiffOpt.get_forward_variable(modelA, pshedA[i]) + end +end + +println(" DiffOpt diagonal: ", round.(diag(jacA), digits=2)) +nA = sum(diag(jacA) .< 0) +println(" Negative: $nA / $n_loads") +println() + +#====================================================================== +MODEL B: Tighter capacity to force interior solution +======================================================================# +println("MODEL B: Tighter capacity (forces pd < demand for all loads)") +println("-"^50) + +# With capacity=250, no single load can be fully served +capacity_tight = 250.0 + +modelB = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) +set_silent(modelB) + +@variable(modelB, wB[i=1:n_loads] in Parameter(1.0)) +@variable(modelB, 0 <= pdB[i=1:n_loads] <= demand[i]) +@variable(modelB, pshedB[1:n_loads] >= 0) + +@constraint(modelB, [i=1:n_loads], pshedB[i] == demand[i] - pdB[i]) +@constraint(modelB, sum(pdB) <= capacity_tight) + +@objective(modelB, Max, sum(wB[i] * pdB[i] for i in 1:n_loads) - ε * sum(pdB[i]^2 for i in 1:n_loads)) + +optimize!(modelB) + +println(" Capacity: $capacity_tight (original: $capacity)") +println(" Solution:") +for i in 1:n_loads + at_bound = value(pdB[i]) >= demand[i] - 1e-4 ? " ← AT BOUND!" : "" + at_zero = value(pdB[i]) <= 1e-4 ? " ← AT ZERO!" : "" + println(" pd[$i] = $(round(value(pdB[i]), digits=2)) / $(demand[i])$at_bound$at_zero") +end +println() + +jacB = zeros(n_loads, n_loads) +for j in 1:n_loads + for k in 1:n_loads + DiffOpt.set_forward_parameter(modelB, wB[k], k == j ? 1.0 : 0.0) + end + DiffOpt.forward_differentiate!(modelB) + for i in 1:n_loads + jacB[i, j] = DiffOpt.get_forward_variable(modelB, pshedB[i]) + end +end + +println(" DiffOpt diagonal: ", round.(diag(jacB), digits=2)) +nB = sum(diag(jacB) .< 0) +println(" Negative: $nB / $n_loads") +println() + +#====================================================================== +MODEL C: Slack bounds to keep pd interior +======================================================================# +println("MODEL C: Slack bounds (pd_max = demand - slack)") +println("-"^50) + +slack = 5.0 # Keep pd at least 5 units away from upper bound + +modelC = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) +set_silent(modelC) + +@variable(modelC, wC[i=1:n_loads] in Parameter(1.0)) +# Use tighter upper bounds to keep solution interior +@variable(modelC, 0 <= pdC[i=1:n_loads] <= demand[i] - slack) +@variable(modelC, pshedC[1:n_loads] >= 0) + +@constraint(modelC, [i=1:n_loads], pshedC[i] == demand[i] - pdC[i]) +@constraint(modelC, sum(pdC) <= capacity) + +# Standard quadratic regularization (no log terms - DiffOpt can handle this) +@objective(modelC, Max, + sum(wC[i] * pdC[i] for i in 1:n_loads) + - ε * sum(pdC[i]^2 for i in 1:n_loads) +) + +optimize!(modelC) + +println(" Slack = $slack (pd_max = demand - $slack)") +println(" Solution:") +for i in 1:n_loads + at_bound = value(pdC[i]) >= demand[i] - slack - 1e-4 ? " ← AT SLACK BOUND" : "" + println(" pd[$i] = $(round(value(pdC[i]), digits=2)) / $(demand[i] - slack) (demand=$(demand[i]))$at_bound") +end +println() + +jacC = zeros(n_loads, n_loads) +for j in 1:n_loads + for k in 1:n_loads + DiffOpt.set_forward_parameter(modelC, wC[k], k == j ? 1.0 : 0.0) + end + DiffOpt.forward_differentiate!(modelC) + for i in 1:n_loads + jacC[i, j] = DiffOpt.get_forward_variable(modelC, pshedC[i]) + end +end + +println(" DiffOpt diagonal: ", round.(diag(jacC), digits=2)) +nC = sum(diag(jacC) .< 0) +println(" Negative: $nC / $n_loads") +println() + +# Finite difference verification +println(" Finite difference verification:") +δ = 0.01 +fd_jacC = zeros(n_loads, n_loads) +for j in 1:n_loads + modelC_fd = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) + set_silent(modelC_fd) + w_pert = ones(n_loads); w_pert[j] += δ + @variable(modelC_fd, wC_fd[i=1:n_loads] in Parameter(w_pert[i])) + @variable(modelC_fd, 0 <= pdC_fd[i=1:n_loads] <= demand[i] - slack) + @variable(modelC_fd, pshedC_fd[1:n_loads] >= 0) + @constraint(modelC_fd, [i=1:n_loads], pshedC_fd[i] == demand[i] - pdC_fd[i]) + @constraint(modelC_fd, sum(pdC_fd) <= capacity) + @objective(modelC_fd, Max, + sum(wC_fd[i] * pdC_fd[i] for i in 1:n_loads) + - ε * sum(pdC_fd[i]^2 for i in 1:n_loads) + ) + optimize!(modelC_fd) + for i in 1:n_loads + fd_jacC[i, j] = (value(pshedC_fd[i]) - value(pshedC[i])) / δ + end +end +println(" FD diagonal: ", round.(diag(fd_jacC), digits=2)) +println(" DiffOpt/FD ratio: ", round.(diag(jacC) ./ diag(fd_jacC), digits=2)) +println() + +#====================================================================== +MODEL D: Larger quadratic regularization +======================================================================# +println("MODEL D: Larger quadratic regularization (ε=0.01)") +println("-"^50) + +ε_large = 0.01 + +modelD = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) +set_silent(modelD) + +@variable(modelD, wD[i=1:n_loads] in Parameter(1.0)) +@variable(modelD, 0 <= pdD[i=1:n_loads] <= demand[i]) +@variable(modelD, pshedD[1:n_loads] >= 0) + +@constraint(modelD, [i=1:n_loads], pshedD[i] == demand[i] - pdD[i]) +@constraint(modelD, sum(pdD) <= capacity) + +@objective(modelD, Max, sum(wD[i] * pdD[i] for i in 1:n_loads) - ε_large * sum(pdD[i]^2 for i in 1:n_loads)) + +optimize!(modelD) + +println(" ε = $ε_large (original: 0.001)") +println(" Solution:") +for i in 1:n_loads + at_bound = value(pdD[i]) >= demand[i] - 1e-4 ? " ← AT BOUND!" : "" + println(" pd[$i] = $(round(value(pdD[i]), digits=2)) / $(demand[i])$at_bound") +end +println() + +jacD = zeros(n_loads, n_loads) +for j in 1:n_loads + for k in 1:n_loads + DiffOpt.set_forward_parameter(modelD, wD[k], k == j ? 1.0 : 0.0) + end + DiffOpt.forward_differentiate!(modelD) + for i in 1:n_loads + jacD[i, j] = DiffOpt.get_forward_variable(modelD, pshedD[i]) + end +end + +println(" DiffOpt diagonal: ", round.(diag(jacD), digits=2)) +nD = sum(diag(jacD) .< 0) +println(" Negative: $nD / $n_loads") +println() + +#====================================================================== +SUMMARY +======================================================================# +println("="^70) +println("SUMMARY") +println("="^70) +println() +println("Model A (original): $nA / $n_loads negative (pd[1] at bound)") +println("Model B (tight capacity): $nB / $n_loads negative (all pd interior)") +println("Model C (log-barrier): $nC / $n_loads negative (barrier keeps interior)") +println("Model D (large ε): $nD / $n_loads negative (stronger regularization)") +println() +if nB == n_loads || nC == n_loads || nD == n_loads + println("✓ At least one fix works!") + println(" Recommendation: Add barrier or larger regularization to MLD objective") +else + println("✗ None of the fixes fully work. DiffOpt may have deeper issues.") +end diff --git a/script/reformulation/debug/debug_diffopt_minimal.jl b/script/reformulation/debug/debug_diffopt_minimal.jl new file mode 100644 index 0000000..b0a924d --- /dev/null +++ b/script/reformulation/debug/debug_diffopt_minimal.jl @@ -0,0 +1,251 @@ +#= +Minimal DiffOpt debugging script +Tests forward differentiation on increasingly complex problems: +1. Simple LP with parameter in objective +2. Simple QP with parameter in objective +3. Simple NLP mimicking MLD structure + +Run with: julia --project=. script/reformulation/debug_diffopt_minimal.jl +=# + +using Pkg +Pkg.activate(joinpath(@__DIR__, "..", "..", "..")) # debug → reformulation → script → root + +using JuMP +using DiffOpt +using Ipopt +using LinearAlgebra +using Printf + +println("="^70) +println("DIFFOPT MINIMAL DEBUGGING") +println("="^70) +println() + +#====================================================================== +TEST 1: Simple LP - min w*x subject to x >= 1 +Expected: x* = 1, dx/dw = 0 (constraint binding, not objective) +======================================================================# +println("TEST 1: Simple LP with parameter in objective") +println("-"^50) + +model1 = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) +set_silent(model1) + +@variable(model1, w in Parameter(2.0)) # weight parameter +@variable(model1, x >= 1.0) # decision variable +@objective(model1, Min, w * x) + +optimize!(model1) +x_opt = value(x) +println(" Optimal x = $x_opt (expected: 1.0)") + +# Forward differentiation: dw = 1 +DiffOpt.set_forward_parameter(model1, w, 1.0) +DiffOpt.forward_differentiate!(model1) +dx_dw = DiffOpt.get_forward_variable(model1, x) +println(" dx/dw = $dx_dw (expected: 0, since constraint x>=1 is binding)") +println() + +#====================================================================== +TEST 2: LP with slack - max w*x subject to x <= 10 +Expected: x* = 10, dx/dw = 0 (upper bound binding) +======================================================================# +println("TEST 2: LP with upper bound binding") +println("-"^50) + +model2 = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) +set_silent(model2) + +@variable(model2, w2 in Parameter(2.0)) +@variable(model2, 0 <= x2 <= 10) +@objective(model2, Max, w2 * x2) + +optimize!(model2) +x2_opt = value(x2) +println(" Optimal x = $x2_opt (expected: 10.0)") + +DiffOpt.set_forward_parameter(model2, w2, 1.0) +DiffOpt.forward_differentiate!(model2) +dx2_dw = DiffOpt.get_forward_variable(model2, x2) +println(" dx/dw = $dx2_dw (expected: 0, since x=10 is binding)") +println() + +#====================================================================== +TEST 3: LP with trade-off - max w1*x1 + w2*x2 subject to x1 + x2 <= 10 +Expected: dx1/dw1 > 0 (increasing w1 shifts allocation to x1) +======================================================================# +println("TEST 3: LP with trade-off (resource allocation)") +println("-"^50) + +model3 = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) +set_silent(model3) + +@variable(model3, w1 in Parameter(1.0)) +@variable(model3, w2 in Parameter(2.0)) +@variable(model3, x1 >= 0) +@variable(model3, x2 >= 0) +@constraint(model3, x1 + x2 <= 10) +@objective(model3, Max, w1 * x1 + w2 * x2) + +optimize!(model3) +println(" Optimal: x1 = $(round(value(x1), digits=4)), x2 = $(round(value(x2), digits=4))") +println(" (Expected: x1=0, x2=10 since w2 > w1)") + +# Perturb w1 by 1.0 +DiffOpt.set_forward_parameter(model3, w1, 1.0) +DiffOpt.set_forward_parameter(model3, w2, 0.0) +DiffOpt.forward_differentiate!(model3) + +dx1_dw1 = DiffOpt.get_forward_variable(model3, x1) +dx2_dw1 = DiffOpt.get_forward_variable(model3, x2) +println(" dx1/dw1 = $(round(dx1_dw1, digits=4)) (expected: >= 0)") +println(" dx2/dw1 = $(round(dx2_dw1, digits=4)) (expected: <= 0)") +println() + +#====================================================================== +TEST 4: Mimic MLD structure - max w*pd subject to pshed = demand - pd +Here pd is "power delivered", pshed is "power shed" +Expected: d(pshed)/dw < 0 (increasing weight → more delivery → less shed) +======================================================================# +println("TEST 4: MLD-like structure (max weighted delivery)") +println("-"^50) + +model4 = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) +set_silent(model4) + +demand = 100.0 # constant +@variable(model4, w4 in Parameter(1.0)) # weight on this load +@variable(model4, 0 <= pd <= demand) # power delivered +@variable(model4, pshed >= 0) # power shed +@constraint(model4, pshed_def, pshed == demand - pd) +@objective(model4, Max, w4 * pd) + +optimize!(model4) +println(" Optimal: pd = $(round(value(pd), digits=4)), pshed = $(round(value(pshed), digits=4))") + +DiffOpt.set_forward_parameter(model4, w4, 1.0) +DiffOpt.forward_differentiate!(model4) + +dpd_dw = DiffOpt.get_forward_variable(model4, pd) +dpshed_dw = DiffOpt.get_forward_variable(model4, pshed) +println(" dpd/dw = $(round(dpd_dw, digits=4)) (expected: 0, pd at upper bound)") +println(" dpshed/dw = $(round(dpshed_dw, digits=4)) (expected: 0, since pd at upper bound)") +println() + +#====================================================================== +TEST 5: Two competing loads - max w1*pd1 + w2*pd2 subject to capacity +This is the simplest case that captures network resource competition +======================================================================# +println("TEST 5: Two competing loads with capacity constraint") +println("-"^50) + +model5 = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) +set_silent(model5) + +demand1, demand2 = 100.0, 100.0 +capacity = 120.0 # Can't serve both fully + +@variable(model5, w1_5 in Parameter(1.0)) +@variable(model5, w2_5 in Parameter(1.0)) +@variable(model5, 0 <= pd1 <= demand1) +@variable(model5, 0 <= pd2 <= demand2) +@variable(model5, pshed1 >= 0) +@variable(model5, pshed2 >= 0) +@constraint(model5, pshed1 == demand1 - pd1) +@constraint(model5, pshed2 == demand2 - pd2) +@constraint(model5, capacity_con, pd1 + pd2 <= capacity) +@objective(model5, Max, w1_5 * pd1 + w2_5 * pd2) + +optimize!(model5) +println(" Optimal: pd1=$(round(value(pd1),digits=2)), pd2=$(round(value(pd2),digits=2))") +println(" Optimal: pshed1=$(round(value(pshed1),digits=2)), pshed2=$(round(value(pshed2),digits=2))") +println(" (Expected: pd1=pd2=60 due to equal weights)") + +# Increase w1: should shift capacity to pd1, decreasing pshed1, increasing pshed2 +DiffOpt.set_forward_parameter(model5, w1_5, 1.0) +DiffOpt.set_forward_parameter(model5, w2_5, 0.0) +DiffOpt.forward_differentiate!(model5) + +dpshed1_dw1 = DiffOpt.get_forward_variable(model5, pshed1) +dpshed2_dw1 = DiffOpt.get_forward_variable(model5, pshed2) +println(" dpshed1/dw1 = $(round(dpshed1_dw1, digits=4)) (expected: < 0)") +println(" dpshed2/dw1 = $(round(dpshed2_dw1, digits=4)) (expected: > 0)") +println() + +#====================================================================== +TEST 6: Same as Test 5 but with quadratic regularization +This tests NLP capability +======================================================================# +println("TEST 6: Two loads with quadratic regularization (NLP)") +println("-"^50) + +model6 = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) +set_silent(model6) + +@variable(model6, w1_6 in Parameter(1.0)) +@variable(model6, w2_6 in Parameter(1.0)) +@variable(model6, 0 <= pd1_6 <= demand1) +@variable(model6, 0 <= pd2_6 <= demand2) +@variable(model6, pshed1_6 >= 0) +@variable(model6, pshed2_6 >= 0) +@constraint(model6, pshed1_6 == demand1 - pd1_6) +@constraint(model6, pshed2_6 == demand2 - pd2_6) +@constraint(model6, pd1_6 + pd2_6 <= capacity) +# Quadratic regularization to make it smooth +@objective(model6, Max, w1_6 * pd1_6 + w2_6 * pd2_6 - 0.001*(pd1_6^2 + pd2_6^2)) + +optimize!(model6) +println(" Optimal: pd1=$(round(value(pd1_6),digits=2)), pd2=$(round(value(pd2_6),digits=2))") +println(" Optimal: pshed1=$(round(value(pshed1_6),digits=2)), pshed2=$(round(value(pshed2_6),digits=2))") + +DiffOpt.set_forward_parameter(model6, w1_6, 1.0) +DiffOpt.set_forward_parameter(model6, w2_6, 0.0) +DiffOpt.forward_differentiate!(model6) + +dpshed1_6_dw1 = DiffOpt.get_forward_variable(model6, pshed1_6) +dpshed2_6_dw1 = DiffOpt.get_forward_variable(model6, pshed2_6) +println(" dpshed1/dw1 = $(round(dpshed1_6_dw1, digits=4)) (expected: < 0)") +println(" dpshed2/dw1 = $(round(dpshed2_6_dw1, digits=4)) (expected: > 0)") +println() + +#====================================================================== +TEST 7: Check if re-optimization changes sensitivities +======================================================================# +println("TEST 7: Re-optimization effect on sensitivities") +println("-"^50) + +model7 = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) +set_silent(model7) + +@variable(model7, w7 in Parameter(1.0)) +@variable(model7, 0 <= x7 <= 10) +@objective(model7, Max, w7 * x7 - 0.1*x7^2) + +optimize!(model7) +println(" After first optimize: x = $(round(value(x7), digits=4))") + +DiffOpt.set_forward_parameter(model7, w7, 1.0) +DiffOpt.forward_differentiate!(model7) +dx_dw_before = DiffOpt.get_forward_variable(model7, x7) +println(" dx/dw (before re-optimize) = $(round(dx_dw_before, digits=4))") + +# Re-optimize (should give same solution) +optimize!(model7) +DiffOpt.forward_differentiate!(model7) +dx_dw_after = DiffOpt.get_forward_variable(model7, x7) +println(" dx/dw (after re-optimize) = $(round(dx_dw_after, digits=4))") +println(" Difference: $(abs(dx_dw_before - dx_dw_after))") +println() + +#====================================================================== +SUMMARY +======================================================================# +println("="^70) +println("SUMMARY") +println("="^70) +println() +println("If Tests 5-6 show NEGATIVE dpshed1/dw1, DiffOpt is working correctly.") +println("If they show POSITIVE values, there's a fundamental issue with the API usage.") +println() +println("Compare these results to the full MLD model to identify where the issue arises.") diff --git a/script/reformulation/debug/debug_diffopt_reverse.jl b/script/reformulation/debug/debug_diffopt_reverse.jl new file mode 100644 index 0000000..6e41706 --- /dev/null +++ b/script/reformulation/debug/debug_diffopt_reverse.jl @@ -0,0 +1,191 @@ +#= +Test reverse mode differentiation as alternative to forward mode. +Reverse mode seeds the output and retrieves input sensitivities. + +Run with: julia --project=. script/reformulation/debug/debug_diffopt_reverse.jl +=# + +using Pkg +Pkg.activate(joinpath(@__DIR__, "..", "..", "..")) # debug → reformulation → script → root + +using JuMP +using DiffOpt +using Ipopt +using LinearAlgebra +using Printf + +println("="^70) +println("DIFFOPT REVERSE MODE TEST") +println("="^70) +println() + +n_loads = 3 +demand = [100.0, 150.0, 200.0] +capacity = 300.0 +ε = 0.001 + +println("Testing reverse mode vs forward mode on same model") +println(" Demands: $demand, Capacity: $capacity") +println() + +#====================================================================== +Build model +======================================================================# +model = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) +set_silent(model) + +@variable(model, w[i=1:n_loads] in Parameter(1.0)) +@variable(model, 0 <= pd[i=1:n_loads] <= demand[i]) +@variable(model, pshed[1:n_loads] >= 0) + +@constraint(model, [i=1:n_loads], pshed[i] == demand[i] - pd[i]) +@constraint(model, sum(pd) <= capacity) +@objective(model, Max, sum(w[i] * pd[i] for i in 1:n_loads) - ε * sum(pd[i]^2 for i in 1:n_loads)) + +optimize!(model) + +println("Solution:") +for i in 1:n_loads + at_bound = value(pd[i]) >= demand[i] - 1e-4 ? " ← AT BOUND" : "" + println(" pd[$i] = $(round(value(pd[i]), digits=2))$at_bound, pshed[$i] = $(round(value(pshed[i]), digits=2))") +end +println() + +#====================================================================== +Forward mode Jacobian (what we've been using) +======================================================================# +println("FORWARD MODE (seed parameter, get variable sensitivity)") +println("-"^50) + +fwd_jacobian = zeros(n_loads, n_loads) +for j in 1:n_loads + # Clear previous sensitivities + for k in 1:n_loads + DiffOpt.set_forward_parameter(model, w[k], k == j ? 1.0 : 0.0) + end + DiffOpt.forward_differentiate!(model) + for i in 1:n_loads + fwd_jacobian[i, j] = DiffOpt.get_forward_variable(model, pshed[i]) + end +end + +println("Forward mode Jacobian ∂pshed/∂w:") +println(" w[1] w[2] w[3]") +for i in 1:n_loads + row = [@sprintf("%12.2f", fwd_jacobian[i,j]) for j in 1:n_loads] + println(" pshed[$i]: ", join(row, " ")) +end +println(" Diagonal: ", round.(diag(fwd_jacobian), digits=2)) +println() + +#====================================================================== +Reverse mode Jacobian +======================================================================# +println("REVERSE MODE (seed variable, get parameter sensitivity)") +println("-"^50) + +rev_jacobian = zeros(n_loads, n_loads) + +for i in 1:n_loads + # Clear and set seed for pshed[i] + for k in 1:n_loads + DiffOpt.set_reverse_variable(model, pshed[k], k == i ? 1.0 : 0.0) + end + DiffOpt.reverse_differentiate!(model) + for j in 1:n_loads + # Get ∂pshed[i]/∂w[j] via reverse mode + rev_jacobian[i, j] = DiffOpt.get_reverse_parameter(model, w[j]) + end +end + +println("Reverse mode Jacobian ∂pshed/∂w:") +println(" w[1] w[2] w[3]") +for i in 1:n_loads + row = [@sprintf("%12.2f", rev_jacobian[i,j]) for j in 1:n_loads] + println(" pshed[$i]: ", join(row, " ")) +end +println(" Diagonal: ", round.(diag(rev_jacobian), digits=2)) +println() + +#====================================================================== +Comparison +======================================================================# +println("COMPARISON") +println("-"^50) +println("Forward vs Reverse diagonal:") +for i in 1:n_loads + f = fwd_jacobian[i,i] + r = rev_jacobian[i,i] + match = abs(f - r) < 1e-6 ? "✓ match" : "✗ DIFFER" + println(" pshed[$i]: Fwd=$(round(f, digits=2)), Rev=$(round(r, digits=2)) $match") +end +println() + +#====================================================================== +Finite difference ground truth +======================================================================# +println("FINITE DIFFERENCE (ground truth)") +println("-"^50) + +δ = 0.01 +fd_jacobian = zeros(n_loads, n_loads) + +for j in 1:n_loads + model_fd = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) + set_silent(model_fd) + w_pert = ones(n_loads); w_pert[j] += δ + @variable(model_fd, w_fd[i=1:n_loads] in Parameter(w_pert[i])) + @variable(model_fd, 0 <= pd_fd[i=1:n_loads] <= demand[i]) + @variable(model_fd, pshed_fd[1:n_loads] >= 0) + @constraint(model_fd, [i=1:n_loads], pshed_fd[i] == demand[i] - pd_fd[i]) + @constraint(model_fd, sum(pd_fd) <= capacity) + @objective(model_fd, Max, sum(w_fd[i] * pd_fd[i] for i in 1:n_loads) - ε * sum(pd_fd[i]^2 for i in 1:n_loads)) + optimize!(model_fd) + for i in 1:n_loads + fd_jacobian[i, j] = (value(pshed_fd[i]) - value(pshed[i])) / δ + end +end + +println("Finite diff Jacobian ∂pshed/∂w:") +println(" w[1] w[2] w[3]") +for i in 1:n_loads + row = [@sprintf("%12.4f", fd_jacobian[i,j]) for j in 1:n_loads] + println(" pshed[$i]: ", join(row, " ")) +end +println(" Diagonal: ", round.(diag(fd_jacobian), digits=4)) +println() + +#====================================================================== +Summary +======================================================================# +println("="^70) +println("SUMMARY") +println("="^70) +println() + +fwd_neg = sum(diag(fwd_jacobian) .< 0) +rev_neg = sum(diag(rev_jacobian) .< 0) +fd_neg = sum(diag(fd_jacobian) .< 0) + +println("Negative diagonal entries (expected: $n_loads):") +println(" Forward mode: $fwd_neg / $n_loads") +println(" Reverse mode: $rev_neg / $n_loads") +println(" Finite diff: $fd_neg / $n_loads") +println() + +# Check accuracy vs finite diff +fwd_error = norm(diag(fwd_jacobian) - diag(fd_jacobian)) / norm(diag(fd_jacobian)) +rev_error = norm(diag(rev_jacobian) - diag(fd_jacobian)) / norm(diag(fd_jacobian)) + +println("Relative error vs finite diff:") +println(" Forward mode: $(round(fwd_error * 100, digits=1))%") +println(" Reverse mode: $(round(rev_error * 100, digits=1))%") +println() + +if rev_neg == n_loads && rev_error < 0.1 + println("✓ REVERSE MODE WORKS! Use this instead of forward mode.") +elseif rev_error < fwd_error + println("⚠ Reverse mode is better but still has issues.") +else + println("✗ Neither mode works well. Need barrier/regularization fix.") +end diff --git a/script/reformulation/debug/debug_diffopt_stripped.jl b/script/reformulation/debug/debug_diffopt_stripped.jl new file mode 100644 index 0000000..d22076f --- /dev/null +++ b/script/reformulation/debug/debug_diffopt_stripped.jl @@ -0,0 +1,276 @@ +#= +Stripped-down MLD model to test DiffOpt without PowerModelsDistribution complexity. +This isolates the core structure: +- Weights as parameters +- pd = z * demand +- pshed = (1-z) * demand +- Capacity constraint +- Objective: Max Σ w * pd + +Run with: julia --project=. script/reformulation/debug_diffopt_stripped.jl +=# + +using Pkg +Pkg.activate(joinpath(@__DIR__, "..", "..", "..")) # debug → reformulation → script → root + +using JuMP +using DiffOpt +using Ipopt +using LinearAlgebra +using Printf + +println("="^70) +println("STRIPPED MLD MODEL - DIFFOPT TEST") +println("="^70) +println() + +# Parameters +n_loads = 3 +demand = [100.0, 150.0, 200.0] +capacity = 300.0 # Can only serve 300 out of 450 total + +println("Setup:") +println(" Loads: $n_loads") +println(" Demands: $demand (total: $(sum(demand)))") +println(" Capacity: $capacity") +println(" Shortfall: $(sum(demand) - capacity) kW must be shed") +println() + +#====================================================================== +MODEL 1: Bilinear formulation (z_demand relaxed) +This mimics the actual MLD structure +======================================================================# +println("MODEL 1: Bilinear (z * demand)") +println("-"^50) + +model1 = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) +set_silent(model1) + +# Weight parameters +@variable(model1, w[i=1:n_loads] in Parameter(1.0)) + +# Load indicator (relaxed: continuous 0-1) +@variable(model1, 0 <= z[1:n_loads] <= 1) + +# Power delivered and shed +@variable(model1, pd[1:n_loads] >= 0) +@variable(model1, pshed[1:n_loads] >= 0) + +# Bilinear constraints (this is the challenge!) +@constraint(model1, [i=1:n_loads], pd[i] == z[i] * demand[i]) +@constraint(model1, [i=1:n_loads], pshed[i] == (1 - z[i]) * demand[i]) + +# Capacity constraint +@constraint(model1, sum(pd) <= capacity) + +# Objective: maximize weighted delivery +@objective(model1, Max, sum(w[i] * pd[i] for i in 1:n_loads)) + +optimize!(model1) + +println(" Solution:") +for i in 1:n_loads + println(" Load $i: z=$(round(value(z[i]),digits=4)), pd=$(round(value(pd[i]),digits=2)), pshed=$(round(value(pshed[i]),digits=2))") +end +println() + +# Compute Jacobian +println(" DiffOpt Jacobian (∂pshed/∂w):") +jacobian1 = zeros(n_loads, n_loads) +for j in 1:n_loads + for k in 1:n_loads + DiffOpt.set_forward_parameter(model1, w[k], k == j ? 1.0 : 0.0) + end + DiffOpt.forward_differentiate!(model1) + for i in 1:n_loads + jacobian1[i, j] = DiffOpt.get_forward_variable(model1, pshed[i]) + end +end + +println(" w[1] w[2] w[3]") +for i in 1:n_loads + row = [@sprintf("%10.4f", jacobian1[i,j]) for j in 1:n_loads] + println(" pshed[$i]: ", join(row, " ")) +end +println() +println(" Diagonal: ", round.(diag(jacobian1), digits=4)) +n_neg = sum(diag(jacobian1) .< 0) +println(" Negative diagonals: $n_neg / $n_loads (expected: $n_loads)") +println() + +#====================================================================== +MODEL 2: Linear formulation (pd directly bounded) +Avoids bilinear z * demand +======================================================================# +println("MODEL 2: Linear (pd bounded directly)") +println("-"^50) + +model2 = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) +set_silent(model2) + +@variable(model2, w2[i=1:n_loads] in Parameter(1.0)) +@variable(model2, 0 <= pd2[i=1:n_loads] <= demand[i]) +@variable(model2, pshed2[1:n_loads] >= 0) + +# Linear constraint: pshed = demand - pd +@constraint(model2, [i=1:n_loads], pshed2[i] == demand[i] - pd2[i]) + +# Capacity +@constraint(model2, sum(pd2) <= capacity) + +# Objective +@objective(model2, Max, sum(w2[i] * pd2[i] for i in 1:n_loads)) + +optimize!(model2) + +println(" Solution:") +for i in 1:n_loads + println(" Load $i: pd=$(round(value(pd2[i]),digits=2)), pshed=$(round(value(pshed2[i]),digits=2))") +end +println() + +# Jacobian +println(" DiffOpt Jacobian (∂pshed/∂w):") +jacobian2 = zeros(n_loads, n_loads) +for j in 1:n_loads + for k in 1:n_loads + DiffOpt.set_forward_parameter(model2, w2[k], k == j ? 1.0 : 0.0) + end + DiffOpt.forward_differentiate!(model2) + for i in 1:n_loads + jacobian2[i, j] = DiffOpt.get_forward_variable(model2, pshed2[i]) + end +end + +println(" w[1] w[2] w[3]") +for i in 1:n_loads + row = [@sprintf("%10.4f", jacobian2[i,j]) for j in 1:n_loads] + println(" pshed[$i]: ", join(row, " ")) +end +println() +println(" Diagonal: ", round.(diag(jacobian2), digits=4)) +n_neg2 = sum(diag(jacobian2) .< 0) +println(" Negative diagonals: $n_neg2 / $n_loads (expected: $n_loads)") +println() + +#====================================================================== +MODEL 3: Quadratic regularization (smooth NLP) +======================================================================# +println("MODEL 3: Quadratic regularization") +println("-"^50) + +model3 = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) +set_silent(model3) + +@variable(model3, w3[i=1:n_loads] in Parameter(1.0)) +@variable(model3, 0 <= pd3[i=1:n_loads] <= demand[i]) +@variable(model3, pshed3[1:n_loads] >= 0) + +@constraint(model3, [i=1:n_loads], pshed3[i] == demand[i] - pd3[i]) +@constraint(model3, sum(pd3) <= capacity) + +# Small quadratic regularization makes the problem strictly convex +ε = 0.001 +@objective(model3, Max, sum(w3[i] * pd3[i] for i in 1:n_loads) - ε * sum(pd3[i]^2 for i in 1:n_loads)) + +optimize!(model3) + +println(" Solution:") +for i in 1:n_loads + println(" Load $i: pd=$(round(value(pd3[i]),digits=2)), pshed=$(round(value(pshed3[i]),digits=2))") +end +println() + +# Jacobian +println(" DiffOpt Jacobian (∂pshed/∂w):") +jacobian3 = zeros(n_loads, n_loads) +for j in 1:n_loads + for k in 1:n_loads + DiffOpt.set_forward_parameter(model3, w3[k], k == j ? 1.0 : 0.0) + end + DiffOpt.forward_differentiate!(model3) + for i in 1:n_loads + jacobian3[i, j] = DiffOpt.get_forward_variable(model3, pshed3[i]) + end +end + +println(" w[1] w[2] w[3]") +for i in 1:n_loads + row = [@sprintf("%10.4f", jacobian3[i,j]) for j in 1:n_loads] + println(" pshed[$i]: ", join(row, " ")) +end +println() +println(" Diagonal: ", round.(diag(jacobian3), digits=4)) +n_neg3 = sum(diag(jacobian3) .< 0) +println(" Negative diagonals: $n_neg3 / $n_loads (expected: $n_loads)") +println() + +#====================================================================== +FINITE DIFFERENCE VERIFICATION for Model 3 +======================================================================# +println("FINITE DIFFERENCE VERIFICATION (Model 3)") +println("-"^50) + +δ = 0.01 +fd_jacobian = zeros(n_loads, n_loads) + +for j in 1:n_loads + # Perturb w[j] + model_fd = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer)) + set_silent(model_fd) + + # Set perturbed weights + w_perturbed = ones(n_loads) + w_perturbed[j] += δ + + @variable(model_fd, w_fd[i=1:n_loads] in Parameter(w_perturbed[i])) + @variable(model_fd, 0 <= pd_fd[i=1:n_loads] <= demand[i]) + @variable(model_fd, pshed_fd[1:n_loads] >= 0) + + @constraint(model_fd, [i=1:n_loads], pshed_fd[i] == demand[i] - pd_fd[i]) + @constraint(model_fd, sum(pd_fd) <= capacity) + @objective(model_fd, Max, sum(w_fd[i] * pd_fd[i] for i in 1:n_loads) - ε * sum(pd_fd[i]^2 for i in 1:n_loads)) + + optimize!(model_fd) + + pshed_perturbed = [value(pshed_fd[i]) for i in 1:n_loads] + pshed_baseline = [value(pshed3[i]) for i in 1:n_loads] + + fd_jacobian[:, j] = (pshed_perturbed .- pshed_baseline) ./ δ +end + +println(" Finite Difference Jacobian:") +println(" w[1] w[2] w[3]") +for i in 1:n_loads + row = [@sprintf("%10.4f", fd_jacobian[i,j]) for j in 1:n_loads] + println(" pshed[$i]: ", join(row, " ")) +end +println() + +println(" Comparison (DiffOpt / FiniteDiff):") +println(" w[1] w[2] w[3]") +for i in 1:n_loads + row = [@sprintf("%10.2f", abs(fd_jacobian[i,j]) > 1e-8 ? jacobian3[i,j] / fd_jacobian[i,j] : Inf) for j in 1:n_loads] + println(" pshed[$i]: ", join(row, " ")) +end +println() + +#====================================================================== +SUMMARY +======================================================================# +println("="^70) +println("SUMMARY") +println("="^70) +println() +println("Model 1 (Bilinear): $n_neg / $n_loads negative diagonals") +println("Model 2 (Linear LP): $n_neg2 / $n_loads negative diagonals") +println("Model 3 (Regularized): $n_neg3 / $n_loads negative diagonals") +println() + +if n_neg3 == n_loads + println("✓ DiffOpt works correctly on simplified MLD model!") + println(" Issue is likely in PowerModelsDistribution integration or constraint structure.") +else + println("✗ DiffOpt shows issues even on simplified model.") + println(" This suggests a fundamental problem with the API usage or NLP backend.") +end diff --git a/script/reformulation/debug/diagnose_jacobian.jl b/script/reformulation/debug/diagnose_jacobian.jl new file mode 100644 index 0000000..318634f --- /dev/null +++ b/script/reformulation/debug/diagnose_jacobian.jl @@ -0,0 +1,262 @@ +#= +Diagnostic script to check Jacobian computation +Run with: julia --project=. script/reformulation/diagnose_jacobian.jl +=# + +using Pkg +Pkg.activate(joinpath(@__DIR__, "..", "..", "..")) # debug → reformulation → script → root + +using FairLoadDelivery +using PowerModelsDistribution +using Ipopt +using JuMP +using DiffOpt +using LinearAlgebra +using Printf +using Statistics + +include(joinpath(@__DIR__, "..", "..", "..", "src", "implementation", "network_setup.jl")) + +println("="^70) +println("JACOBIAN DIAGNOSTIC") +println("="^70) +println() + +# Setup network +eng, math, lbs, critical_id = setup_network("ieee_13_aw_edit/motivation_a.dss", 0.5, String[]) + +n_loads = length(math["load"]) +load_ids = sort(parse.(Int, collect(keys(math["load"])))) + +# Get initial weights and demands +weights = Float64[] +pd = Float64[] +for id in load_ids + load = math["load"][string(id)] + push!(weights, load["weight"]) + push!(pd, sum(load["pd"])) +end + +println("Number of loads: $n_loads") +println("Load IDs: $load_ids") +println("Initial weights: $(weights[1]) (all equal)") +println("Load demands (pd): ", round.(pd, digits=1)) +println() + +# Solve MLD once to get baseline +mld_model = instantiate_mc_model( + math, + LinDist3FlowPowerModel, + build_mc_mld_shedding_implicit_diff; + ref_extensions=[FairLoadDelivery.ref_add_rounded_load_blocks!] +) + +optimize!(mld_model.model) +# Extract pshed in sorted load_id order (pshed is indexed by integer load IDs) +pshed_baseline = Float64[] +for id in load_ids + push!(pshed_baseline, value(mld_model.model[:pshed][id])) +end +println("Baseline pshed (sorted by load_id): ", round.(pshed_baseline, digits=2)) +println("Baseline Palma: ", round(sum(sort(pshed_baseline)[end-1:end]) / sum(sort(pshed_baseline)[1:6]), digits=4)) +println() + +# Now compute Jacobian column by column +println("Computing Jacobian (n=$(n_loads) MLD solves)...") +println() + +weight_params = mld_model.model[:fair_load_weights] +pshed_vars = mld_model.model[:pshed] +# Sort keys to ensure consistent ordering with load_ids (keys are integers) +weight_keys = sort(collect(eachindex(weight_params))) +pshed_keys = sort(collect(eachindex(pshed_vars))) + +println("Key alignment check:") +println(" load_ids: $load_ids") +println(" weight_keys: $weight_keys") +println(" pshed_keys: $pshed_keys") +println() + +jacobian = zeros(n_loads, n_loads) + +for j in 1:n_loads + # Set unit perturbation for weight j + for (k, wkey) in enumerate(weight_keys) + perturbation = (k == j) ? 1.0 : 0.0 + DiffOpt.set_forward_parameter(mld_model.model, weight_params[wkey], perturbation) + end + + optimize!(mld_model.model) + DiffOpt.forward_differentiate!(mld_model.model) + + for (i, pkey) in enumerate(pshed_keys) + jacobian[i, j] = DiffOpt.get_forward_variable(mld_model.model, pshed_vars[pkey]) + end +end + +println("Jacobian computed!") +println() + +# Analyze Jacobian +println("="^70) +println("JACOBIAN ANALYSIS") +println("="^70) +println() + +println("Jacobian diagonal (∂pshed[i]/∂w[i]):") +diag_J = diag(jacobian) +for i in 1:n_loads + sign_str = diag_J[i] < 0 ? "✓ (correct)" : "✗ (WRONG - should be negative!)" + println(" Load $i: $(round(diag_J[i], digits=4)) $sign_str") +end +println() + +n_negative_diag = sum(diag_J .< 0) +n_positive_diag = sum(diag_J .> 0) +n_zero_diag = sum(abs.(diag_J) .< 1e-6) + +println("Summary:") +println(" Negative diagonal entries: $n_negative_diag (expected: $n_loads)") +println(" Positive diagonal entries: $n_positive_diag (expected: 0)") +println(" Near-zero diagonal entries: $n_zero_diag") +println() + +if n_positive_diag > 0 + println("⚠️ WARNING: Positive diagonal entries mean increasing weight INCREASES shed!") + println(" This is likely a bug in the MLD formulation or DiffOpt usage.") +end + +if n_zero_diag > n_loads / 2 + println("⚠️ WARNING: Many near-zero diagonal entries suggest Jacobian is degenerate.") + println(" DiffOpt may not be computing gradients correctly.") +end + +# Show full Jacobian matrix (small enough for n=15) +println() +println("Full Jacobian matrix (rounded):") +println("Rows = ∂pshed[i], Columns = ∂w[j]") +for i in 1:n_loads + row_str = join([@sprintf("%8.2f", jacobian[i,j]) for j in 1:n_loads], " ") + println(" Row $i: $row_str") +end +println() + +# Test: What would happen with a small weight change? +println("="^70) +println("PREDICTION TEST") +println("="^70) +println() + +# Increase weight on load 1 by 0.1 +Δw = zeros(n_loads) +Δw[1] = 0.1 +predicted_pshed = pshed_baseline + jacobian * Δw + +println("Test: Increase weight[1] by 0.1") +println(" Δpshed[1] predicted: $(round(jacobian[1,1] * 0.1, digits=4))") +println(" Expected: negative (more weight → less shed)") +println() + +if jacobian[1,1] > 0 + println("❌ BUG CONFIRMED: Jacobian has wrong sign!") + println(" The optimization will move weights in the WRONG direction.") +else + println("✓ Jacobian sign looks correct for load 1") +end + +# ====================================================================== +# FINITE DIFFERENCE VERIFICATION +# ====================================================================== +println() +println("="^70) +println("FINITE DIFFERENCE VERIFICATION") +println("="^70) +println() +println("Computing TRUE Jacobian via finite differences...") +println("This actually perturbs weights and re-solves the MLD problem.") +println() + +δ = 0.01 # Perturbation size +fd_jacobian = zeros(n_loads, n_loads) + +for j in 1:n_loads + # Create new math dict with perturbed weight for load j + math_perturbed = deepcopy(math) + load_key = string(load_ids[j]) + math_perturbed["load"][load_key]["weight"] += δ + + # Re-solve MLD with perturbed weights + mld_perturbed = instantiate_mc_model( + math_perturbed, + LinDist3FlowPowerModel, + build_mc_mld_shedding_implicit_diff; + ref_extensions=[FairLoadDelivery.ref_add_rounded_load_blocks!] + ) + JuMP.set_silent(mld_perturbed.model) + optimize!(mld_perturbed.model) + + # Extract pshed in sorted load_id order (pshed is indexed by integer load IDs) + pshed_perturbed = Float64[] + for id in load_ids + push!(pshed_perturbed, value(mld_perturbed.model[:pshed][id])) + end + + # Finite difference: (pshed_perturbed - pshed_baseline) / δ + fd_jacobian[:, j] = (pshed_perturbed .- pshed_baseline) ./ δ + + print(" Column $j done...") + if j % 5 == 0 || j == n_loads + println() + end +end + +println() +println("Finite Difference Jacobian diagonal (TRUE ∂pshed[i]/∂w[i]):") +fd_diag = diag(fd_jacobian) +for i in 1:n_loads + sign_str = fd_diag[i] < 0 ? "✓ (correct)" : "✗ (should be negative)" + println(" Load $i: $(round(fd_diag[i], digits=4)) $sign_str") +end +println() + +# Compare DiffOpt vs Finite Difference +println("="^70) +println("COMPARISON: DiffOpt vs Finite Difference") +println("="^70) +println() +println("Diagonal comparison:") +println(@sprintf(" %-8s %15s %15s %15s", "Load", "DiffOpt", "FiniteDiff", "Ratio")) +println("-"^60) +for i in 1:n_loads + do_val = diag_J[i] + fd_val = fd_diag[i] + ratio = abs(fd_val) > 1e-8 ? do_val / fd_val : Inf + println(@sprintf(" %-8d %15.2f %15.4f %15.2f", i, do_val, fd_val, ratio)) +end +println() + +# Summary statistics +mean_ratio = mean(abs.(diag_J) ./ max.(abs.(fd_diag), 1e-8)) +println("Average |DiffOpt/FiniteDiff| ratio: $(round(mean_ratio, digits=2))") +println() + +fd_negative = sum(fd_diag .< 0) +fd_positive = sum(fd_diag .> 0) +fd_zero = sum(abs.(fd_diag) .< 1e-6) + +println("Finite Difference Summary:") +println(" Negative diagonal entries: $fd_negative") +println(" Positive diagonal entries: $fd_positive") +println(" Near-zero diagonal entries: $fd_zero") +println() + +if fd_negative > 0 + println("✓ Finite difference shows CORRECT sign (negative)") + println(" This confirms DiffOpt is computing WRONG sensitivities!") + println() + println("RECOMMENDATION: Use finite differences for Jacobian computation") + println(" in the bilevel optimization loop instead of DiffOpt.") +else + println("⚠️ Finite difference also shows wrong sign.") + println(" The issue may be in the MLD formulation itself.") +end diff --git a/script/reformulation/test_bilevel_convergence.jl b/script/reformulation/test_bilevel_convergence.jl new file mode 100644 index 0000000..3549f13 --- /dev/null +++ b/script/reformulation/test_bilevel_convergence.jl @@ -0,0 +1,159 @@ +#= +Test bilevel optimization convergence with DiffOpt fix +====================================================== + +This script runs a few iterations of the bilevel Palma ratio optimization +to verify that DiffOpt now computes correct Jacobians with regularization. + +Run with: julia --project=. script/reformulation/test_bilevel_convergence.jl +=# + +using Pkg +Pkg.activate(joinpath(@__DIR__, "..", "..")) + +using FairLoadDelivery +using PowerModelsDistribution +using Ipopt +using Gurobi +using JuMP +using DiffOpt +using LinearAlgebra +using Printf + +include(joinpath(@__DIR__, "..", "..", "src", "implementation", "network_setup.jl")) +include(joinpath(@__DIR__, "..", "..", "src", "implementation", "palma_relaxation.jl")) + +println("="^70) +println("BILEVEL OPTIMIZATION CONVERGENCE TEST") +println("="^70) +println() + +# Setup network +ls_percent = 0.5 +eng, math, lbs, critical_id = setup_network("ieee_13_aw_edit/motivation_a.dss", ls_percent, String[]) + +n_loads = length(math["load"]) +load_ids = sort(parse.(Int, collect(keys(math["load"])))) + +# Get demands for Palma optimization +pd = Float64[] +for id in load_ids + push!(pd, sum(math["load"][string(id)]["pd"])) +end + +println("Setup: $n_loads loads") +println("Demands: ", round.(pd, digits=1)) +println() + +function run_bilevel_test(math, load_ids, pd, n_loads) + # Initialize weights + current_weights = 10.0 * ones(n_loads) + + # Run bilevel optimization + n_iterations = 5 + palma_history = Float64[] + + for k in 1:n_iterations + println("-"^50) + println("Iteration $k") + println("-"^50) + + # Update weights in math dict + for (i, id) in enumerate(load_ids) + math["load"][string(id)]["weight"] = current_weights[i] + end + + # Solve lower-level MLD with DiffOpt + mld_model = instantiate_mc_model( + math, + LinDist3FlowPowerModel, + build_mc_mld_shedding_implicit_diff; + ref_extensions=[FairLoadDelivery.ref_add_rounded_load_blocks!] + ) + JuMP.set_silent(mld_model.model) + optimize!(mld_model.model) + + # Extract pshed in sorted order + pshed = Float64[] + for id in load_ids + push!(pshed, value(mld_model.model[:pshed][id])) + end + + # Compute current Palma ratio + sorted_pshed = sort(pshed) + n_top = max(1, ceil(Int, 0.1 * n_loads)) + n_bottom = max(1, floor(Int, 0.4 * n_loads)) + top_sum = sum(sorted_pshed[end-n_top+1:end]) + bottom_sum = sum(sorted_pshed[1:n_bottom]) + palma = bottom_sum > 1e-6 ? top_sum / bottom_sum : Inf + push!(palma_history, palma) + + println(" pshed: ", round.(pshed, digits=1)) + println(" Palma ratio: ", @sprintf("%.4f", palma)) + + if k == n_iterations + break # Don't compute Jacobian on last iteration + end + + # Compute Jacobian via DiffOpt + weight_params = mld_model.model[:fair_load_weights] + pshed_vars = mld_model.model[:pshed] + + jacobian = zeros(n_loads, n_loads) + for j in 1:n_loads + # Unit perturbation for weight j + for i in 1:n_loads + DiffOpt.set_forward_parameter(mld_model.model, weight_params[load_ids[i]], i == j ? 1.0 : 0.0) + end + DiffOpt.forward_differentiate!(mld_model.model) + for i in 1:n_loads + jacobian[i, j] = DiffOpt.get_forward_variable(mld_model.model, pshed_vars[load_ids[i]]) + end + end + + # Check Jacobian diagonal + diag_J = diag(jacobian) + n_negative = sum(diag_J .< 0) + println(" Jacobian diagonal: $n_negative / $n_loads negative") + + # Solve upper-level Palma optimization + println(" Solving upper-level optimization...") + pshed_new, weights_new, sigma = lin_palma_w_grad_input(jacobian, pshed, current_weights, pd) + + # Update weights for next iteration + current_weights = weights_new + println(" New weights: ", round.(current_weights, digits=2)) + end + + return palma_history +end + +# Run the test +palma_history = run_bilevel_test(math, load_ids, pd, n_loads) + +println() +println("="^70) +println("CONVERGENCE SUMMARY") +println("="^70) +println() + +println("Palma ratio history:") +for (k, p) in enumerate(palma_history) + println(" Iteration $k: ", @sprintf("%.4f", p)) +end +println() + +# Check if Palma ratio decreased +if length(palma_history) >= 2 + initial = palma_history[1] + final = palma_history[end] + improvement = (initial - final) / initial * 100 + + if final < initial + println("✓ SUCCESS: Palma ratio decreased from $(@sprintf("%.4f", initial)) to $(@sprintf("%.4f", final))") + println(" Improvement: $(@sprintf("%.1f%%", improvement))") + else + println("✗ FAILURE: Palma ratio did not decrease") + println(" Initial: $(@sprintf("%.4f", initial)), Final: $(@sprintf("%.4f", final))") + end +end diff --git a/src/core/objective.jl b/src/core/objective.jl index a36678f..770c891 100644 --- a/src/core/objective.jl +++ b/src/core/objective.jl @@ -203,16 +203,27 @@ function objective_weighted_max_load_served(pm::_PMD.AbstractUnbalancedPowerMode sum(weighted_load_served)) end -function objective_fairly_weighted_max_load_served(pm::_PMD.AbstractUnbalancedPowerModel; nw::Int=_IM.nw_id_default, report::Bool=true) +function objective_fairly_weighted_max_load_served(pm::_PMD.AbstractUnbalancedPowerModel; nw::Int=_IM.nw_id_default, report::Bool=true, regularization::Float64=0.0) fair_load_weights = _PMD.var(pm, nw, :fair_load_weights) weighted_load_served = [] + regularization_term = [] for d in _PMD.ids(pm, nw, :load) - push!(weighted_load_served, sum(fair_load_weights[d].*_PMD.var(pm, nw, :pd)[d])) + pd_var = _PMD.var(pm, nw, :pd)[d] + push!(weighted_load_served, sum(fair_load_weights[d] .* pd_var)) + # Quadratic regularization to keep pd interior (fixes DiffOpt sensitivity computation) + if regularization > 0 + push!(regularization_term, sum(pd_var .^ 2)) + end end #@info fair_load_weights #@info _PMD.var(pm, nw, :pd) - return JuMP.@objective(pm.model, Max, - sum(weighted_load_served)) + if regularization > 0 + return JuMP.@objective(pm.model, Max, + sum(weighted_load_served) - regularization * sum(regularization_term)) + else + return JuMP.@objective(pm.model, Max, + sum(weighted_load_served)) + end end function objective_fairly_weighted_min_load_shed(pm::_PMD.AbstractUnbalancedPowerModel; nw::Int=_IM.nw_id_default, report::Bool=true) diff --git a/src/implementation/palma_relaxation.jl b/src/implementation/palma_relaxation.jl index b900c70..482ecdf 100644 --- a/src/implementation/palma_relaxation.jl +++ b/src/implementation/palma_relaxation.jl @@ -3,6 +3,7 @@ using FairLoadDelivery using PowerModelsDistribution using JuMP +import MathOptInterface as MOI using LinearAlgebra using Ipopt, Gurobi @@ -186,7 +187,8 @@ function lin_palma_w_grad_input(dpshed_dw::Matrix{Float64}, pshed_prev::Vector{F @variable(model, σ >= 1e-6) @variable(model, y_xhat[1:n^2] >= 0) @variable(model, y_a[1:n^2], Bin) # or Bin if enforced - @variable(model, y_pshed[1:n] >= 0) # pshed_new + # Allow y_pshed to go slightly negative in Taylor expansion (will be bounded by McCormick) + @variable(model, y_pshed[1:n] >= -100) # pshed_new with relaxed lower bound @variable(model, y_w[1:n] >=0) # FREE (signed) y = vcat(y_xhat, y_a, y_pshed, (y_w-weights_prev)) @@ -194,24 +196,21 @@ function lin_palma_w_grad_input(dpshed_dw::Matrix{Float64}, pshed_prev::Vector{F # # Create new weight variables #@variable(model, weights_new[1:n] >= 0) + # Weight bounds @constraint(model, y_w .<= 10) - # Create a infinity norm trust region on the weights - # ϵ = 0.1 - - # @constraint(model, [i in eachindex(y_w)], - # y_w[i] - weights_prev[i] <= ϵ - # ) - - # @constraint(model, [i in eachindex(y_w)], - # weights_prev[i] - y_w[i] <= ϵ - # ) - # for i in 1:n - # if i in critical_id - # @constraint(model, weights_new[i] == 1000) - # else - # @constraint(model, weights_new[i] <= 10) - # end - # end + @constraint(model, y_w .>= 0) + + # Trust region on weight changes (critical for convergence) + trust_radius = 0.5 + Δw = y_w .- weights_prev + @constraint(model, Δw .<= trust_radius) + @constraint(model, Δw .>= -trust_radius) + + # CRITICAL: Taylor expansion constraint linking pshed_new to weight changes + # pshed_new[i] = pshed_prev[i] + Σ_j dpshed_dw[i,j] * Δw[j] + @constraint(model, taylor_expansion[i=1:n], + y_pshed[i] == pshed_prev[i] + sum(dpshed_dw[i,j] * Δw[j] for j in 1:n) + ) # Sorting constraint matrix: enforces ascending order S_k <= S_{k+1} # T * x_hat <= 0 means sorted[k] - sorted[k+1] <= 0 @@ -314,31 +313,45 @@ function lin_palma_w_grad_input(dpshed_dw::Matrix{Float64}, pshed_prev::Vector{F @assert size(F, 1) == length(g) "F rows ($(size(F, 1))) must match g length ($(length(g)))" @constraint(model, F * y .<= g*σ) - A_long = [A_row zeros(n,n^2) zeros(n,2*n)] # FIXED: use A_row instead of undefined A - # Create the vectors to extract the top 10% of load shed + # A_long extracts sorted values from y_xhat (McCormick aux = a * pshed) + # y = [y_xhat(n²), y_a(n²), y_pshed(n), y_w-weights_prev(n)] + A_long = [A_row zeros(n, n^2) zeros(n, 2*n)] + + # Palma ratio = sum(top 10% sorted) / sum(bottom 40% sorted) + # Using Charnes-Cooper: normalize denominator = 1, minimize numerator top_10_percent_indices = zeros(n) top_10_percent_indices[ceil(Int, 0.9*n):n] .= 1.0 bottom_40_percent_indices = zeros(n) bottom_40_percent_indices[1:floor(Int, 0.4*n)] .= 1.0 - obj = transpose(top_10_percent_indices)*A_long*y - denominator_constraint = transpose(bottom_40_percent_indices)*A_long*y - # @constraint(model, denominator_constraint .== σ) - @objective(model, Max, 0) + + # Numerator (top 10% sum, scaled by σ) + numerator_expr = transpose(top_10_percent_indices) * A_long * y + # Denominator (bottom 40% sum, scaled by σ) - normalized to 1 via Charnes-Cooper + denominator_expr = transpose(bottom_40_percent_indices) * A_long * y + + # Charnes-Cooper normalization: denominator * σ = 1 (scaled denominator = 1) + @constraint(model, denominator_expr == 1.0) + + # Minimize the Palma ratio (= numerator when denominator normalized to 1) + @objective(model, Min, numerator_expr) set_optimizer(model, Gurobi.Optimizer) # Disable dual reductions set_attribute(model, "DualReductions", 0) optimize!(model) - # value.(pshed_new) - # value(σ) - # value.(weights_new) @info termination_status(model) println("Termination: ", termination_status(model)) println("Primal status: ", primal_status(model)) println("Dual status: ", dual_status(model)) - return Array(value.(y_pshed)), Array(value.(weights_prev .+ y_w)), value(σ) + # Handle infeasibility - return unchanged values + if termination_status(model) in [MOI.INFEASIBLE, MOI.INFEASIBLE_OR_UNBOUNDED] + @warn "Palma optimization infeasible, returning unchanged weights" + return pshed_prev, weights_prev, 1.0 + end + + return Array(value.(y_pshed)), Array(value.(y_w)), value(σ) end diff --git a/src/prob/mld.jl b/src/prob/mld.jl index f085f89..54adb62 100644 --- a/src/prob/mld.jl +++ b/src/prob/mld.jl @@ -130,7 +130,9 @@ function build_mc_mld_shedding_implicit_diff(pm::_PMD.AbstractUBFModels) #_PMD.objective_mc_min_load_setpoint_delta_simple(pm) #_PMD.objective_mc_min_fuel_cost(pm) #objective_mc_min_fuel_cost_pwl_voll(pm) - objective_fairly_weighted_max_load_served(pm) + # Regularization keeps pd interior, fixing DiffOpt sensitivity computation + # See script/reformulation/debug/ for analysis + objective_fairly_weighted_max_load_served(pm; regularization=0.1) #objective_fair_max_load_served(pm,"jain") #objective_fairly_weighted_max_load_served_with_penalty(pm) #objective_fairly_weighted_min_load_shed(pm) From f75a95942bece6952434c1d1008eee4848fdb429 Mon Sep 17 00:00:00 2001 From: samtalki Date: Thu, 15 Jan 2026 16:44:56 -0500 Subject: [PATCH 6/7] Set binary permutation as default for Palma reformulation The McCormick relaxation (relax_binary=true) produces degenerate solutions where the solver exploits loose envelopes to find objective=0. Binary permutation is required for correct results. Validated on IEEE 13-bus: NEW achieves 4.9% Palma improvement in 0.2s vs OLD's 3.8% in 3.6s. TODO: Investigate tighter McCormick cuts or alternative relaxations. Co-Authored-By: Claude Opus 4.5 --- .../reformulation/load_shed_as_parameter.jl | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/script/reformulation/load_shed_as_parameter.jl b/script/reformulation/load_shed_as_parameter.jl index d69e8b2..e9d8fb5 100644 --- a/script/reformulation/load_shed_as_parameter.jl +++ b/script/reformulation/load_shed_as_parameter.jl @@ -74,22 +74,18 @@ For n=10: bottom_40_idx = [1,2,3,4], top_10_idx = [10] For n=20: bottom_40_idx = [1,2,3,4,5,6,7,8], top_10_idx = [19,20] """ function compute_palma_indices(n::Int) - # Bottom 40%: indices 1 to floor(0.4n) in sorted order + # Bottom 40%: first floor(0.4n) positions in ascending sorted order n_bottom = max(1, floor(Int, 0.4 * n)) bottom_40_idx = collect(1:n_bottom) - # Top 10%: indices ceil(0.9n) to n in sorted order - n_top_start = max(n, ceil(Int, 0.9 * n)) # Ensure at least last element + # Top 10%: last ceil(0.1n) positions in ascending sorted order + # For n=15: ceil(0.1*15) = 2 elements → positions [14, 15] + # For n=10: ceil(0.1*10) = 1 element → position [10] + # For n=20: ceil(0.1*20) = 2 elements → positions [19, 20] + n_top = max(1, ceil(Int, 0.1 * n)) # Number of elements in top 10% + n_top_start = n - n_top + 1 # Starting position (1-indexed) top_10_idx = collect(n_top_start:n) - # Handle edge case where ranges might be empty - if isempty(top_10_idx) - top_10_idx = [n] - end - if isempty(bottom_40_idx) - bottom_40_idx = [1] - end - return top_10_idx, bottom_40_idx end @@ -233,7 +229,7 @@ function palma_ratio_minimization( w_bounds::Tuple{Float64, Float64} = (0.0, 10.0), solver = get_default_solver(), silent::Bool = true, - relax_binary::Bool = true + relax_binary::Bool = false # Binary required; McCormick relaxation (true) produces degenerate solutions - needs further testing ) n = length(pshed_prev) w_min, w_max = w_bounds @@ -254,8 +250,13 @@ function palma_ratio_minimization( # Solver-specific settings if GUROBI_AVAILABLE && solver == Gurobi.Optimizer set_optimizer_attribute(model, "DualReductions", 0) - set_optimizer_attribute(model, "MIPGap", 1e-6) - set_optimizer_attribute(model, "NonConvex", 2) # Allow non-convex QP + set_optimizer_attribute(model, "MIPGap", 1e-4) # Relaxed gap (was 1e-6) + set_optimizer_attribute(model, "NonConvex", 2) # Allow non-convex QP + set_optimizer_attribute(model, "TimeLimit", 60*5) # 5-minute time limit + set_optimizer_attribute(model, "MIPFocus", 1) # Focus on finding feasible solutions + if !silent + set_optimizer_attribute(model, "OutputFlag", 1) # Show progress + end elseif IPOPT_AVAILABLE && solver == Ipopt.Optimizer set_optimizer_attribute(model, "print_level", 0) end @@ -447,9 +448,9 @@ function lin_palma_reformulated( ) result = palma_ratio_minimization( dpshed_dw, pshed_prev, weights_prev, pd; - trust_radius = 0.1, + trust_radius = 0.5, w_bounds = (0.0, 10.0), - relax_binary = true + relax_binary = false # Binary required; McCormick relaxation needs testing ) # Compute σ from result (for compatibility) From d98e9cfb2b814afdbc8f13c40c16a5ddc8a9f252 Mon Sep 17 00:00:00 2001 From: samtalki Date: Thu, 15 Jan 2026 21:37:53 -0500 Subject: [PATCH 7/7] add README.md file to script/reformulation, run minimum working example of IEEE 13 --- CLAUDE.md | 90 +++++ script/reformulation/README.md | 176 +++++++++ .../convergence.csv | 5 + .../fairness_metrics.csv | 5 + .../final_pshed_distribution.png | Bin 0 -> 19288 bytes .../final_pshed_distribution.svg | 102 +++++ .../final_results.csv | 16 + .../final_weights.png | Bin 0 -> 16703 bytes .../final_weights.svg | 102 +++++ .../palma_convergence.png | Bin 0 -> 22814 bytes .../palma_convergence.svg | 38 ++ .../pshed_heatmap.png | Bin 0 -> 17493 bytes .../pshed_heatmap.svg | 344 +++++++++++++++++ .../total_shed.png | Bin 0 -> 24754 bytes .../total_shed.svg | 40 ++ .../weights_heatmap.png | Bin 0 -> 18067 bytes .../weights_heatmap.svg | 348 ++++++++++++++++++ script/reformulation/opendss_experiment.jl | 26 +- 18 files changed, 1291 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 script/reformulation/README.md create mode 100644 script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/convergence.csv create mode 100644 script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/fairness_metrics.csv create mode 100644 script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/final_pshed_distribution.png create mode 100644 script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/final_pshed_distribution.svg create mode 100644 script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/final_results.csv create mode 100644 script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/final_weights.png create mode 100644 script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/final_weights.svg create mode 100644 script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/palma_convergence.png create mode 100644 script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/palma_convergence.svg create mode 100644 script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/pshed_heatmap.png create mode 100644 script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/pshed_heatmap.svg create mode 100644 script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/total_shed.png create mode 100644 script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/total_shed.svg create mode 100644 script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/weights_heatmap.png create mode 100644 script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/weights_heatmap.svg diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b04fd1e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,90 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +FairLoadDelivery is a Julia package for fair load shedding optimization in power distribution systems. It implements a bilevel optimization framework that balances efficiency with fairness metrics when determining which loads to shed during capacity shortages. + +## Build & Development Commands + +```bash +# Activate and install dependencies +julia --project=. -e 'using Pkg; Pkg.instantiate()' + +# Run tests +julia --project=. -e 'using Pkg; Pkg.test()' + +# Start interactive development session +julia --project=. + +# In Julia REPL: +using Revise +using FairLoadDelivery +``` + +## Running Scripts + +```julia +# Main FLDP workflow +include("script/FLDP.jl") + +# Network setup example +eng, math, lbs, critical_id = setup_network("ieee_13_aw_edit/motivation_b.dss", 0.9, []) + +# Solve MLD problems +pm_soln = FairLoadDelivery.solve_mc_mld_switch_integer(math, gurobi) +pm_soln = solve_mc_mld_shed_implicit_diff(math, ipopt) +``` + +## Architecture + +### Bilevel Optimization Framework + +- **Upper Level**: Optimizes fairness metrics by adjusting load weights +- **Lower Level**: Solves Minimum Load Delivery (MLD) problem given weights, using implicit differentiation (`DiffOpt`) to compute gradients through the optimization + +### Core Components + +- `src/core/` - Variable definitions, constraints, and objectives for JuMP models +- `src/prob/` - Problem formulations (OPF, MLD, Power Flow) +- `src/implementation/` - Algorithm implementations: + - `network_setup.jl` - Parses OpenDSS files, identifies load blocks + - `lower_level_mld.jl` - Lower-level solver with DiffOpt differentiation + - `palma_relaxation.jl` - Palma ratio fairness optimization + - `random_rounding.jl` - Converts relaxed solutions to integer-feasible + - `other_fair_funcs.jl` - Fairness metrics (Jain, proportional, min-max, efficiency) + +### Key Concepts + +- **Load Blocks**: Connected regions of loads that can be shed together, enforced by switch topology +- **Three-Phase Unbalanced**: Uses PowerModelsDistribution for realistic distribution system modeling +- **Radiality Constraints**: Ensures tree topology in distribution networks + +### Solvers + +Preconfigured optimizers are exported from the module: +- `ipopt` - NLP/continuous problems (primary) +- `gurobi` - Mixed-integer programming +- `highs` - Secondary MIP solver +- `juniper` - Mixed-integer nonlinear + +### Data Format + +Network files use OpenDSS `.dss` format, located in `data/`. Primary test case: `ieee_13_aw_edit/motivation_b.dss` + +## Key Patterns + +### Reference Extensions + +Network preprocessing uses the extension pattern: +```julia +ref_extensions=[ref_add_load_blocks!] +# or for rounded solutions: +ref_extensions=[ref_add_rounded_load_blocks!] +``` + +### Constants + +- `zero_tol = 1e-9` - Numerical tolerance for zero checks +- Module aliases: `_PMD` for PowerModelsDistribution, `_IM` for InfrastructureModels diff --git a/script/reformulation/README.md b/script/reformulation/README.md new file mode 100644 index 0000000..5ad1cf1 --- /dev/null +++ b/script/reformulation/README.md @@ -0,0 +1,176 @@ +# Palma Ratio Reformulation + +This directory contains the reformulated Palma ratio optimization for fair load shedding. + +## Overview + +The Palma ratio measures inequality as: +``` +Palma = sum(top 10% load shed) / sum(bottom 40% load shed) +``` + +Lower Palma ratio = more equal distribution of load shedding. + +## Files + +| File | Description | +|------|-------------| +| `load_shed_as_parameter.jl` | Core optimization: Palma ratio minimization with McCormick envelopes and Charnes-Cooper transformation | +| `opendss_experiment.jl` | Bilevel optimization loop integrating with OpenDSS network data via DiffOpt | +| `validate_reformulation.jl` | Unit tests for the reformulation | +| `diagnose_jacobian.jl` | Diagnostic script to verify Jacobian computation | + +## Mathematical Formulation + +### Decision Variables +- `Δw[j]`: Weight changes (continuous, bounded by trust region) +- `a[i,j]`: Permutation matrix (binary or relaxed) +- `u[i,j]`: McCormick auxiliary for `a[i,j] * pshed_new[j]` +- `σ`: Charnes-Cooper scaling variable + +### Key Expressions +```julia +# P_shed as Taylor expansion (NOT a variable) +pshed_new[j] = pshed_prev[j] + Σₖ (∂pshed[j]/∂w[k]) * Δw[k] + +# Sorted values via permutation +sorted[i] = Σⱼ u[i,j] where u[i,j] ≈ a[i,j] * pshed_new[j] +``` + +### Charnes-Cooper Transformation +Converts ratio minimization to linear objective: +``` +min (top 10% sum) / (bottom 40% sum) +→ min (top 10% sum) * σ s.t. (bottom 40% sum) * σ = 1 +``` + +## Known Issues and Limitations + +### 1. McCormick Relaxation vs Binary Permutation + +**Problem**: McCormick envelopes are tight only at binary (0,1) points. + +| Setting | Pros | Cons | +|---------|------|------| +| `relax_binary=true` | Fast LP solve | **Sorting breaks** - u values collapse to zero | +| `relax_binary=false` | Correct sorting | **Slow MIQP** - 225 binary variables for n=15 | + +**Current approach**: Use `relax_binary=false` with Gurobi time limits. + +### 2. Taylor Approximation Validity + +The bilevel optimization relies on: +``` +pshed_new ≈ pshed_prev + Jacobian * Δw +``` + +This is only valid for **small** weight changes. If: +- Trust region is too large +- System is highly nonlinear +- Jacobian is inaccurate + +The predicted Palma ratio may be **very different** from actual. + +### 3. Jacobian Computation (CRITICAL BUG) + +**Status: DiffOpt sensitivities are BROKEN for this NLP** + +The Jacobian `∂pshed/∂w` computed via DiffOpt forward differentiation produces **WRONG** values: + +| Issue | Expected | Actual (DiffOpt) | +|-------|----------|------------------| +| Sign | Negative | **Positive** (all 15 loads) | +| Magnitude | O(demand) ≈ 17-1155 | **O(100,000)** - 100x too large | + +**Root cause**: DiffOpt's KKT-based differentiation fails on this non-convex NLP because: +1. "Inertia correction needed" warnings indicate ill-conditioned KKT matrix +2. Non-convex power flow constraints create saddle points +3. Relaxed binary indicators (z_demand) cause degeneracy + +**Workaround**: Use **finite differences** instead of DiffOpt: +```julia +# Actually perturb weights and re-solve MLD +δ = 0.01 +for j in 1:n_loads + math_perturbed = deepcopy(math) + math_perturbed["load"][j]["weight"] += δ + # ... re-solve and compute (pshed_new - pshed_baseline) / δ +end +``` + +The `diagnose_jacobian.jl` script now includes both DiffOpt and finite difference comparison. +Run it to verify: +```bash +julia --project=. script/reformulation/diagnose_jacobian.jl +``` + +### 4. Charnes-Cooper Creates Quadratic Constraints + +The constraint `(bottom 40% sum) * σ = 1` is bilinear, requiring: +- Gurobi with `NonConvex=2`, or +- Ipopt (NLP solver) + +HiGHS cannot be used (LP/MILP only). + +## Alternative Approaches + +Located in `src/implementation/other_fair_funcs.jl`: + +| Metric | Formula | Complexity | Recommendation | +|--------|---------|------------|----------------| +| **Proportional Fairness** | `max Σ log(pshed + ε)` | NLP | ⭐ Best alternative | +| **Jain's Index** | `max (Σpshed)² / (n·Σpshed²)` | NLP | Good for bounded metric | +| **Min-Max** | `max min(pshed)` | LP | Extreme fairness | +| **Palma Ratio** | `min top10% / bottom40%` | MIQP | Current (slow) | + +**Recommendation**: If Palma MIQP is too slow, use **Proportional Fairness** - it has similar fairness properties without requiring sorting/permutation matrices. + +## Usage + +### Basic experiment +```julia +include("script/reformulation/opendss_experiment.jl") +run_mwe(ls_percent=0.5) +``` + +### With custom settings +```julia +result = solve_palma_ratio_minimization( + "ieee_13_aw_edit/motivation_a.dss"; + ls_percent = 0.5, # 50% capacity → forces load shedding + iterations = 5, + trust_radius = 0.1, # Smaller = more conservative + experiment_name = "my_experiment" +) +``` + +### Diagnose Jacobian issues +```bash +julia --project=. script/reformulation/diagnose_jacobian.jl +``` + +## Debugging + +### Palma ratio exploding? +1. Check Jacobian diagonal: should be **negative** +2. Check predicted vs actual Palma after each iteration +3. Reduce trust radius (try 0.01 instead of 0.1) +4. Check if bottom 40% sum → 0 (makes Palma undefined) + +### Gurobi timing out? +1. Increase `MIPGap` to 0.01 (1%) +2. Reduce time limit +3. Consider switching to Proportional Fairness + +### Segmentation fault? +Usually caused by Julia/solver memory issues. Try: +- Restart Julia session +- Reduce number of iterations +- Use fewer loads + +## References + +- Charnes-Cooper transformation: Charnes & Cooper (1962) +- McCormick envelopes: McCormick (1976) +- Palma ratio: Cobham & Sumner (2013) +- DiffOpt.jl: https://github.com/jump-dev/DiffOpt.jl diff --git a/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/convergence.csv b/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/convergence.csv new file mode 100644 index 0000000..024bd63 --- /dev/null +++ b/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/convergence.csv @@ -0,0 +1,5 @@ +iteration,palma_ratio,total_shed +0,5.975103456522013,2549.0000153213496 +1,5.913729619501063,2552.694048738304 +2,5.85363100072619,2556.376415534519 +3,5.794741753586909,2560.046049069473 diff --git a/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/fairness_metrics.csv b/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/fairness_metrics.csv new file mode 100644 index 0000000..d5af3a7 --- /dev/null +++ b/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/fairness_metrics.csv @@ -0,0 +1,5 @@ +metric,initial,final,improvement +Palma Ratio,5.975103456522013,5.794741753586909,3.018553640912006 +Gini Index,0.6861234132262425,0.6882810147417802,-0.31446259870252075 +Jain's Index,0.3231324891931608,0.32558875688375827,-0.760142595605477 +Min-Max Range,1004.9999948416543,1004.9986633525397,0.00013248647974379432 diff --git a/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/final_pshed_distribution.png b/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/final_pshed_distribution.png new file mode 100644 index 0000000000000000000000000000000000000000..6f7864fa4d162bc3999640f0319d2cffc6ce93da GIT binary patch literal 19288 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/final_results.csv b/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/final_results.csv new file mode 100644 index 0000000..cedbde1 --- /dev/null +++ b/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/final_results.csv @@ -0,0 +1,16 @@ +load_id,demand,final_pshed,final_weight,shed_fraction +1,160.0,109.99954998031622,9.999990201345195,0.6874971873769764 +2,68.0,19.50027226999875,9.700000024284337,0.2867687098529228 +3,1155.0,1004.9986690595692,9.999999988481303,0.8701287178004928 +4,485.0,434.99951945580233,9.999999959777027,0.8969062256820667 +5,128.0,79.50282124413947,9.700018741305897,0.6211157909698396 +6,230.0,129.99593846270486,9.999995732203535,0.5651997324465429 +7,170.0,120.00016624687345,9.999051301401678,0.7058833308639615 +8,117.0,68.50000621029282,9.700000023692363,0.5854701385495114 +9,120.0,71.50027081162816,9.70000003051058,0.5958355900969013 +10,66.0,17.50001349695763,9.700000023598509,0.2651517196508732 +11,120.0,71.50027076072111,9.700000019695961,0.5958355896726759 +12,290.0,240.54812414406987,9.89042681881141,0.8294762901519651 +13,170.0,71.50027072078728,9.849952414440908,0.42058982776933695 +14,17.0,5.707029543095186e-6,9.998144543072094,3.3570762018206976e-7 +15,170.0,120.00015049858239,9.999999201263046,0.7058832382269552 diff --git a/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/final_weights.png b/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/final_weights.png new file mode 100644 index 0000000000000000000000000000000000000000..d2fc7fb9c6d62f391358e255c3a166217b7e33b2 GIT binary patch literal 16703 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/palma_convergence.png b/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/palma_convergence.png new file mode 100644 index 0000000000000000000000000000000000000000..b1a094a85663f606837ff4850c97f5e7528bb0a7 GIT binary patch literal 22814 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 literal 0 HcmV?d00001 diff --git a/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/palma_convergence.svg b/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/palma_convergence.svg new file mode 100644 index 0000000..9b9b8e9 --- /dev/null +++ b/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/palma_convergence.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/pshed_heatmap.png b/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/pshed_heatmap.png new file mode 100644 index 0000000000000000000000000000000000000000..a8ed210d1aeb7f61092c9259487ca0a9a7d61c49 GIT binary patch literal 17493 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 K$R(Y*`F{XEpaYix literal 0 HcmV?d00001 diff --git a/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/pshed_heatmap.svg b/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/pshed_heatmap.svg new file mode 100644 index 0000000..c1b3957 --- /dev/null +++ b/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/pshed_heatmap.svg @@ -0,0 +1,344 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/total_shed.png b/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/total_shed.png new file mode 100644 index 0000000000000000000000000000000000000000..edece83027d3c647ae7eda3bed3592de53f56310 GIT binary patch literal 24754 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/weights_heatmap.png b/script/reformulation/experiments/mwe_ieee13a_50pct_2026-01-15_16/weights_heatmap.png new file mode 100644 index 0000000000000000000000000000000000000000..7bba295355f038976d7494d0afb0f2f4eb14914d GIT binary patch literal 18067 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/script/reformulation/opendss_experiment.jl b/script/reformulation/opendss_experiment.jl index b82c07e..0f3cbe6 100644 --- a/script/reformulation/opendss_experiment.jl +++ b/script/reformulation/opendss_experiment.jl @@ -245,6 +245,21 @@ function solve_palma_ratio_minimization( push!(palma_history, initial_palma) push!(pshed_history, copy(pshed_ordered)) push!(weights_history, copy(weights)) + else + # For subsequent iterations, show ACTUAL vs PREDICTED comparison + actual_palma_from_mld = palma_ratio(pshed_ordered) + if length(palma_history) > 0 + predicted_palma = palma_history[end] + verbose && println(" [DEBUG] Predicted Palma (Taylor): $predicted_palma") + verbose && println(" [DEBUG] Actual Palma (MLD solve): $actual_palma_from_mld") + verbose && println(" [DEBUG] Prediction error: $(abs(predicted_palma - actual_palma_from_mld) / actual_palma_from_mld * 100)%") + end + end + + # Debug: Show Jacobian diagonal (should be negative) + if k == 1 && verbose + println(" [DEBUG] Jacobian diagonal (first 5): ", round.(diag(jacobian_ordered)[1:min(5,n_loads)], digits=4)) + println(" [DEBUG] Expected: NEGATIVE values (increasing weight → decreasing shed)") end # Upper level: Solve reformulated Palma ratio minimization @@ -272,13 +287,22 @@ function solve_palma_ratio_minimization( trust_radius = trust_radius, w_bounds = (0.0, 10.0), relax_binary = false, # Must use integer permutation for correct sorting - silent = true + silent = false # Show Gurobi output to debug ) solve_time = time() - t_start # Update weights for next iteration + old_weights = copy(weights) weights = result.weights_new + # Debug: Show weight changes + if verbose + delta_w = weights - old_weights + println(" [DEBUG] Weight changes (Δw): min=$(minimum(delta_w)), max=$(maximum(delta_w))") + println(" [DEBUG] Loads with Δw > 0: ", sum(delta_w .> 0.001)) + println(" [DEBUG] Loads with Δw < 0: ", sum(delta_w .< -0.001)) + end + # Store results push!(palma_history, result.palma_ratio) push!(pshed_history, result.pshed_new)