Merge branch 'master' into relog-web

This commit is contained in:
2023-02-15 13:27:47 -06:00
50 changed files with 1057 additions and 736 deletions

View File

@@ -4,20 +4,24 @@
module RELOG
include("instance/structs.jl")
using Pkg
version() = Pkg.dependencies()[Base.UUID("a2afcdf7-cf04-4913-85f9-c0d81ddf2008")].version
include("instance/structs.jl")
include("graph/structs.jl")
include("instance/geodb.jl")
include("graph/dist.jl")
include("graph/build.jl")
include("graph/csv.jl")
include("instance/compress.jl")
include("instance/geodb.jl")
include("instance/parse.jl")
include("instance/validate.jl")
include("model/build.jl")
include("model/getsol.jl")
include("model/solve.jl")
include("model/resolve.jl")
include("model/solve.jl")
include("reports/plant_emissions.jl")
include("reports/plant_outputs.jl")
include("reports/plants.jl")

View File

@@ -2,14 +2,6 @@
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
using Geodesy
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(euclidean_distance(x, y) / 1000.0, digits = 2)
end
function build_graph(instance::Instance)::Graph
arcs = []
next_index = 0
@@ -18,6 +10,7 @@ function build_graph(instance::Instance)::Graph
collection_shipping_nodes = ShippingNode[]
name_to_process_node_map = Dict{Tuple{AbstractString,AbstractString},ProcessNode}()
collection_center_to_node = Dict()
process_nodes_by_input_product =
Dict(product => ProcessNode[] for product in instance.products)
@@ -27,6 +20,7 @@ function build_graph(instance::Instance)::Graph
for center in instance.collection_centers
node = ShippingNode(next_index, center, center.product, [], [])
next_index += 1
collection_center_to_node[center] = node
push!(collection_shipping_nodes, node)
end
@@ -50,11 +44,12 @@ function build_graph(instance::Instance)::Graph
# Build arcs from collection centers to plants, and from one plant to another
for source in [collection_shipping_nodes; plant_shipping_nodes]
for dest in process_nodes_by_input_product[source.product]
distance = calculate_distance(
distance = _calculate_distance(
source.location.latitude,
source.location.longitude,
dest.location.latitude,
dest.location.longitude,
instance.distance_metric,
)
values = Dict("distance" => distance)
arc = Arc(source, dest, values)
@@ -83,6 +78,7 @@ function build_graph(instance::Instance)::Graph
collection_shipping_nodes,
arcs,
name_to_process_node_map,
collection_center_to_node,
)
end

60
src/graph/dist.jl Normal file
View File

@@ -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 Geodesy
using NearestNeighbors
using DataFrames
function _calculate_distance(
source_lat,
source_lon,
dest_lat,
dest_lon,
::EuclideanDistance,
)::Float64
x = LLA(source_lat, source_lon, 0.0)
y = LLA(dest_lat, dest_lon, 0.0)
return round(euclidean_distance(x, y) / 1000.0, digits = 3)
end
function _calculate_distance(
source_lat,
source_lon,
dest_lat,
dest_lon,
metric::KnnDrivingDistance,
)::Float64
if metric.tree === nothing
basedir = joinpath(dirname(@__FILE__), "..", "..", "data")
csv_filename = joinpath(basedir, "dist_driving.csv")
# Download pre-computed driving data
if !isfile(csv_filename)
_download_zip(
"https://axavier.org/RELOG/0.6/data/dist_driving_0b9a6ad6.zip",
basedir,
csv_filename,
0x0b9a6ad6,
)
end
# Fit kNN model
df = DataFrame(CSV.File(csv_filename, missingstring = "NaN"))
dropmissing!(df)
coords = Matrix(df[!, [:source_lat, :source_lon, :dest_lat, :dest_lon]])'
metric.ratios = Matrix(df[!, [:ratio]])
metric.tree = KDTree(coords)
end
# Compute Euclidean distance
dist_euclidean =
_calculate_distance(source_lat, source_lon, dest_lat, dest_lon, EuclideanDistance())
# Predict ratio
idxs, _ = knn(metric.tree, [source_lat, source_lon, dest_lat, dest_lon], 5)
ratio_pred = mean(metric.ratios[idxs])
dist_pred = round(dist_euclidean * ratio_pred, digits = 3)
isfinite(dist_pred) || error("non-finite distance detected: $dist_pred")
return dist_pred
end

View File

@@ -33,6 +33,7 @@ mutable struct Graph
collection_shipping_nodes::Vector{ShippingNode}
arcs::Vector{Arc}
name_to_process_node_map::Dict{Tuple{AbstractString,AbstractString},ProcessNode}
collection_center_to_node::Dict{CollectionCenter,ShippingNode}
end
function Base.show(io::IO, instance::Graph)

View File

@@ -23,8 +23,20 @@ function parse(json)::Instance
validate(json, Schema(json_schema))
building_period = [1]
if "building period (years)" in keys(json)
building_period = json["building period (years)"]
if "building period (years)" in keys(json["parameters"])
building_period = json["parameters"]["building period (years)"]
end
distance_metric = EuclideanDistance()
if "distance metric" in keys(json["parameters"])
metric_name = json["parameters"]["distance metric"]
if metric_name == "driving"
distance_metric = KnnDrivingDistance()
elseif metric_name == "Euclidean"
# nop
else
error("Unknown distance metric: $metric_name")
end
end
plants = Plant[]
@@ -37,6 +49,8 @@ function parse(json)::Instance
cost = product_dict["transportation cost (\$/km/tonne)"]
energy = zeros(T)
emissions = Dict()
disposal_limit = zeros(T)
disposal_cost = zeros(T)
if "transportation energy (J/km/tonne)" in keys(product_dict)
energy = product_dict["transportation energy (J/km/tonne)"]
@@ -46,7 +60,25 @@ function parse(json)::Instance
emissions = product_dict["transportation emissions (tonne/km/tonne)"]
end
product = Product(product_name, cost, energy, emissions)
if "disposal limit (tonne)" in keys(product_dict)
disposal_limit = product_dict["disposal limit (tonne)"]
end
if "disposal cost (\$/tonne)" in keys(product_dict)
disposal_cost = product_dict["disposal cost (\$/tonne)"]
end
prod_centers = []
product = Product(
product_name,
cost,
energy,
emissions,
disposal_limit,
disposal_cost,
prod_centers,
)
push!(products, product)
prod_name_to_product[product_name] = product
@@ -66,6 +98,7 @@ function parse(json)::Instance
product,
center_dict["amount (tonne)"],
)
push!(prod_centers, center)
push!(collection_centers, center)
end
end
@@ -176,5 +209,12 @@ function parse(json)::Instance
@info @sprintf("%12d collection centers", length(collection_centers))
@info @sprintf("%12d candidate plant locations", length(plants))
return Instance(T, products, collection_centers, plants, building_period)
return Instance(
T,
products,
collection_centers,
plants,
building_period,
distance_metric,
)
end

View File

@@ -13,6 +13,9 @@ mutable struct Product
transportation_cost::Vector{Float64}
transportation_energy::Vector{Float64}
transportation_emissions::Dict{String,Vector{Float64}}
disposal_limit::Vector{Float64}
disposal_cost::Vector{Float64}
collection_centers::Vector
end
mutable struct CollectionCenter
@@ -48,10 +51,21 @@ mutable struct Plant
storage_cost::Vector{Float64}
end
abstract type DistanceMetric end
Base.@kwdef mutable struct KnnDrivingDistance <: DistanceMetric
tree = nothing
ratios = nothing
end
mutable struct EuclideanDistance <: DistanceMetric end
mutable struct Instance
time::Int64
products::Vector{Product}
collection_centers::Vector{CollectionCenter}
plants::Vector{Plant}
building_period::Vector{Int64}
distance_metric::DistanceMetric
end

View File

@@ -20,13 +20,17 @@ 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(
model[:plant_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[:collection_dispose] = Dict(
(n, t) => @variable(model, lower_bound = 0,) for
n in values(graph.collection_shipping_nodes), t = 1:T
)
model[:store] = Dict(
(n, t) =>
@variable(model, lower_bound = 0, upper_bound = n.location.storage_limit)
@@ -131,14 +135,25 @@ function create_objective_function!(model::JuMP.Model)
end
end
# Shipping node costs
# Plant 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],
model[:plant_dispose][n, t],
)
end
# Collection shipping node costs
for n in values(graph.collection_shipping_nodes), t = 1:T
# Disposal costs
add_to_expression!(
obj,
n.location.product.disposal_cost[t],
model[:collection_dispose][n, t],
)
end
@@ -154,16 +169,29 @@ function create_shipping_node_constraints!(model::JuMP.Model)
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]
sum(model[:flow][a, t] for a in n.outgoing_arcs) ==
n.location.amount[t] + model[:collection_dispose][n, t]
)
end
for prod in model[:instance].products
if isempty(prod.collection_centers)
continue
end
expr = AffExpr()
for center in prod.collection_centers
n = graph.collection_center_to_node[center]
add_to_expression!(expr, model[:collection_dispose][n, t])
end
@constraint(model, expr <= prod.disposal_limit[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]
sum(model[:flow][a, t] for a in n.outgoing_arcs) +
model[:plant_dispose][n, t]
)
end
end

View File

@@ -39,21 +39,24 @@ function get_solution(model::JuMP.Model; marginal_costs = true)
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
],
"Latitude (deg)" => n.location.latitude,
"Longitude (deg)" => n.location.longitude,
"Amount (tonne)" => n.location.amount,
)
if n.product.name keys(output["Products"])
output["Products"][n.product.name] = OrderedDict()
end
output["Products"][n.product.name][n.location.name] = location_dict
for n in graph.collection_shipping_nodes
location_dict = OrderedDict{Any,Any}(
"Latitude (deg)" => n.location.latitude,
"Longitude (deg)" => n.location.longitude,
"Amount (tonne)" => n.location.amount,
"Dispose (tonne)" =>
[JuMP.value(model[:collection_dispose][n, t]) for t = 1:T],
)
if marginal_costs
location_dict["Marginal cost (\$/tonne)"] = [
round(abs(JuMP.shadow_price(model[:eq_balance][n, t])), digits = 2) for
t = 1:T
]
end
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
# Plants
@@ -178,13 +181,14 @@ function get_solution(model::JuMP.Model; marginal_costs = true)
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]
disposal_amount =
[JuMP.value(model[:plant_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]
[JuMP.value(model[:plant_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

View File

@@ -14,14 +14,14 @@ end
function _print_graph_stats(instance::Instance, graph::Graph)::Nothing
@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 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)",
"%12d shipping nodes (collection)",
length(graph.collection_shipping_nodes)
)
@info @sprintf(" %12d arcs", length(graph.arcs))
@info @sprintf("%12d arcs", length(graph.arcs))
return
end

View File

@@ -13,6 +13,7 @@ function products_report(solution; marginal_costs = true)::DataFrame
df."longitude (deg)" = Float64[]
df."year" = Int[]
df."amount (tonne)" = Float64[]
df."amount disposed (tonne)" = Float64[]
df."marginal cost (\$/tonne)" = Float64[]
T = length(solution["Energy"]["Plants (GJ)"])
for (prod_name, prod_dict) in solution["Products"]
@@ -22,6 +23,7 @@ function products_report(solution; marginal_costs = true)::DataFrame
latitude = round(location_dict["Latitude (deg)"], digits = 6)
longitude = round(location_dict["Longitude (deg)"], digits = 6)
amount = location_dict["Amount (tonne)"][year]
amount_disposed = location_dict["Dispose (tonne)"][year]
push!(
df,
[
@@ -32,6 +34,7 @@ function products_report(solution; marginal_costs = true)::DataFrame
year,
amount,
marginal_cost,
amount_disposed,
],
)
end

View File

@@ -14,6 +14,9 @@
"properties": {
"time horizon (years)": {
"type": "number"
},
"distance metric": {
"type": "string"
}
},
"required": [
@@ -169,6 +172,12 @@
},
"initial amounts": {
"$ref": "#/definitions/InitialAmount"
},
"disposal limit (tonne)": {
"$ref": "#/definitions/TimeSeries"
},
"disposal cost ($/tonne)": {
"$ref": "#/definitions/TimeSeries"
}
},
"required": [