diff --git a/src/RELOG.jl b/src/RELOG.jl index 8c49e34..e115591 100644 --- a/src/RELOG.jl +++ b/src/RELOG.jl @@ -3,8 +3,23 @@ # Released under the modified BSD license. See COPYING.md for more details. module RELOG -include("instance.jl") -include("graph.jl") -include("model.jl") -include("reports.jl") + +include("instance/structs.jl") + +include("graph/structs.jl") + +include("graph/build.jl") +include("graph/csv.jl") +include("instance/compress.jl") +include("instance/parse.jl") +include("instance/validate.jl") +include("model/build.jl") +include("model/getsol.jl") +include("model/solve.jl") +include("reports/plant_emissions.jl") +include("reports/plant_outputs.jl") +include("reports/plants.jl") +include("reports/tr_emissions.jl") +include("reports/tr.jl") +include("reports/write.jl") end diff --git a/src/graph.jl b/src/graph/build.jl similarity index 78% rename from src/graph.jl rename to src/graph/build.jl index cd606a7..d0d6bc7 100644 --- a/src/graph.jl +++ b/src/graph/build.jl @@ -4,42 +4,12 @@ using Geodesy - -abstract type Node end - - -mutable struct Arc - source::Node - dest::Node - values::Dict{String,Float64} -end - - -mutable struct ProcessNode <: Node - index::Int - location::Plant - incoming_arcs::Array{Arc} - outgoing_arcs::Array{Arc} -end - - -mutable struct ShippingNode <: Node - index::Int - location::Union{Plant,CollectionCenter} - product::Product - incoming_arcs::Array{Arc} - outgoing_arcs::Array{Arc} -end - - -mutable struct Graph - process_nodes::Array{ProcessNode} - plant_shipping_nodes::Array{ShippingNode} - collection_shipping_nodes::Array{ShippingNode} - arcs::Array{Arc} +function calculate_distance(source_lat, source_lon, dest_lat, dest_lon)::Float64 + x = LLA(source_lat, source_lon, 0.0) + y = LLA(dest_lat, dest_lon, 0.0) + return round(distance(x, y) / 1000.0, digits = 2) end - function build_graph(instance::Instance)::Graph arcs = [] next_index = 0 @@ -105,19 +75,3 @@ function build_graph(instance::Instance)::Graph return Graph(process_nodes, plant_shipping_nodes, collection_shipping_nodes, arcs) end - - -function to_csv(graph::Graph) - result = "" - for a in graph.arcs - result *= "$(a.source.index),$(a.dest.index)\n" - end - return result -end - - -function calculate_distance(source_lat, source_lon, dest_lat, dest_lon)::Float64 - x = LLA(source_lat, source_lon, 0.0) - y = LLA(dest_lat, dest_lon, 0.0) - return round(distance(x, y) / 1000.0, digits = 2) -end diff --git a/src/graph/csv.jl b/src/graph/csv.jl new file mode 100644 index 0000000..a7d06b9 --- /dev/null +++ b/src/graph/csv.jl @@ -0,0 +1,11 @@ +# RELOG: Reverse Logistics Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +function to_csv(graph::Graph) + result = "" + for a in graph.arcs + result *= "$(a.source.index),$(a.dest.index)\n" + end + return result +end diff --git a/src/graph/structs.jl b/src/graph/structs.jl new file mode 100644 index 0000000..e384259 --- /dev/null +++ b/src/graph/structs.jl @@ -0,0 +1,35 @@ +# RELOG: Reverse Logistics Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using Geodesy + +abstract type Node end + +mutable struct Arc + source::Node + dest::Node + values::Dict{String,Float64} +end + +mutable struct ProcessNode <: Node + index::Int + location::Plant + incoming_arcs::Array{Arc} + outgoing_arcs::Array{Arc} +end + +mutable struct ShippingNode <: Node + index::Int + location::Union{Plant,CollectionCenter} + product::Product + incoming_arcs::Array{Arc} + outgoing_arcs::Array{Arc} +end + +mutable struct Graph + process_nodes::Array{ProcessNode} + plant_shipping_nodes::Array{ShippingNode} + collection_shipping_nodes::Array{ShippingNode} + arcs::Array{Arc} +end diff --git a/src/instance/compress.jl b/src/instance/compress.jl new file mode 100644 index 0000000..8051aac --- /dev/null +++ b/src/instance/compress.jl @@ -0,0 +1,60 @@ +# RELOG: Reverse Logistics Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using DataStructures +using JSON +using JSONSchema +using Printf +using Statistics + +""" + _compress(instance::Instance) + +Create a single-period instance from a multi-period one. Specifically, +replaces every time-dependent attribute, such as initial_amounts, +by a list with a single element, which is either a sum, an average, +or something else that makes sense to that specific attribute. +""" +function _compress(instance::Instance)::Instance + T = instance.time + compressed = deepcopy(instance) + compressed.time = 1 + compressed.building_period = [1] + + # Compress products + for p in compressed.products + p.transportation_cost = [mean(p.transportation_cost)] + p.transportation_energy = [mean(p.transportation_energy)] + for (emission_name, emission_value) in p.transportation_emissions + p.transportation_emissions[emission_name] = [mean(emission_value)] + end + end + + # Compress collection centers + for c in compressed.collection_centers + c.amount = [maximum(c.amount) * T] + end + + # Compress plants + for plant in compressed.plants + plant.energy = [mean(plant.energy)] + for (emission_name, emission_value) in plant.emissions + plant.emissions[emission_name] = [mean(emission_value)] + end + for s in plant.sizes + s.capacity *= T + s.variable_operating_cost = [mean(s.variable_operating_cost)] + s.opening_cost = [s.opening_cost[1]] + s.fixed_operating_cost = [sum(s.fixed_operating_cost)] + end + for (prod_name, disp_limit) in plant.disposal_limit + plant.disposal_limit[prod_name] = [sum(disp_limit)] + end + for (prod_name, disp_cost) in plant.disposal_cost + plant.disposal_cost[prod_name] = [mean(disp_cost)] + end + end + + return compressed +end diff --git a/src/instance.jl b/src/instance/parse.jl similarity index 63% rename from src/instance.jl rename to src/instance/parse.jl index 99d71c2..249dc7d 100644 --- a/src/instance.jl +++ b/src/instance/parse.jl @@ -8,85 +8,13 @@ using JSONSchema using Printf using Statistics - -mutable struct Product - name::String - transportation_cost::Array{Float64} - transportation_energy::Array{Float64} - transportation_emissions::Dict{String,Array{Float64}} -end - - -mutable struct CollectionCenter - index::Int64 - name::String - latitude::Float64 - longitude::Float64 - product::Product - amount::Array{Float64} -end - - -mutable struct PlantSize - capacity::Float64 - variable_operating_cost::Array{Float64} - fixed_operating_cost::Array{Float64} - opening_cost::Array{Float64} -end - - -mutable struct Plant - index::Int64 - plant_name::String - location_name::String - input::Product - output::Dict{Product,Float64} - latitude::Float64 - longitude::Float64 - disposal_limit::Dict{Product,Array{Float64}} - disposal_cost::Dict{Product,Array{Float64}} - sizes::Array{PlantSize} - energy::Array{Float64} - emissions::Dict{String,Array{Float64}} - storage_limit::Float64 - storage_cost::Array{Float64} -end - - -mutable struct Instance - time::Int64 - products::Array{Product,1} - collection_centers::Array{CollectionCenter,1} - plants::Array{Plant,1} - building_period::Array{Int64} -end - - -function validate(json, schema) - result = JSONSchema.validate(json, schema) - if result !== nothing - if result isa JSONSchema.SingleIssue - path = join(result.path, " → ") - if length(path) == 0 - path = "root" - end - msg = "$(result.msg) in $(path)" - else - msg = convert(String, result) - end - throw(msg) - end -end - - function parsefile(path::String)::Instance return RELOG.parse(JSON.parsefile(path)) end - function parse(json)::Instance basedir = dirname(@__FILE__) - json_schema = JSON.parsefile("$basedir/schemas/input.json") + json_schema = JSON.parsefile("$basedir/../schemas/input.json") validate(json, Schema(json_schema)) T = json["parameters"]["time horizon (years)"] @@ -238,55 +166,3 @@ function parse(json)::Instance return Instance(T, products, collection_centers, plants, building_period) end - - -""" - _compress(instance::Instance) - -Create a single-period instance from a multi-period one. Specifically, -replaces every time-dependent attribute, such as initial_amounts, -by a list with a single element, which is either a sum, an average, -or something else that makes sense to that specific attribute. -""" -function _compress(instance::Instance)::Instance - T = instance.time - compressed = deepcopy(instance) - compressed.time = 1 - compressed.building_period = [1] - - # Compress products - for p in compressed.products - p.transportation_cost = [mean(p.transportation_cost)] - p.transportation_energy = [mean(p.transportation_energy)] - for (emission_name, emission_value) in p.transportation_emissions - p.transportation_emissions[emission_name] = [mean(emission_value)] - end - end - - # Compress collection centers - for c in compressed.collection_centers - c.amount = [maximum(c.amount) * T] - end - - # Compress plants - for plant in compressed.plants - plant.energy = [mean(plant.energy)] - for (emission_name, emission_value) in plant.emissions - plant.emissions[emission_name] = [mean(emission_value)] - end - for s in plant.sizes - s.capacity *= T - s.variable_operating_cost = [mean(s.variable_operating_cost)] - s.opening_cost = [s.opening_cost[1]] - s.fixed_operating_cost = [sum(s.fixed_operating_cost)] - end - for (prod_name, disp_limit) in plant.disposal_limit - plant.disposal_limit[prod_name] = [sum(disp_limit)] - end - for (prod_name, disp_cost) in plant.disposal_cost - plant.disposal_cost[prod_name] = [mean(disp_cost)] - end - end - - return compressed -end diff --git a/src/instance/structs.jl b/src/instance/structs.jl new file mode 100644 index 0000000..93f5065 --- /dev/null +++ b/src/instance/structs.jl @@ -0,0 +1,57 @@ +# RELOG: Reverse Logistics Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using DataStructures +using JSON +using JSONSchema +using Printf +using Statistics + +mutable struct Product + name::String + transportation_cost::Array{Float64} + transportation_energy::Array{Float64} + transportation_emissions::Dict{String,Array{Float64}} +end + +mutable struct CollectionCenter + index::Int64 + name::String + latitude::Float64 + longitude::Float64 + product::Product + amount::Array{Float64} +end + +mutable struct PlantSize + capacity::Float64 + variable_operating_cost::Array{Float64} + fixed_operating_cost::Array{Float64} + opening_cost::Array{Float64} +end + +mutable struct Plant + index::Int64 + plant_name::String + location_name::String + input::Product + output::Dict{Product,Float64} + latitude::Float64 + longitude::Float64 + disposal_limit::Dict{Product,Array{Float64}} + disposal_cost::Dict{Product,Array{Float64}} + sizes::Array{PlantSize} + energy::Array{Float64} + emissions::Dict{String,Array{Float64}} + storage_limit::Float64 + storage_cost::Array{Float64} +end + +mutable struct Instance + time::Int64 + products::Array{Product,1} + collection_centers::Array{CollectionCenter,1} + plants::Array{Plant,1} + building_period::Array{Int64} +end diff --git a/src/instance/validate.jl b/src/instance/validate.jl new file mode 100644 index 0000000..10dee55 --- /dev/null +++ b/src/instance/validate.jl @@ -0,0 +1,25 @@ +# RELOG: Reverse Logistics Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using DataStructures +using JSON +using JSONSchema +using Printf +using Statistics + +function validate(json, schema) + result = JSONSchema.validate(json, schema) + if result !== nothing + if result isa JSONSchema.SingleIssue + path = join(result.path, " → ") + if length(path) == 0 + path = "root" + end + msg = "$(result.msg) in $(path)" + else + msg = convert(String, result) + end + throw(msg) + end +end diff --git a/src/model.jl b/src/model.jl deleted file mode 100644 index 7ceace4..0000000 --- a/src/model.jl +++ /dev/null @@ -1,559 +0,0 @@ -# RELOG: Reverse Logistics Optimization -# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -using JuMP, LinearAlgebra, Geodesy, Cbc, Clp, ProgressBars, Printf, DataStructures - - -function build_model(instance::Instance, graph::Graph, optimizer)::JuMP.Model - model = Model(optimizer) - model[:instance] = instance - model[:graph] = graph - create_vars!(model) - create_objective_function!(model) - create_shipping_node_constraints!(model) - create_process_node_constraints!(model) - return model -end - - -function create_vars!(model::JuMP.Model) - graph, T = model[:graph], model[:instance].time - model[:flow] = - Dict((a, t) => @variable(model, lower_bound = 0) for a in graph.arcs, t = 1:T) - model[:dispose] = Dict( - (n, t) => @variable( - model, - lower_bound = 0, - upper_bound = n.location.disposal_limit[n.product][t] - ) for n in values(graph.plant_shipping_nodes), t = 1:T - ) - model[:store] = Dict( - (n, t) => - @variable(model, lower_bound = 0, upper_bound = n.location.storage_limit) - for n in values(graph.process_nodes), t = 1:T - ) - model[:process] = Dict( - (n, t) => @variable(model, lower_bound = 0) for - n in values(graph.process_nodes), t = 1:T - ) - model[:open_plant] = Dict( - (n, t) => @variable(model, binary = true) for n in values(graph.process_nodes), - t = 1:T - ) - model[:is_open] = Dict( - (n, t) => @variable(model, binary = true) for n in values(graph.process_nodes), - t = 1:T - ) - model[:capacity] = Dict( - (n, t) => @variable( - model, - lower_bound = 0, - upper_bound = n.location.sizes[2].capacity - ) for n in values(graph.process_nodes), t = 1:T - ) - model[:expansion] = Dict( - (n, t) => @variable( - model, - lower_bound = 0, - upper_bound = n.location.sizes[2].capacity - n.location.sizes[1].capacity - ) for n in values(graph.process_nodes), t = 1:T - ) -end - - -function slope_open(plant, t) - if plant.sizes[2].capacity <= plant.sizes[1].capacity - 0.0 - else - (plant.sizes[2].opening_cost[t] - plant.sizes[1].opening_cost[t]) / - (plant.sizes[2].capacity - plant.sizes[1].capacity) - end -end - -function slope_fix_oper_cost(plant, t) - if plant.sizes[2].capacity <= plant.sizes[1].capacity - 0.0 - else - (plant.sizes[2].fixed_operating_cost[t] - plant.sizes[1].fixed_operating_cost[t]) / - (plant.sizes[2].capacity - plant.sizes[1].capacity) - end -end - -function create_objective_function!(model::JuMP.Model) - graph, T = model[:graph], model[:instance].time - obj = AffExpr(0.0) - - # Process node costs - for n in values(graph.process_nodes), t = 1:T - - # Transportation and variable operating costs - for a in n.incoming_arcs - c = n.location.input.transportation_cost[t] * a.values["distance"] - add_to_expression!(obj, c, model[:flow][a, t]) - end - - # Opening costs - add_to_expression!( - obj, - n.location.sizes[1].opening_cost[t], - model[:open_plant][n, t], - ) - - # Fixed operating costs (base) - add_to_expression!( - obj, - n.location.sizes[1].fixed_operating_cost[t], - model[:is_open][n, t], - ) - - # Fixed operating costs (expansion) - add_to_expression!(obj, slope_fix_oper_cost(n.location, t), model[:expansion][n, t]) - - # Processing costs - add_to_expression!( - obj, - n.location.sizes[1].variable_operating_cost[t], - model[:process][n, t], - ) - - # Storage costs - add_to_expression!(obj, n.location.storage_cost[t], model[:store][n, t]) - - # Expansion costs - if t < T - add_to_expression!( - obj, - slope_open(n.location, t) - slope_open(n.location, t + 1), - model[:expansion][n, t], - ) - else - add_to_expression!(obj, slope_open(n.location, t), model[:expansion][n, t]) - end - end - - # Shipping node costs - for n in values(graph.plant_shipping_nodes), t = 1:T - - # Disposal costs - add_to_expression!( - obj, - n.location.disposal_cost[n.product][t], - model[:dispose][n, t], - ) - end - - @objective(model, Min, obj) -end - - -function create_shipping_node_constraints!(model::JuMP.Model) - graph, T = model[:graph], model[:instance].time - model[:eq_balance] = OrderedDict() - for t = 1:T - # Collection centers - for n in graph.collection_shipping_nodes - model[:eq_balance][n, t] = @constraint( - model, - sum(model[:flow][a, t] for a in n.outgoing_arcs) == n.location.amount[t] - ) - end - - # Plants - for n in graph.plant_shipping_nodes - @constraint( - model, - sum(model[:flow][a, t] for a in n.incoming_arcs) == - sum(model[:flow][a, t] for a in n.outgoing_arcs) + model[:dispose][n, t] - ) - end - end - -end - - -function create_process_node_constraints!(model::JuMP.Model) - graph, T = model[:graph], model[:instance].time - - for t = 1:T, n in graph.process_nodes - input_sum = AffExpr(0.0) - for a in n.incoming_arcs - add_to_expression!(input_sum, 1.0, model[:flow][a, t]) - end - - # Output amount is implied by amount processed - for a in n.outgoing_arcs - @constraint( - model, - model[:flow][a, t] == a.values["weight"] * model[:process][n, t] - ) - end - - # If plant is closed, capacity is zero - @constraint( - model, - model[:capacity][n, t] <= n.location.sizes[2].capacity * model[:is_open][n, t] - ) - - # If plant is open, capacity is greater than base - @constraint( - model, - model[:capacity][n, t] >= n.location.sizes[1].capacity * model[:is_open][n, t] - ) - - # Capacity is linked to expansion - @constraint( - model, - model[:capacity][n, t] <= - n.location.sizes[1].capacity + model[:expansion][n, t] - ) - - # Can only process up to capacity - @constraint(model, model[:process][n, t] <= model[:capacity][n, t]) - - if t > 1 - # Plant capacity can only increase over time - @constraint(model, model[:capacity][n, t] >= model[:capacity][n, t-1]) - @constraint(model, model[:expansion][n, t] >= model[:expansion][n, t-1]) - end - - # Amount received equals amount processed plus stored - store_in = 0 - if t > 1 - store_in = model[:store][n, t-1] - end - if t == T - @constraint(model, model[:store][n, t] == 0) - end - @constraint( - model, - input_sum + store_in == model[:store][n, t] + model[:process][n, t] - ) - - - # Plant is currently open if it was already open in the previous time period or - # if it was built just now - if t > 1 - @constraint( - model, - model[:is_open][n, t] == model[:is_open][n, t-1] + model[:open_plant][n, t] - ) - else - @constraint(model, model[:is_open][n, t] == model[:open_plant][n, t]) - end - - # Plant can only be opened during building period - if t ∉ model[:instance].building_period - @constraint(model, model[:open_plant][n, t] == 0) - end - end -end - -default_milp_optimizer = optimizer_with_attributes(Cbc.Optimizer, "logLevel" => 0) -default_lp_optimizer = optimizer_with_attributes(Clp.Optimizer, "LogLevel" => 0) - -function solve( - instance::Instance; - optimizer = nothing, - output = nothing, - marginal_costs = true, -) - - milp_optimizer = lp_optimizer = optimizer - if optimizer == nothing - milp_optimizer = default_milp_optimizer - lp_optimizer = default_lp_optimizer - end - - @info "Building graph..." - graph = RELOG.build_graph(instance) - @info @sprintf(" %12d time periods", instance.time) - @info @sprintf(" %12d process nodes", length(graph.process_nodes)) - @info @sprintf(" %12d shipping nodes (plant)", length(graph.plant_shipping_nodes)) - @info @sprintf( - " %12d shipping nodes (collection)", - length(graph.collection_shipping_nodes) - ) - @info @sprintf(" %12d arcs", length(graph.arcs)) - - @info "Building optimization model..." - model = RELOG.build_model(instance, graph, milp_optimizer) - - @info "Optimizing MILP..." - JuMP.optimize!(model) - - if !has_values(model) - @warn "No solution available" - return OrderedDict() - end - - if marginal_costs - @info "Re-optimizing with integer variables fixed..." - all_vars = JuMP.all_variables(model) - vals = OrderedDict(var => JuMP.value(var) for var in all_vars) - JuMP.set_optimizer(model, lp_optimizer) - for var in all_vars - if JuMP.is_binary(var) - JuMP.unset_binary(var) - JuMP.fix(var, vals[var]) - end - end - JuMP.optimize!(model) - end - - @info "Extracting solution..." - solution = get_solution(model, marginal_costs = marginal_costs) - - if output != nothing - write(solution, output) - end - - return solution -end - -function solve(filename::AbstractString; heuristic = false, kwargs...) - @info "Reading $filename..." - instance = RELOG.parsefile(filename) - if heuristic && instance.time > 1 - @info "Solving single-period version..." - compressed = _compress(instance) - csol = solve(compressed; output = nothing, marginal_costs = false, kwargs...) - @info "Filtering candidate locations..." - selected_pairs = [] - for (plant_name, plant_dict) in csol["Plants"] - for (location_name, location_dict) in plant_dict - push!(selected_pairs, (plant_name, location_name)) - end - end - filtered_plants = [] - for p in instance.plants - if (p.plant_name, p.location_name) in selected_pairs - push!(filtered_plants, p) - end - end - instance.plants = filtered_plants - @info "Solving original version..." - end - sol = solve(instance; kwargs...) - return sol -end - - -function get_solution(model::JuMP.Model; marginal_costs = true) - graph, instance = model[:graph], model[:instance] - T = instance.time - - output = OrderedDict( - "Plants" => OrderedDict(), - "Products" => OrderedDict(), - "Costs" => OrderedDict( - "Fixed operating (\$)" => zeros(T), - "Variable operating (\$)" => zeros(T), - "Opening (\$)" => zeros(T), - "Transportation (\$)" => zeros(T), - "Disposal (\$)" => zeros(T), - "Expansion (\$)" => zeros(T), - "Storage (\$)" => zeros(T), - "Total (\$)" => zeros(T), - ), - "Energy" => - OrderedDict("Plants (GJ)" => zeros(T), "Transportation (GJ)" => zeros(T)), - "Emissions" => OrderedDict( - "Plants (tonne)" => OrderedDict(), - "Transportation (tonne)" => OrderedDict(), - ), - ) - - plant_to_process_node = OrderedDict(n.location => n for n in graph.process_nodes) - plant_to_shipping_nodes = OrderedDict() - for p in instance.plants - plant_to_shipping_nodes[p] = [] - for a in plant_to_process_node[p].outgoing_arcs - push!(plant_to_shipping_nodes[p], a.dest) - end - end - - # Products - if marginal_costs - for n in graph.collection_shipping_nodes - location_dict = OrderedDict{Any,Any}( - "Marginal cost (\$/tonne)" => [ - round(abs(JuMP.shadow_price(model[:eq_balance][n, t])), digits = 2) for t = 1:T - ], - ) - if n.product.name ∉ keys(output["Products"]) - output["Products"][n.product.name] = OrderedDict() - end - output["Products"][n.product.name][n.location.name] = location_dict - end - end - - # Plants - for plant in instance.plants - skip_plant = true - process_node = plant_to_process_node[plant] - plant_dict = OrderedDict{Any,Any}( - "Input" => OrderedDict(), - "Output" => - OrderedDict("Send" => OrderedDict(), "Dispose" => OrderedDict()), - "Input product" => plant.input.name, - "Total input (tonne)" => [0.0 for t = 1:T], - "Total output" => OrderedDict(), - "Latitude (deg)" => plant.latitude, - "Longitude (deg)" => plant.longitude, - "Capacity (tonne)" => - [JuMP.value(model[:capacity][process_node, t]) for t = 1:T], - "Opening cost (\$)" => [ - JuMP.value(model[:open_plant][process_node, t]) * - plant.sizes[1].opening_cost[t] for t = 1:T - ], - "Fixed operating cost (\$)" => [ - JuMP.value(model[:is_open][process_node, t]) * - plant.sizes[1].fixed_operating_cost[t] + - JuMP.value(model[:expansion][process_node, t]) * - slope_fix_oper_cost(plant, t) for t = 1:T - ], - "Expansion cost (\$)" => [ - ( - if t == 1 - slope_open(plant, t) * JuMP.value(model[:expansion][process_node, t]) - else - slope_open(plant, t) * ( - JuMP.value(model[:expansion][process_node, t]) - - JuMP.value(model[:expansion][process_node, t-1]) - ) - end - ) for t = 1:T - ], - "Process (tonne)" => - [JuMP.value(model[:process][process_node, t]) for t = 1:T], - "Variable operating cost (\$)" => [ - JuMP.value(model[:process][process_node, t]) * - plant.sizes[1].variable_operating_cost[t] for t = 1:T - ], - "Storage (tonne)" => - [JuMP.value(model[:store][process_node, t]) for t = 1:T], - "Storage cost (\$)" => [ - JuMP.value(model[:store][process_node, t]) * plant.storage_cost[t] - for t = 1:T - ], - ) - output["Costs"]["Fixed operating (\$)"] += plant_dict["Fixed operating cost (\$)"] - output["Costs"]["Variable operating (\$)"] += - plant_dict["Variable operating cost (\$)"] - output["Costs"]["Opening (\$)"] += plant_dict["Opening cost (\$)"] - output["Costs"]["Expansion (\$)"] += plant_dict["Expansion cost (\$)"] - output["Costs"]["Storage (\$)"] += plant_dict["Storage cost (\$)"] - - # Inputs - for a in process_node.incoming_arcs - vals = [JuMP.value(model[:flow][a, t]) for t = 1:T] - if sum(vals) <= 1e-3 - continue - end - skip_plant = false - dict = OrderedDict{Any,Any}( - "Amount (tonne)" => vals, - "Distance (km)" => a.values["distance"], - "Latitude (deg)" => a.source.location.latitude, - "Longitude (deg)" => a.source.location.longitude, - "Transportation cost (\$)" => - a.source.product.transportation_cost .* vals .* a.values["distance"], - "Transportation energy (J)" => - vals .* a.values["distance"] .* a.source.product.transportation_energy, - "Emissions (tonne)" => OrderedDict(), - ) - emissions_dict = output["Emissions"]["Transportation (tonne)"] - for (em_name, em_values) in a.source.product.transportation_emissions - dict["Emissions (tonne)"][em_name] = - em_values .* dict["Amount (tonne)"] .* a.values["distance"] - if em_name ∉ keys(emissions_dict) - emissions_dict[em_name] = zeros(T) - end - emissions_dict[em_name] += dict["Emissions (tonne)"][em_name] - end - if a.source.location isa CollectionCenter - plant_name = "Origin" - location_name = a.source.location.name - else - plant_name = a.source.location.plant_name - location_name = a.source.location.location_name - end - - if plant_name ∉ keys(plant_dict["Input"]) - plant_dict["Input"][plant_name] = OrderedDict() - end - plant_dict["Input"][plant_name][location_name] = dict - plant_dict["Total input (tonne)"] += vals - output["Costs"]["Transportation (\$)"] += dict["Transportation cost (\$)"] - output["Energy"]["Transportation (GJ)"] += - dict["Transportation energy (J)"] / 1e9 - end - - plant_dict["Energy (GJ)"] = plant_dict["Total input (tonne)"] .* plant.energy - output["Energy"]["Plants (GJ)"] += plant_dict["Energy (GJ)"] - - plant_dict["Emissions (tonne)"] = OrderedDict() - emissions_dict = output["Emissions"]["Plants (tonne)"] - for (em_name, em_values) in plant.emissions - plant_dict["Emissions (tonne)"][em_name] = - em_values .* plant_dict["Total input (tonne)"] - if em_name ∉ keys(emissions_dict) - emissions_dict[em_name] = zeros(T) - end - emissions_dict[em_name] += plant_dict["Emissions (tonne)"][em_name] - end - - # Outputs - for shipping_node in plant_to_shipping_nodes[plant] - product_name = shipping_node.product.name - plant_dict["Total output"][product_name] = zeros(T) - plant_dict["Output"]["Send"][product_name] = product_dict = OrderedDict() - - disposal_amount = [JuMP.value(model[:dispose][shipping_node, t]) for t = 1:T] - if sum(disposal_amount) > 1e-5 - skip_plant = false - plant_dict["Output"]["Dispose"][product_name] = - disposal_dict = OrderedDict() - disposal_dict["Amount (tonne)"] = - [JuMP.value(model[:dispose][shipping_node, t]) for t = 1:T] - disposal_dict["Cost (\$)"] = [ - disposal_dict["Amount (tonne)"][t] * - plant.disposal_cost[shipping_node.product][t] for t = 1:T - ] - plant_dict["Total output"][product_name] += disposal_amount - output["Costs"]["Disposal (\$)"] += disposal_dict["Cost (\$)"] - end - - for a in shipping_node.outgoing_arcs - vals = [JuMP.value(model[:flow][a, t]) for t = 1:T] - if sum(vals) <= 1e-3 - continue - end - skip_plant = false - dict = OrderedDict( - "Amount (tonne)" => vals, - "Distance (km)" => a.values["distance"], - "Latitude (deg)" => a.dest.location.latitude, - "Longitude (deg)" => a.dest.location.longitude, - ) - if a.dest.location.plant_name ∉ keys(product_dict) - product_dict[a.dest.location.plant_name] = OrderedDict() - end - product_dict[a.dest.location.plant_name][a.dest.location.location_name] = - dict - plant_dict["Total output"][product_name] += vals - end - end - - if !skip_plant - if plant.plant_name ∉ keys(output["Plants"]) - output["Plants"][plant.plant_name] = OrderedDict() - end - output["Plants"][plant.plant_name][plant.location_name] = plant_dict - end - end - - output["Costs"]["Total (\$)"] = sum(values(output["Costs"])) - return output -end diff --git a/src/model/build.jl b/src/model/build.jl new file mode 100644 index 0000000..85f1b7e --- /dev/null +++ b/src/model/build.jl @@ -0,0 +1,250 @@ +# RELOG: Reverse Logistics Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using JuMP, LinearAlgebra, Geodesy, Cbc, Clp, ProgressBars, Printf, DataStructures + + +function build_model(instance::Instance, graph::Graph, optimizer)::JuMP.Model + model = Model(optimizer) + model[:instance] = instance + model[:graph] = graph + create_vars!(model) + create_objective_function!(model) + create_shipping_node_constraints!(model) + create_process_node_constraints!(model) + return model +end + + +function create_vars!(model::JuMP.Model) + graph, T = model[:graph], model[:instance].time + model[:flow] = + Dict((a, t) => @variable(model, lower_bound = 0) for a in graph.arcs, t = 1:T) + model[:dispose] = Dict( + (n, t) => @variable( + model, + lower_bound = 0, + upper_bound = n.location.disposal_limit[n.product][t] + ) for n in values(graph.plant_shipping_nodes), t = 1:T + ) + model[:store] = Dict( + (n, t) => + @variable(model, lower_bound = 0, upper_bound = n.location.storage_limit) + for n in values(graph.process_nodes), t = 1:T + ) + model[:process] = Dict( + (n, t) => @variable(model, lower_bound = 0) for + n in values(graph.process_nodes), t = 1:T + ) + model[:open_plant] = Dict( + (n, t) => @variable(model, binary = true) for n in values(graph.process_nodes), + t = 1:T + ) + model[:is_open] = Dict( + (n, t) => @variable(model, binary = true) for n in values(graph.process_nodes), + t = 1:T + ) + model[:capacity] = Dict( + (n, t) => @variable( + model, + lower_bound = 0, + upper_bound = n.location.sizes[2].capacity + ) for n in values(graph.process_nodes), t = 1:T + ) + model[:expansion] = Dict( + (n, t) => @variable( + model, + lower_bound = 0, + upper_bound = n.location.sizes[2].capacity - n.location.sizes[1].capacity + ) for n in values(graph.process_nodes), t = 1:T + ) +end + + +function slope_open(plant, t) + if plant.sizes[2].capacity <= plant.sizes[1].capacity + 0.0 + else + (plant.sizes[2].opening_cost[t] - plant.sizes[1].opening_cost[t]) / + (plant.sizes[2].capacity - plant.sizes[1].capacity) + end +end + +function slope_fix_oper_cost(plant, t) + if plant.sizes[2].capacity <= plant.sizes[1].capacity + 0.0 + else + (plant.sizes[2].fixed_operating_cost[t] - plant.sizes[1].fixed_operating_cost[t]) / + (plant.sizes[2].capacity - plant.sizes[1].capacity) + end +end + +function create_objective_function!(model::JuMP.Model) + graph, T = model[:graph], model[:instance].time + obj = AffExpr(0.0) + + # Process node costs + for n in values(graph.process_nodes), t = 1:T + + # Transportation and variable operating costs + for a in n.incoming_arcs + c = n.location.input.transportation_cost[t] * a.values["distance"] + add_to_expression!(obj, c, model[:flow][a, t]) + end + + # Opening costs + add_to_expression!( + obj, + n.location.sizes[1].opening_cost[t], + model[:open_plant][n, t], + ) + + # Fixed operating costs (base) + add_to_expression!( + obj, + n.location.sizes[1].fixed_operating_cost[t], + model[:is_open][n, t], + ) + + # Fixed operating costs (expansion) + add_to_expression!(obj, slope_fix_oper_cost(n.location, t), model[:expansion][n, t]) + + # Processing costs + add_to_expression!( + obj, + n.location.sizes[1].variable_operating_cost[t], + model[:process][n, t], + ) + + # Storage costs + add_to_expression!(obj, n.location.storage_cost[t], model[:store][n, t]) + + # Expansion costs + if t < T + add_to_expression!( + obj, + slope_open(n.location, t) - slope_open(n.location, t + 1), + model[:expansion][n, t], + ) + else + add_to_expression!(obj, slope_open(n.location, t), model[:expansion][n, t]) + end + end + + # Shipping node costs + for n in values(graph.plant_shipping_nodes), t = 1:T + + # Disposal costs + add_to_expression!( + obj, + n.location.disposal_cost[n.product][t], + model[:dispose][n, t], + ) + end + + @objective(model, Min, obj) +end + + +function create_shipping_node_constraints!(model::JuMP.Model) + graph, T = model[:graph], model[:instance].time + model[:eq_balance] = OrderedDict() + for t = 1:T + # Collection centers + for n in graph.collection_shipping_nodes + model[:eq_balance][n, t] = @constraint( + model, + sum(model[:flow][a, t] for a in n.outgoing_arcs) == n.location.amount[t] + ) + end + + # Plants + for n in graph.plant_shipping_nodes + @constraint( + model, + sum(model[:flow][a, t] for a in n.incoming_arcs) == + sum(model[:flow][a, t] for a in n.outgoing_arcs) + model[:dispose][n, t] + ) + end + end + +end + + +function create_process_node_constraints!(model::JuMP.Model) + graph, T = model[:graph], model[:instance].time + + for t = 1:T, n in graph.process_nodes + input_sum = AffExpr(0.0) + for a in n.incoming_arcs + add_to_expression!(input_sum, 1.0, model[:flow][a, t]) + end + + # Output amount is implied by amount processed + for a in n.outgoing_arcs + @constraint( + model, + model[:flow][a, t] == a.values["weight"] * model[:process][n, t] + ) + end + + # If plant is closed, capacity is zero + @constraint( + model, + model[:capacity][n, t] <= n.location.sizes[2].capacity * model[:is_open][n, t] + ) + + # If plant is open, capacity is greater than base + @constraint( + model, + model[:capacity][n, t] >= n.location.sizes[1].capacity * model[:is_open][n, t] + ) + + # Capacity is linked to expansion + @constraint( + model, + model[:capacity][n, t] <= + n.location.sizes[1].capacity + model[:expansion][n, t] + ) + + # Can only process up to capacity + @constraint(model, model[:process][n, t] <= model[:capacity][n, t]) + + if t > 1 + # Plant capacity can only increase over time + @constraint(model, model[:capacity][n, t] >= model[:capacity][n, t-1]) + @constraint(model, model[:expansion][n, t] >= model[:expansion][n, t-1]) + end + + # Amount received equals amount processed plus stored + store_in = 0 + if t > 1 + store_in = model[:store][n, t-1] + end + if t == T + @constraint(model, model[:store][n, t] == 0) + end + @constraint( + model, + input_sum + store_in == model[:store][n, t] + model[:process][n, t] + ) + + + # Plant is currently open if it was already open in the previous time period or + # if it was built just now + if t > 1 + @constraint( + model, + model[:is_open][n, t] == model[:is_open][n, t-1] + model[:open_plant][n, t] + ) + else + @constraint(model, model[:is_open][n, t] == model[:open_plant][n, t]) + end + + # Plant can only be opened during building period + if t ∉ model[:instance].building_period + @constraint(model, model[:open_plant][n, t] == 0) + end + end +end diff --git a/src/model/getsol.jl b/src/model/getsol.jl new file mode 100644 index 0000000..92c2e16 --- /dev/null +++ b/src/model/getsol.jl @@ -0,0 +1,224 @@ +# RELOG: Reverse Logistics Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using JuMP, LinearAlgebra, Geodesy, Cbc, Clp, ProgressBars, Printf, DataStructures + +function get_solution(model::JuMP.Model; marginal_costs = true) + graph, instance = model[:graph], model[:instance] + T = instance.time + + output = OrderedDict( + "Plants" => OrderedDict(), + "Products" => OrderedDict(), + "Costs" => OrderedDict( + "Fixed operating (\$)" => zeros(T), + "Variable operating (\$)" => zeros(T), + "Opening (\$)" => zeros(T), + "Transportation (\$)" => zeros(T), + "Disposal (\$)" => zeros(T), + "Expansion (\$)" => zeros(T), + "Storage (\$)" => zeros(T), + "Total (\$)" => zeros(T), + ), + "Energy" => + OrderedDict("Plants (GJ)" => zeros(T), "Transportation (GJ)" => zeros(T)), + "Emissions" => OrderedDict( + "Plants (tonne)" => OrderedDict(), + "Transportation (tonne)" => OrderedDict(), + ), + ) + + plant_to_process_node = OrderedDict(n.location => n for n in graph.process_nodes) + plant_to_shipping_nodes = OrderedDict() + for p in instance.plants + plant_to_shipping_nodes[p] = [] + for a in plant_to_process_node[p].outgoing_arcs + push!(plant_to_shipping_nodes[p], a.dest) + end + end + + # Products + if marginal_costs + for n in graph.collection_shipping_nodes + location_dict = OrderedDict{Any,Any}( + "Marginal cost (\$/tonne)" => [ + round(abs(JuMP.shadow_price(model[:eq_balance][n, t])), digits = 2) for t = 1:T + ], + ) + if n.product.name ∉ keys(output["Products"]) + output["Products"][n.product.name] = OrderedDict() + end + output["Products"][n.product.name][n.location.name] = location_dict + end + end + + # Plants + for plant in instance.plants + skip_plant = true + process_node = plant_to_process_node[plant] + plant_dict = OrderedDict{Any,Any}( + "Input" => OrderedDict(), + "Output" => + OrderedDict("Send" => OrderedDict(), "Dispose" => OrderedDict()), + "Input product" => plant.input.name, + "Total input (tonne)" => [0.0 for t = 1:T], + "Total output" => OrderedDict(), + "Latitude (deg)" => plant.latitude, + "Longitude (deg)" => plant.longitude, + "Capacity (tonne)" => + [JuMP.value(model[:capacity][process_node, t]) for t = 1:T], + "Opening cost (\$)" => [ + JuMP.value(model[:open_plant][process_node, t]) * + plant.sizes[1].opening_cost[t] for t = 1:T + ], + "Fixed operating cost (\$)" => [ + JuMP.value(model[:is_open][process_node, t]) * + plant.sizes[1].fixed_operating_cost[t] + + JuMP.value(model[:expansion][process_node, t]) * + slope_fix_oper_cost(plant, t) for t = 1:T + ], + "Expansion cost (\$)" => [ + ( + if t == 1 + slope_open(plant, t) * JuMP.value(model[:expansion][process_node, t]) + else + slope_open(plant, t) * ( + JuMP.value(model[:expansion][process_node, t]) - + JuMP.value(model[:expansion][process_node, t-1]) + ) + end + ) for t = 1:T + ], + "Process (tonne)" => + [JuMP.value(model[:process][process_node, t]) for t = 1:T], + "Variable operating cost (\$)" => [ + JuMP.value(model[:process][process_node, t]) * + plant.sizes[1].variable_operating_cost[t] for t = 1:T + ], + "Storage (tonne)" => + [JuMP.value(model[:store][process_node, t]) for t = 1:T], + "Storage cost (\$)" => [ + JuMP.value(model[:store][process_node, t]) * plant.storage_cost[t] + for t = 1:T + ], + ) + output["Costs"]["Fixed operating (\$)"] += plant_dict["Fixed operating cost (\$)"] + output["Costs"]["Variable operating (\$)"] += + plant_dict["Variable operating cost (\$)"] + output["Costs"]["Opening (\$)"] += plant_dict["Opening cost (\$)"] + output["Costs"]["Expansion (\$)"] += plant_dict["Expansion cost (\$)"] + output["Costs"]["Storage (\$)"] += plant_dict["Storage cost (\$)"] + + # Inputs + for a in process_node.incoming_arcs + vals = [JuMP.value(model[:flow][a, t]) for t = 1:T] + if sum(vals) <= 1e-3 + continue + end + skip_plant = false + dict = OrderedDict{Any,Any}( + "Amount (tonne)" => vals, + "Distance (km)" => a.values["distance"], + "Latitude (deg)" => a.source.location.latitude, + "Longitude (deg)" => a.source.location.longitude, + "Transportation cost (\$)" => + a.source.product.transportation_cost .* vals .* a.values["distance"], + "Transportation energy (J)" => + vals .* a.values["distance"] .* a.source.product.transportation_energy, + "Emissions (tonne)" => OrderedDict(), + ) + emissions_dict = output["Emissions"]["Transportation (tonne)"] + for (em_name, em_values) in a.source.product.transportation_emissions + dict["Emissions (tonne)"][em_name] = + em_values .* dict["Amount (tonne)"] .* a.values["distance"] + if em_name ∉ keys(emissions_dict) + emissions_dict[em_name] = zeros(T) + end + emissions_dict[em_name] += dict["Emissions (tonne)"][em_name] + end + if a.source.location isa CollectionCenter + plant_name = "Origin" + location_name = a.source.location.name + else + plant_name = a.source.location.plant_name + location_name = a.source.location.location_name + end + + if plant_name ∉ keys(plant_dict["Input"]) + plant_dict["Input"][plant_name] = OrderedDict() + end + plant_dict["Input"][plant_name][location_name] = dict + plant_dict["Total input (tonne)"] += vals + output["Costs"]["Transportation (\$)"] += dict["Transportation cost (\$)"] + output["Energy"]["Transportation (GJ)"] += + dict["Transportation energy (J)"] / 1e9 + end + + plant_dict["Energy (GJ)"] = plant_dict["Total input (tonne)"] .* plant.energy + output["Energy"]["Plants (GJ)"] += plant_dict["Energy (GJ)"] + + plant_dict["Emissions (tonne)"] = OrderedDict() + emissions_dict = output["Emissions"]["Plants (tonne)"] + for (em_name, em_values) in plant.emissions + plant_dict["Emissions (tonne)"][em_name] = + em_values .* plant_dict["Total input (tonne)"] + if em_name ∉ keys(emissions_dict) + emissions_dict[em_name] = zeros(T) + end + emissions_dict[em_name] += plant_dict["Emissions (tonne)"][em_name] + end + + # Outputs + for shipping_node in plant_to_shipping_nodes[plant] + product_name = shipping_node.product.name + plant_dict["Total output"][product_name] = zeros(T) + plant_dict["Output"]["Send"][product_name] = product_dict = OrderedDict() + + disposal_amount = [JuMP.value(model[:dispose][shipping_node, t]) for t = 1:T] + if sum(disposal_amount) > 1e-5 + skip_plant = false + plant_dict["Output"]["Dispose"][product_name] = + disposal_dict = OrderedDict() + disposal_dict["Amount (tonne)"] = + [JuMP.value(model[:dispose][shipping_node, t]) for t = 1:T] + disposal_dict["Cost (\$)"] = [ + disposal_dict["Amount (tonne)"][t] * + plant.disposal_cost[shipping_node.product][t] for t = 1:T + ] + plant_dict["Total output"][product_name] += disposal_amount + output["Costs"]["Disposal (\$)"] += disposal_dict["Cost (\$)"] + end + + for a in shipping_node.outgoing_arcs + vals = [JuMP.value(model[:flow][a, t]) for t = 1:T] + if sum(vals) <= 1e-3 + continue + end + skip_plant = false + dict = OrderedDict( + "Amount (tonne)" => vals, + "Distance (km)" => a.values["distance"], + "Latitude (deg)" => a.dest.location.latitude, + "Longitude (deg)" => a.dest.location.longitude, + ) + if a.dest.location.plant_name ∉ keys(product_dict) + product_dict[a.dest.location.plant_name] = OrderedDict() + end + product_dict[a.dest.location.plant_name][a.dest.location.location_name] = + dict + plant_dict["Total output"][product_name] += vals + end + end + + if !skip_plant + if plant.plant_name ∉ keys(output["Plants"]) + output["Plants"][plant.plant_name] = OrderedDict() + end + output["Plants"][plant.plant_name][plant.location_name] = plant_dict + end + end + + output["Costs"]["Total (\$)"] = sum(values(output["Costs"])) + return output +end diff --git a/src/model/solve.jl b/src/model/solve.jl new file mode 100644 index 0000000..68ab553 --- /dev/null +++ b/src/model/solve.jl @@ -0,0 +1,94 @@ +# RELOG: Reverse Logistics Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using JuMP, LinearAlgebra, Geodesy, Cbc, Clp, ProgressBars, Printf, DataStructures + +default_milp_optimizer = optimizer_with_attributes(Cbc.Optimizer, "logLevel" => 0) +default_lp_optimizer = optimizer_with_attributes(Clp.Optimizer, "LogLevel" => 0) + +function solve( + instance::Instance; + optimizer = nothing, + output = nothing, + marginal_costs = true, +) + + milp_optimizer = lp_optimizer = optimizer + if optimizer == nothing + milp_optimizer = default_milp_optimizer + lp_optimizer = default_lp_optimizer + end + + @info "Building graph..." + graph = RELOG.build_graph(instance) + @info @sprintf(" %12d time periods", instance.time) + @info @sprintf(" %12d process nodes", length(graph.process_nodes)) + @info @sprintf(" %12d shipping nodes (plant)", length(graph.plant_shipping_nodes)) + @info @sprintf( + " %12d shipping nodes (collection)", + length(graph.collection_shipping_nodes) + ) + @info @sprintf(" %12d arcs", length(graph.arcs)) + + @info "Building optimization model..." + model = RELOG.build_model(instance, graph, milp_optimizer) + + @info "Optimizing MILP..." + JuMP.optimize!(model) + + if !has_values(model) + @warn "No solution available" + return OrderedDict() + end + + if marginal_costs + @info "Re-optimizing with integer variables fixed..." + all_vars = JuMP.all_variables(model) + vals = OrderedDict(var => JuMP.value(var) for var in all_vars) + JuMP.set_optimizer(model, lp_optimizer) + for var in all_vars + if JuMP.is_binary(var) + JuMP.unset_binary(var) + JuMP.fix(var, vals[var]) + end + end + JuMP.optimize!(model) + end + + @info "Extracting solution..." + solution = get_solution(model, marginal_costs = marginal_costs) + + if output != nothing + write(solution, output) + end + + return solution +end + +function solve(filename::AbstractString; heuristic = false, kwargs...) + @info "Reading $filename..." + instance = RELOG.parsefile(filename) + if heuristic && instance.time > 1 + @info "Solving single-period version..." + compressed = _compress(instance) + csol = solve(compressed; output = nothing, marginal_costs = false, kwargs...) + @info "Filtering candidate locations..." + selected_pairs = [] + for (plant_name, plant_dict) in csol["Plants"] + for (location_name, location_dict) in plant_dict + push!(selected_pairs, (plant_name, location_name)) + end + end + filtered_plants = [] + for p in instance.plants + if (p.plant_name, p.location_name) in selected_pairs + push!(filtered_plants, p) + end + end + instance.plants = filtered_plants + @info "Solving original version..." + end + sol = solve(instance; kwargs...) + return sol +end diff --git a/src/reports.jl b/src/reports.jl deleted file mode 100644 index 6aedb5f..0000000 --- a/src/reports.jl +++ /dev/null @@ -1,315 +0,0 @@ -# RELOG: Reverse Logistics Optimization -# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -using DataFrames -using CSV - -function plants_report(solution)::DataFrame - df = DataFrame() - df."plant type" = String[] - df."location name" = String[] - df."year" = Int[] - df."latitude (deg)" = Float64[] - df."longitude (deg)" = Float64[] - df."capacity (tonne)" = Float64[] - df."amount processed (tonne)" = Float64[] - df."amount received (tonne)" = Float64[] - df."amount in storage (tonne)" = Float64[] - df."utilization factor (%)" = Float64[] - df."energy (GJ)" = Float64[] - df."opening cost (\$)" = Float64[] - df."expansion cost (\$)" = Float64[] - df."fixed operating cost (\$)" = Float64[] - df."variable operating cost (\$)" = Float64[] - df."storage cost (\$)" = Float64[] - df."total cost (\$)" = Float64[] - T = length(solution["Energy"]["Plants (GJ)"]) - for (plant_name, plant_dict) in solution["Plants"] - for (location_name, location_dict) in plant_dict - for year = 1:T - capacity = round(location_dict["Capacity (tonne)"][year], digits = 2) - received = round(location_dict["Total input (tonne)"][year], digits = 2) - processed = round(location_dict["Process (tonne)"][year], digits = 2) - in_storage = round(location_dict["Storage (tonne)"][year], digits = 2) - utilization_factor = round(processed / capacity * 100.0, digits = 2) - energy = round(location_dict["Energy (GJ)"][year], digits = 2) - latitude = round(location_dict["Latitude (deg)"], digits = 6) - longitude = round(location_dict["Longitude (deg)"], digits = 6) - opening_cost = round(location_dict["Opening cost (\$)"][year], digits = 2) - expansion_cost = - round(location_dict["Expansion cost (\$)"][year], digits = 2) - fixed_cost = - round(location_dict["Fixed operating cost (\$)"][year], digits = 2) - var_cost = - round(location_dict["Variable operating cost (\$)"][year], digits = 2) - storage_cost = round(location_dict["Storage cost (\$)"][year], digits = 2) - total_cost = round( - opening_cost + expansion_cost + fixed_cost + var_cost + storage_cost, - digits = 2, - ) - push!( - df, - [ - plant_name, - location_name, - year, - latitude, - longitude, - capacity, - processed, - received, - in_storage, - utilization_factor, - energy, - opening_cost, - expansion_cost, - fixed_cost, - var_cost, - storage_cost, - total_cost, - ], - ) - end - end - end - return df -end - -function plant_outputs_report(solution)::DataFrame - df = DataFrame() - df."plant type" = String[] - df."location name" = String[] - df."year" = Int[] - df."product name" = String[] - df."amount produced (tonne)" = Float64[] - df."amount sent (tonne)" = Float64[] - df."amount disposed (tonne)" = Float64[] - df."disposal cost (\$)" = Float64[] - T = length(solution["Energy"]["Plants (GJ)"]) - for (plant_name, plant_dict) in solution["Plants"] - for (location_name, location_dict) in plant_dict - for (product_name, amount_produced) in location_dict["Total output"] - send_dict = location_dict["Output"]["Send"] - disposal_dict = location_dict["Output"]["Dispose"] - - sent = zeros(T) - if product_name in keys(send_dict) - for (dst_plant_name, dst_plant_dict) in send_dict[product_name] - for (dst_location_name, dst_location_dict) in dst_plant_dict - sent += dst_location_dict["Amount (tonne)"] - end - end - end - sent = round.(sent, digits = 2) - - disposal_amount = zeros(T) - disposal_cost = zeros(T) - if product_name in keys(disposal_dict) - disposal_amount += disposal_dict[product_name]["Amount (tonne)"] - disposal_cost += disposal_dict[product_name]["Cost (\$)"] - end - disposal_amount = round.(disposal_amount, digits = 2) - disposal_cost = round.(disposal_cost, digits = 2) - - for year = 1:T - push!( - df, - [ - plant_name, - location_name, - year, - product_name, - round(amount_produced[year], digits = 2), - sent[year], - disposal_amount[year], - disposal_cost[year], - ], - ) - end - end - end - end - return df -end - - -function plant_emissions_report(solution)::DataFrame - df = DataFrame() - df."plant type" = String[] - df."location name" = String[] - df."year" = Int[] - df."emission type" = String[] - df."emission amount (tonne)" = Float64[] - T = length(solution["Energy"]["Plants (GJ)"]) - for (plant_name, plant_dict) in solution["Plants"] - for (location_name, location_dict) in plant_dict - for (emission_name, emission_amount) in location_dict["Emissions (tonne)"] - for year = 1:T - push!( - df, - [ - plant_name, - location_name, - year, - emission_name, - round(emission_amount[year], digits = 2), - ], - ) - end - end - end - end - return df -end - - -function transportation_report(solution)::DataFrame - df = DataFrame() - df."source type" = String[] - df."source location name" = String[] - df."source latitude (deg)" = Float64[] - df."source longitude (deg)" = Float64[] - df."destination type" = String[] - df."destination location name" = String[] - df."destination latitude (deg)" = Float64[] - df."destination longitude (deg)" = Float64[] - df."product" = String[] - df."year" = Int[] - df."distance (km)" = Float64[] - df."amount (tonne)" = Float64[] - df."amount-distance (tonne-km)" = Float64[] - df."transportation cost (\$)" = Float64[] - df."transportation energy (GJ)" = Float64[] - - T = length(solution["Energy"]["Plants (GJ)"]) - for (dst_plant_name, dst_plant_dict) in solution["Plants"] - for (dst_location_name, dst_location_dict) in dst_plant_dict - for (src_plant_name, src_plant_dict) in dst_location_dict["Input"] - for (src_location_name, src_location_dict) in src_plant_dict - for year = 1:T - push!( - df, - [ - src_plant_name, - src_location_name, - round(src_location_dict["Latitude (deg)"], digits = 6), - round(src_location_dict["Longitude (deg)"], digits = 6), - dst_plant_name, - dst_location_name, - round(dst_location_dict["Latitude (deg)"], digits = 6), - round(dst_location_dict["Longitude (deg)"], digits = 6), - dst_location_dict["Input product"], - year, - round(src_location_dict["Distance (km)"], digits = 2), - round( - src_location_dict["Amount (tonne)"][year], - digits = 2, - ), - round( - src_location_dict["Amount (tonne)"][year] * - src_location_dict["Distance (km)"], - digits = 2, - ), - round( - src_location_dict["Transportation cost (\$)"][year], - digits = 2, - ), - round( - src_location_dict["Transportation energy (J)"][year] / - 1e9, - digits = 2, - ), - ], - ) - end - end - end - end - end - return df -end - - -function transportation_emissions_report(solution)::DataFrame - df = DataFrame() - df."source type" = String[] - df."source location name" = String[] - df."source latitude (deg)" = Float64[] - df."source longitude (deg)" = Float64[] - df."destination type" = String[] - df."destination location name" = String[] - df."destination latitude (deg)" = Float64[] - df."destination longitude (deg)" = Float64[] - df."product" = String[] - df."year" = Int[] - df."distance (km)" = Float64[] - df."shipped amount (tonne)" = Float64[] - df."shipped amount-distance (tonne-km)" = Float64[] - df."emission type" = String[] - df."emission amount (tonne)" = Float64[] - - T = length(solution["Energy"]["Plants (GJ)"]) - for (dst_plant_name, dst_plant_dict) in solution["Plants"] - for (dst_location_name, dst_location_dict) in dst_plant_dict - for (src_plant_name, src_plant_dict) in dst_location_dict["Input"] - for (src_location_name, src_location_dict) in src_plant_dict - for (emission_name, emission_amount) in - src_location_dict["Emissions (tonne)"] - for year = 1:T - push!( - df, - [ - src_plant_name, - src_location_name, - round(src_location_dict["Latitude (deg)"], digits = 6), - round(src_location_dict["Longitude (deg)"], digits = 6), - dst_plant_name, - dst_location_name, - round(dst_location_dict["Latitude (deg)"], digits = 6), - round(dst_location_dict["Longitude (deg)"], digits = 6), - dst_location_dict["Input product"], - year, - round(src_location_dict["Distance (km)"], digits = 2), - round( - src_location_dict["Amount (tonne)"][year], - digits = 2, - ), - round( - src_location_dict["Amount (tonne)"][year] * - src_location_dict["Distance (km)"], - digits = 2, - ), - emission_name, - round(emission_amount[year], digits = 2), - ], - ) - end - end - end - end - end - end - return df -end - -function write(solution::AbstractDict, filename::AbstractString) - @info "Writing solution: $filename" - open(filename, "w") do file - JSON.print(file, solution, 2) - end -end - -write_plants_report(solution, filename) = CSV.write(filename, plants_report(solution)) - -write_plant_outputs_report(solution, filename) = - CSV.write(filename, plant_outputs_report(solution)) - -write_plant_emissions_report(solution, filename) = - CSV.write(filename, plant_emissions_report(solution)) - -write_transportation_report(solution, filename) = - CSV.write(filename, transportation_report(solution)) - -write_transportation_emissions_report(solution, filename) = - CSV.write(filename, transportation_emissions_report(solution)) diff --git a/src/reports/plant_emissions.jl b/src/reports/plant_emissions.jl new file mode 100644 index 0000000..cc420a0 --- /dev/null +++ b/src/reports/plant_emissions.jl @@ -0,0 +1,38 @@ +# RELOG: Reverse Logistics Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using DataFrames +using CSV + +function plant_emissions_report(solution)::DataFrame + df = DataFrame() + df."plant type" = String[] + df."location name" = String[] + df."year" = Int[] + df."emission type" = String[] + df."emission amount (tonne)" = Float64[] + T = length(solution["Energy"]["Plants (GJ)"]) + for (plant_name, plant_dict) in solution["Plants"] + for (location_name, location_dict) in plant_dict + for (emission_name, emission_amount) in location_dict["Emissions (tonne)"] + for year = 1:T + push!( + df, + [ + plant_name, + location_name, + year, + emission_name, + round(emission_amount[year], digits = 2), + ], + ) + end + end + end + end + return df +end + +write_plant_emissions_report(solution, filename) = + CSV.write(filename, plant_emissions_report(solution)) diff --git a/src/reports/plant_outputs.jl b/src/reports/plant_outputs.jl new file mode 100644 index 0000000..053f5ab --- /dev/null +++ b/src/reports/plant_outputs.jl @@ -0,0 +1,66 @@ +# RELOG: Reverse Logistics Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using DataFrames +using CSV + +function plant_outputs_report(solution)::DataFrame + df = DataFrame() + df."plant type" = String[] + df."location name" = String[] + df."year" = Int[] + df."product name" = String[] + df."amount produced (tonne)" = Float64[] + df."amount sent (tonne)" = Float64[] + df."amount disposed (tonne)" = Float64[] + df."disposal cost (\$)" = Float64[] + T = length(solution["Energy"]["Plants (GJ)"]) + for (plant_name, plant_dict) in solution["Plants"] + for (location_name, location_dict) in plant_dict + for (product_name, amount_produced) in location_dict["Total output"] + send_dict = location_dict["Output"]["Send"] + disposal_dict = location_dict["Output"]["Dispose"] + + sent = zeros(T) + if product_name in keys(send_dict) + for (dst_plant_name, dst_plant_dict) in send_dict[product_name] + for (dst_location_name, dst_location_dict) in dst_plant_dict + sent += dst_location_dict["Amount (tonne)"] + end + end + end + sent = round.(sent, digits = 2) + + disposal_amount = zeros(T) + disposal_cost = zeros(T) + if product_name in keys(disposal_dict) + disposal_amount += disposal_dict[product_name]["Amount (tonne)"] + disposal_cost += disposal_dict[product_name]["Cost (\$)"] + end + disposal_amount = round.(disposal_amount, digits = 2) + disposal_cost = round.(disposal_cost, digits = 2) + + for year = 1:T + push!( + df, + [ + plant_name, + location_name, + year, + product_name, + round(amount_produced[year], digits = 2), + sent[year], + disposal_amount[year], + disposal_cost[year], + ], + ) + end + end + end + end + return df +end + +write_plant_outputs_report(solution, filename) = + CSV.write(filename, plant_outputs_report(solution)) diff --git a/src/reports/plants.jl b/src/reports/plants.jl new file mode 100644 index 0000000..4d1a943 --- /dev/null +++ b/src/reports/plants.jl @@ -0,0 +1,79 @@ +# RELOG: Reverse Logistics Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using DataFrames +using CSV + +function plants_report(solution)::DataFrame + df = DataFrame() + df."plant type" = String[] + df."location name" = String[] + df."year" = Int[] + df."latitude (deg)" = Float64[] + df."longitude (deg)" = Float64[] + df."capacity (tonne)" = Float64[] + df."amount processed (tonne)" = Float64[] + df."amount received (tonne)" = Float64[] + df."amount in storage (tonne)" = Float64[] + df."utilization factor (%)" = Float64[] + df."energy (GJ)" = Float64[] + df."opening cost (\$)" = Float64[] + df."expansion cost (\$)" = Float64[] + df."fixed operating cost (\$)" = Float64[] + df."variable operating cost (\$)" = Float64[] + df."storage cost (\$)" = Float64[] + df."total cost (\$)" = Float64[] + T = length(solution["Energy"]["Plants (GJ)"]) + for (plant_name, plant_dict) in solution["Plants"] + for (location_name, location_dict) in plant_dict + for year = 1:T + capacity = round(location_dict["Capacity (tonne)"][year], digits = 2) + received = round(location_dict["Total input (tonne)"][year], digits = 2) + processed = round(location_dict["Process (tonne)"][year], digits = 2) + in_storage = round(location_dict["Storage (tonne)"][year], digits = 2) + utilization_factor = round(processed / capacity * 100.0, digits = 2) + energy = round(location_dict["Energy (GJ)"][year], digits = 2) + latitude = round(location_dict["Latitude (deg)"], digits = 6) + longitude = round(location_dict["Longitude (deg)"], digits = 6) + opening_cost = round(location_dict["Opening cost (\$)"][year], digits = 2) + expansion_cost = + round(location_dict["Expansion cost (\$)"][year], digits = 2) + fixed_cost = + round(location_dict["Fixed operating cost (\$)"][year], digits = 2) + var_cost = + round(location_dict["Variable operating cost (\$)"][year], digits = 2) + storage_cost = round(location_dict["Storage cost (\$)"][year], digits = 2) + total_cost = round( + opening_cost + expansion_cost + fixed_cost + var_cost + storage_cost, + digits = 2, + ) + push!( + df, + [ + plant_name, + location_name, + year, + latitude, + longitude, + capacity, + processed, + received, + in_storage, + utilization_factor, + energy, + opening_cost, + expansion_cost, + fixed_cost, + var_cost, + storage_cost, + total_cost, + ], + ) + end + end + end + return df +end + +write_plants_report(solution, filename) = CSV.write(filename, plants_report(solution)) diff --git a/src/reports/tr.jl b/src/reports/tr.jl new file mode 100644 index 0000000..5519a70 --- /dev/null +++ b/src/reports/tr.jl @@ -0,0 +1,75 @@ +# RELOG: Reverse Logistics Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using DataFrames +using CSV + +function transportation_report(solution)::DataFrame + df = DataFrame() + df."source type" = String[] + df."source location name" = String[] + df."source latitude (deg)" = Float64[] + df."source longitude (deg)" = Float64[] + df."destination type" = String[] + df."destination location name" = String[] + df."destination latitude (deg)" = Float64[] + df."destination longitude (deg)" = Float64[] + df."product" = String[] + df."year" = Int[] + df."distance (km)" = Float64[] + df."amount (tonne)" = Float64[] + df."amount-distance (tonne-km)" = Float64[] + df."transportation cost (\$)" = Float64[] + df."transportation energy (GJ)" = Float64[] + + T = length(solution["Energy"]["Plants (GJ)"]) + for (dst_plant_name, dst_plant_dict) in solution["Plants"] + for (dst_location_name, dst_location_dict) in dst_plant_dict + for (src_plant_name, src_plant_dict) in dst_location_dict["Input"] + for (src_location_name, src_location_dict) in src_plant_dict + for year = 1:T + push!( + df, + [ + src_plant_name, + src_location_name, + round(src_location_dict["Latitude (deg)"], digits = 6), + round(src_location_dict["Longitude (deg)"], digits = 6), + dst_plant_name, + dst_location_name, + round(dst_location_dict["Latitude (deg)"], digits = 6), + round(dst_location_dict["Longitude (deg)"], digits = 6), + dst_location_dict["Input product"], + year, + round(src_location_dict["Distance (km)"], digits = 2), + round( + src_location_dict["Amount (tonne)"][year], + digits = 2, + ), + round( + src_location_dict["Amount (tonne)"][year] * + src_location_dict["Distance (km)"], + digits = 2, + ), + round( + src_location_dict["Transportation cost (\$)"][year], + digits = 2, + ), + round( + src_location_dict["Transportation energy (J)"][year] / + 1e9, + digits = 2, + ), + ], + ) + end + end + end + end + end + return df +end + +write_transportation_report(solution, filename) = + CSV.write(filename, transportation_report(solution)) diff --git a/src/reports/tr_emissions.jl b/src/reports/tr_emissions.jl new file mode 100644 index 0000000..5f1cd47 --- /dev/null +++ b/src/reports/tr_emissions.jl @@ -0,0 +1,71 @@ +# RELOG: Reverse Logistics Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using DataFrames +using CSV + +function transportation_emissions_report(solution)::DataFrame + df = DataFrame() + df."source type" = String[] + df."source location name" = String[] + df."source latitude (deg)" = Float64[] + df."source longitude (deg)" = Float64[] + df."destination type" = String[] + df."destination location name" = String[] + df."destination latitude (deg)" = Float64[] + df."destination longitude (deg)" = Float64[] + df."product" = String[] + df."year" = Int[] + df."distance (km)" = Float64[] + df."shipped amount (tonne)" = Float64[] + df."shipped amount-distance (tonne-km)" = Float64[] + df."emission type" = String[] + df."emission amount (tonne)" = Float64[] + + T = length(solution["Energy"]["Plants (GJ)"]) + for (dst_plant_name, dst_plant_dict) in solution["Plants"] + for (dst_location_name, dst_location_dict) in dst_plant_dict + for (src_plant_name, src_plant_dict) in dst_location_dict["Input"] + for (src_location_name, src_location_dict) in src_plant_dict + for (emission_name, emission_amount) in + src_location_dict["Emissions (tonne)"] + for year = 1:T + push!( + df, + [ + src_plant_name, + src_location_name, + round(src_location_dict["Latitude (deg)"], digits = 6), + round(src_location_dict["Longitude (deg)"], digits = 6), + dst_plant_name, + dst_location_name, + round(dst_location_dict["Latitude (deg)"], digits = 6), + round(dst_location_dict["Longitude (deg)"], digits = 6), + dst_location_dict["Input product"], + year, + round(src_location_dict["Distance (km)"], digits = 2), + round( + src_location_dict["Amount (tonne)"][year], + digits = 2, + ), + round( + src_location_dict["Amount (tonne)"][year] * + src_location_dict["Distance (km)"], + digits = 2, + ), + emission_name, + round(emission_amount[year], digits = 2), + ], + ) + end + end + end + end + end + end + return df +end + +write_transportation_emissions_report(solution, filename) = + CSV.write(filename, transportation_emissions_report(solution)) diff --git a/src/reports/write.jl b/src/reports/write.jl new file mode 100644 index 0000000..bacf6d4 --- /dev/null +++ b/src/reports/write.jl @@ -0,0 +1,13 @@ +# RELOG: Reverse Logistics Optimization +# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using DataFrames +using CSV + +function write(solution::AbstractDict, filename::AbstractString) + @info "Writing solution: $filename" + open(filename, "w") do file + JSON.print(file, solution, 2) + end +end diff --git a/test/graph/build_test.jl b/test/graph/build_test.jl new file mode 100644 index 0000000..1b52f58 --- /dev/null +++ b/test/graph/build_test.jl @@ -0,0 +1,39 @@ +# Copyright (C) 2020 Argonne National Laboratory +# Written by Alinson Santos Xavier + +using RELOG + +@testset "build_graph" begin + basedir = dirname(@__FILE__) + instance = RELOG.parsefile("$basedir/../../instances/s1.json") + graph = RELOG.build_graph(instance) + process_node_by_location_name = + Dict(n.location.location_name => n for n in graph.process_nodes) + + @test length(graph.plant_shipping_nodes) == 8 + @test length(graph.collection_shipping_nodes) == 10 + @test length(graph.process_nodes) == 6 + + node = graph.collection_shipping_nodes[1] + @test node.location.name == "C1" + @test length(node.incoming_arcs) == 0 + @test length(node.outgoing_arcs) == 2 + @test node.outgoing_arcs[1].source.location.name == "C1" + @test node.outgoing_arcs[1].dest.location.plant_name == "F1" + @test node.outgoing_arcs[1].dest.location.location_name == "L1" + @test node.outgoing_arcs[1].values["distance"] == 1095.62 + + node = process_node_by_location_name["L1"] + @test node.location.plant_name == "F1" + @test node.location.location_name == "L1" + @test length(node.incoming_arcs) == 10 + @test length(node.outgoing_arcs) == 2 + + node = process_node_by_location_name["L3"] + @test node.location.plant_name == "F2" + @test node.location.location_name == "L3" + @test length(node.incoming_arcs) == 2 + @test length(node.outgoing_arcs) == 2 + + @test length(graph.arcs) == 38 +end diff --git a/test/graph_test.jl b/test/graph_test.jl deleted file mode 100644 index 872172f..0000000 --- a/test/graph_test.jl +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright (C) 2020 Argonne National Laboratory -# Written by Alinson Santos Xavier - -using RELOG - -@testset "Graph" begin - @testset "build_graph" begin - basedir = dirname(@__FILE__) - instance = RELOG.parsefile("$basedir/../instances/s1.json") - graph = RELOG.build_graph(instance) - process_node_by_location_name = - Dict(n.location.location_name => n for n in graph.process_nodes) - - @test length(graph.plant_shipping_nodes) == 8 - @test length(graph.collection_shipping_nodes) == 10 - @test length(graph.process_nodes) == 6 - - node = graph.collection_shipping_nodes[1] - @test node.location.name == "C1" - @test length(node.incoming_arcs) == 0 - @test length(node.outgoing_arcs) == 2 - @test node.outgoing_arcs[1].source.location.name == "C1" - @test node.outgoing_arcs[1].dest.location.plant_name == "F1" - @test node.outgoing_arcs[1].dest.location.location_name == "L1" - @test node.outgoing_arcs[1].values["distance"] == 1095.62 - - node = process_node_by_location_name["L1"] - @test node.location.plant_name == "F1" - @test node.location.location_name == "L1" - @test length(node.incoming_arcs) == 10 - @test length(node.outgoing_arcs) == 2 - - node = process_node_by_location_name["L3"] - @test node.location.plant_name == "F2" - @test node.location.location_name == "L3" - @test length(node.incoming_arcs) == 2 - @test length(node.outgoing_arcs) == 2 - - @test length(graph.arcs) == 38 - end -end diff --git a/test/instance/compress_test.jl b/test/instance/compress_test.jl new file mode 100644 index 0000000..87d2a98 --- /dev/null +++ b/test/instance/compress_test.jl @@ -0,0 +1,53 @@ +# Copyright (C) 2020 Argonne National Laboratory +# Written by Alinson Santos Xavier + +using RELOG + +@testset "compress" begin + basedir = dirname(@__FILE__) + instance = RELOG.parsefile("$basedir/../../instances/s1.json") + compressed = RELOG._compress(instance) + + product_name_to_product = Dict(p.name => p for p in compressed.products) + location_name_to_facility = Dict() + for p in compressed.plants + location_name_to_facility[p.location_name] = p + end + for c in compressed.collection_centers + location_name_to_facility[c.name] = c + end + + p1 = product_name_to_product["P1"] + p2 = product_name_to_product["P2"] + p3 = product_name_to_product["P3"] + c1 = location_name_to_facility["C1"] + l1 = location_name_to_facility["L1"] + + @test compressed.time == 1 + @test compressed.building_period == [1] + + @test p1.name == "P1" + @test p1.transportation_cost ≈ [0.015] + @test p1.transportation_energy ≈ [0.115] + @test p1.transportation_emissions["CO2"] ≈ [0.051] + @test p1.transportation_emissions["CH4"] ≈ [0.0025] + + @test c1.name == "C1" + @test c1.amount ≈ [1869.12] + + @test l1.plant_name == "F1" + @test l1.location_name == "L1" + @test l1.energy ≈ [0.115] + @test l1.emissions["CO2"] ≈ [0.051] + @test l1.emissions["CH4"] ≈ [0.0025] + @test l1.sizes[1].opening_cost ≈ [500] + @test l1.sizes[2].opening_cost ≈ [1250] + @test l1.sizes[1].fixed_operating_cost ≈ [60] + @test l1.sizes[2].fixed_operating_cost ≈ [60] + @test l1.sizes[1].variable_operating_cost ≈ [30] + @test l1.sizes[2].variable_operating_cost ≈ [30] + @test l1.disposal_limit[p2] ≈ [2.0] + @test l1.disposal_limit[p3] ≈ [2.0] + @test l1.disposal_cost[p2] ≈ [-10.0] + @test l1.disposal_cost[p3] ≈ [-10.0] +end diff --git a/test/instance/parse_test.jl b/test/instance/parse_test.jl new file mode 100644 index 0000000..c23d8d3 --- /dev/null +++ b/test/instance/parse_test.jl @@ -0,0 +1,76 @@ +# Copyright (C) 2020 Argonne National Laboratory +# Written by Alinson Santos Xavier + +using RELOG + +@testset "parse" begin + basedir = dirname(@__FILE__) + instance = RELOG.parsefile("$basedir/../../instances/s1.json") + + centers = instance.collection_centers + plants = instance.plants + products = instance.products + location_name_to_plant = Dict(p.location_name => p for p in plants) + product_name_to_product = Dict(p.name => p for p in products) + + @test length(centers) == 10 + @test centers[1].name == "C1" + @test centers[1].latitude == 7 + @test centers[1].latitude == 7 + @test centers[1].longitude == 7 + @test centers[1].amount == [934.56, 934.56] + @test centers[1].product.name == "P1" + + @test length(plants) == 6 + + plant = location_name_to_plant["L1"] + @test plant.plant_name == "F1" + @test plant.location_name == "L1" + @test plant.input.name == "P1" + @test plant.latitude == 0 + @test plant.longitude == 0 + + @test length(plant.sizes) == 2 + @test plant.sizes[1].capacity == 250 + @test plant.sizes[1].opening_cost == [500, 500] + @test plant.sizes[1].fixed_operating_cost == [30, 30] + @test plant.sizes[1].variable_operating_cost == [30, 30] + @test plant.sizes[2].capacity == 1000 + @test plant.sizes[2].opening_cost == [1250, 1250] + @test plant.sizes[2].fixed_operating_cost == [30, 30] + @test plant.sizes[2].variable_operating_cost == [30, 30] + + p2 = product_name_to_product["P2"] + p3 = product_name_to_product["P3"] + @test length(plant.output) == 2 + @test plant.output[p2] == 0.2 + @test plant.output[p3] == 0.5 + @test plant.disposal_limit[p2] == [1, 1] + @test plant.disposal_limit[p3] == [1, 1] + @test plant.disposal_cost[p2] == [-10, -10] + @test plant.disposal_cost[p3] == [-10, -10] + + plant = location_name_to_plant["L3"] + @test plant.location_name == "L3" + @test plant.input.name == "P2" + @test plant.latitude == 25 + @test plant.longitude == 65 + + @test length(plant.sizes) == 2 + @test plant.sizes[1].capacity == 1000.0 + @test plant.sizes[1].opening_cost == [3000, 3000] + @test plant.sizes[1].fixed_operating_cost == [50, 50] + @test plant.sizes[1].variable_operating_cost == [50, 50] + @test plant.sizes[1] == plant.sizes[2] + + p4 = product_name_to_product["P4"] + @test plant.output[p3] == 0.05 + @test plant.output[p4] == 0.8 + @test plant.disposal_limit[p3] == [1e8, 1e8] + @test plant.disposal_limit[p4] == [0, 0] +end + +@testset "parse (invalid)" begin + basedir = dirname(@__FILE__) + @test_throws String RELOG.parsefile("$basedir/../fixtures/s1-wrong-length.json") +end diff --git a/test/instance_test.jl b/test/instance_test.jl deleted file mode 100644 index 2991cf9..0000000 --- a/test/instance_test.jl +++ /dev/null @@ -1,126 +0,0 @@ -# Copyright (C) 2020 Argonne National Laboratory -# Written by Alinson Santos Xavier - -using RELOG - -@testset "Instance" begin - @testset "load" begin - basedir = dirname(@__FILE__) - instance = RELOG.parsefile("$basedir/../instances/s1.json") - - centers = instance.collection_centers - plants = instance.plants - products = instance.products - location_name_to_plant = Dict(p.location_name => p for p in plants) - product_name_to_product = Dict(p.name => p for p in products) - - @test length(centers) == 10 - @test centers[1].name == "C1" - @test centers[1].latitude == 7 - @test centers[1].latitude == 7 - @test centers[1].longitude == 7 - @test centers[1].amount == [934.56, 934.56] - @test centers[1].product.name == "P1" - - @test length(plants) == 6 - - plant = location_name_to_plant["L1"] - @test plant.plant_name == "F1" - @test plant.location_name == "L1" - @test plant.input.name == "P1" - @test plant.latitude == 0 - @test plant.longitude == 0 - - @test length(plant.sizes) == 2 - @test plant.sizes[1].capacity == 250 - @test plant.sizes[1].opening_cost == [500, 500] - @test plant.sizes[1].fixed_operating_cost == [30, 30] - @test plant.sizes[1].variable_operating_cost == [30, 30] - @test plant.sizes[2].capacity == 1000 - @test plant.sizes[2].opening_cost == [1250, 1250] - @test plant.sizes[2].fixed_operating_cost == [30, 30] - @test plant.sizes[2].variable_operating_cost == [30, 30] - - p2 = product_name_to_product["P2"] - p3 = product_name_to_product["P3"] - @test length(plant.output) == 2 - @test plant.output[p2] == 0.2 - @test plant.output[p3] == 0.5 - @test plant.disposal_limit[p2] == [1, 1] - @test plant.disposal_limit[p3] == [1, 1] - @test plant.disposal_cost[p2] == [-10, -10] - @test plant.disposal_cost[p3] == [-10, -10] - - plant = location_name_to_plant["L3"] - @test plant.location_name == "L3" - @test plant.input.name == "P2" - @test plant.latitude == 25 - @test plant.longitude == 65 - - @test length(plant.sizes) == 2 - @test plant.sizes[1].capacity == 1000.0 - @test plant.sizes[1].opening_cost == [3000, 3000] - @test plant.sizes[1].fixed_operating_cost == [50, 50] - @test plant.sizes[1].variable_operating_cost == [50, 50] - @test plant.sizes[1] == plant.sizes[2] - - p4 = product_name_to_product["P4"] - @test plant.output[p3] == 0.05 - @test plant.output[p4] == 0.8 - @test plant.disposal_limit[p3] == [1e8, 1e8] - @test plant.disposal_limit[p4] == [0, 0] - end - - @testset "validate timeseries" begin - @test_throws String RELOG.parsefile("fixtures/s1-wrong-length.json") - end - - @testset "compress" begin - basedir = dirname(@__FILE__) - instance = RELOG.parsefile("$basedir/../instances/s1.json") - compressed = RELOG._compress(instance) - - product_name_to_product = Dict(p.name => p for p in compressed.products) - location_name_to_facility = Dict() - for p in compressed.plants - location_name_to_facility[p.location_name] = p - end - for c in compressed.collection_centers - location_name_to_facility[c.name] = c - end - - p1 = product_name_to_product["P1"] - p2 = product_name_to_product["P2"] - p3 = product_name_to_product["P3"] - c1 = location_name_to_facility["C1"] - l1 = location_name_to_facility["L1"] - - @test compressed.time == 1 - @test compressed.building_period == [1] - - @test p1.name == "P1" - @test p1.transportation_cost ≈ [0.015] - @test p1.transportation_energy ≈ [0.115] - @test p1.transportation_emissions["CO2"] ≈ [0.051] - @test p1.transportation_emissions["CH4"] ≈ [0.0025] - - @test c1.name == "C1" - @test c1.amount ≈ [1869.12] - - @test l1.plant_name == "F1" - @test l1.location_name == "L1" - @test l1.energy ≈ [0.115] - @test l1.emissions["CO2"] ≈ [0.051] - @test l1.emissions["CH4"] ≈ [0.0025] - @test l1.sizes[1].opening_cost ≈ [500] - @test l1.sizes[2].opening_cost ≈ [1250] - @test l1.sizes[1].fixed_operating_cost ≈ [60] - @test l1.sizes[2].fixed_operating_cost ≈ [60] - @test l1.sizes[1].variable_operating_cost ≈ [30] - @test l1.sizes[2].variable_operating_cost ≈ [30] - @test l1.disposal_limit[p2] ≈ [2.0] - @test l1.disposal_limit[p3] ≈ [2.0] - @test l1.disposal_cost[p2] ≈ [-10.0] - @test l1.disposal_cost[p3] ≈ [-10.0] - end -end diff --git a/test/model/build_test.jl b/test/model/build_test.jl new file mode 100644 index 0000000..27d0e7f --- /dev/null +++ b/test/model/build_test.jl @@ -0,0 +1,38 @@ +# Copyright (C) 2020 Argonne National Laboratory +# Written by Alinson Santos Xavier + +using RELOG, Cbc, JuMP, Printf, JSON, MathOptInterface.FileFormats + +@testset "build" begin + basedir = dirname(@__FILE__) + instance = RELOG.parsefile("$basedir/../../instances/s1.json") + graph = RELOG.build_graph(instance) + model = RELOG.build_model(instance, graph, Cbc.Optimizer) + set_optimizer_attribute(model, "logLevel", 0) + + process_node_by_location_name = + Dict(n.location.location_name => n for n in graph.process_nodes) + + shipping_node_by_loc_and_prod_names = Dict( + (n.location.location_name, n.product.name) => n for n in graph.plant_shipping_nodes + ) + + @test length(model[:flow]) == 76 + @test length(model[:dispose]) == 16 + @test length(model[:open_plant]) == 12 + @test length(model[:capacity]) == 12 + @test length(model[:expansion]) == 12 + + l1 = process_node_by_location_name["L1"] + v = model[:capacity][l1, 1] + @test lower_bound(v) == 0.0 + @test upper_bound(v) == 1000.0 + + v = model[:expansion][l1, 1] + @test lower_bound(v) == 0.0 + @test upper_bound(v) == 750.0 + + v = model[:dispose][shipping_node_by_loc_and_prod_names["L1", "P2"], 1] + @test lower_bound(v) == 0.0 + @test upper_bound(v) == 1.0 +end diff --git a/test/model/solve_test.jl b/test/model/solve_test.jl new file mode 100644 index 0000000..de31d3c --- /dev/null +++ b/test/model/solve_test.jl @@ -0,0 +1,61 @@ +# Copyright (C) 2020 Argonne National Laboratory +# Written by Alinson Santos Xavier + +using RELOG, Cbc, JuMP, Printf, JSON, MathOptInterface.FileFormats + +basedir = dirname(@__FILE__) + +@testset "solve (exact)" begin + solution_filename_a = tempname() + solution_filename_b = tempname() + solution = RELOG.solve("$basedir/../../instances/s1.json", output = solution_filename_a) + + @test isfile(solution_filename_a) + + RELOG.write(solution, solution_filename_b) + @test isfile(solution_filename_b) + + @test "Costs" in keys(solution) + @test "Fixed operating (\$)" in keys(solution["Costs"]) + @test "Transportation (\$)" in keys(solution["Costs"]) + @test "Variable operating (\$)" in keys(solution["Costs"]) + @test "Total (\$)" in keys(solution["Costs"]) + + @test "Plants" in keys(solution) + @test "F1" in keys(solution["Plants"]) + @test "F2" in keys(solution["Plants"]) + @test "F3" in keys(solution["Plants"]) + @test "F4" in keys(solution["Plants"]) +end + +@testset "solve (heuristic)" begin + # Should not crash + solution = RELOG.solve("$basedir/../../instances/s1.json", heuristic = true) +end + +@testset "solve (infeasible)" begin + json = JSON.parsefile("$basedir/../../instances/s1.json") + for (location_name, location_dict) in json["products"]["P1"]["initial amounts"] + location_dict["amount (tonne)"] *= 1000 + end + RELOG.solve(RELOG.parse(json)) +end + +@testset "solve (with storage)" begin + basedir = dirname(@__FILE__) + filename = "$basedir/../fixtures/storage.json" + instance = RELOG.parsefile(filename) + @test instance.plants[1].storage_limit == 50.0 + @test instance.plants[1].storage_cost == [2.0, 1.5, 1.0] + + solution = RELOG.solve(filename) + plant_dict = solution["Plants"]["mega plant"]["Chicago"] + @test plant_dict["Variable operating cost (\$)"] == [500.0, 0.0, 100.0] + @test plant_dict["Process (tonne)"] == [50.0, 0.0, 50.0] + @test plant_dict["Storage (tonne)"] == [50.0, 50.0, 0.0] + @test plant_dict["Storage cost (\$)"] == [100.0, 75.0, 0.0] + + @test solution["Costs"]["Variable operating (\$)"] == [500.0, 0.0, 100.0] + @test solution["Costs"]["Storage (\$)"] == [100.0, 75.0, 0.0] + @test solution["Costs"]["Total (\$)"] == [600.0, 75.0, 100.0] +end diff --git a/test/model_test.jl b/test/model_test.jl deleted file mode 100644 index 344fe33..0000000 --- a/test/model_test.jl +++ /dev/null @@ -1,102 +0,0 @@ -# Copyright (C) 2020 Argonne National Laboratory -# Written by Alinson Santos Xavier - -using RELOG, Cbc, JuMP, Printf, JSON, MathOptInterface.FileFormats - -@testset "Model" begin - @testset "build" begin - basedir = dirname(@__FILE__) - instance = RELOG.parsefile("$basedir/../instances/s1.json") - graph = RELOG.build_graph(instance) - model = RELOG.build_model(instance, graph, Cbc.Optimizer) - set_optimizer_attribute(model, "logLevel", 0) - - process_node_by_location_name = - Dict(n.location.location_name => n for n in graph.process_nodes) - - shipping_node_by_location_and_product_names = Dict( - (n.location.location_name, n.product.name) => n for - n in graph.plant_shipping_nodes - ) - - @test length(model[:flow]) == 76 - @test length(model[:dispose]) == 16 - @test length(model[:open_plant]) == 12 - @test length(model[:capacity]) == 12 - @test length(model[:expansion]) == 12 - - l1 = process_node_by_location_name["L1"] - v = model[:capacity][l1, 1] - @test lower_bound(v) == 0.0 - @test upper_bound(v) == 1000.0 - - v = model[:expansion][l1, 1] - @test lower_bound(v) == 0.0 - @test upper_bound(v) == 750.0 - - v = model[:dispose][shipping_node_by_location_and_product_names["L1", "P2"], 1] - @test lower_bound(v) == 0.0 - @test upper_bound(v) == 1.0 - - # dest = FileFormats.Model(format = FileFormats.FORMAT_LP) - # MOI.copy_to(dest, model) - # MOI.write_to_file(dest, "model.lp") - end - - @testset "solve (exact)" begin - solution_filename_a = tempname() - solution_filename_b = tempname() - solution = - RELOG.solve("$(pwd())/../instances/s1.json", output = solution_filename_a) - - @test isfile(solution_filename_a) - - RELOG.write(solution, solution_filename_b) - @test isfile(solution_filename_b) - - @test "Costs" in keys(solution) - @test "Fixed operating (\$)" in keys(solution["Costs"]) - @test "Transportation (\$)" in keys(solution["Costs"]) - @test "Variable operating (\$)" in keys(solution["Costs"]) - @test "Total (\$)" in keys(solution["Costs"]) - - @test "Plants" in keys(solution) - @test "F1" in keys(solution["Plants"]) - @test "F2" in keys(solution["Plants"]) - @test "F3" in keys(solution["Plants"]) - @test "F4" in keys(solution["Plants"]) - end - - - @testset "solve (heuristic)" begin - # Should not crash - solution = RELOG.solve("$(pwd())/../instances/s1.json", heuristic = true) - end - - @testset "infeasible solve" begin - json = JSON.parsefile("$(pwd())/../instances/s1.json") - for (location_name, location_dict) in json["products"]["P1"]["initial amounts"] - location_dict["amount (tonne)"] *= 1000 - end - RELOG.solve(RELOG.parse(json)) - end - - @testset "storage" begin - basedir = dirname(@__FILE__) - filename = "$basedir/fixtures/storage.json" - instance = RELOG.parsefile(filename) - @test instance.plants[1].storage_limit == 50.0 - @test instance.plants[1].storage_cost == [2.0, 1.5, 1.0] - - solution = RELOG.solve(filename) - plant_dict = solution["Plants"]["mega plant"]["Chicago"] - @test plant_dict["Variable operating cost (\$)"] == [500.0, 0.0, 100.0] - @test plant_dict["Process (tonne)"] == [50.0, 0.0, 50.0] - @test plant_dict["Storage (tonne)"] == [50.0, 50.0, 0.0] - @test plant_dict["Storage cost (\$)"] == [100.0, 75.0, 0.0] - - @test solution["Costs"]["Variable operating (\$)"] == [500.0, 0.0, 100.0] - @test solution["Costs"]["Storage (\$)"] == [100.0, 75.0, 0.0] - @test solution["Costs"]["Total (\$)"] == [600.0, 75.0, 100.0] - end -end diff --git a/test/runtests.jl b/test/runtests.jl index bd7059c..e15324a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -4,8 +4,16 @@ using Test @testset "RELOG" begin - include("instance_test.jl") - include("graph_test.jl") - include("model_test.jl") + @testset "Instance" begin + include("instance/compress_test.jl") + include("instance/parse_test.jl") + end + @testset "Graph" begin + include("graph/build_test.jl") + end + @testset "Model" begin + include("model/build_test.jl") + include("model/solve_test.jl") + end include("reports_test.jl") end