# UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.

using Printf
using JSON
using DataStructures
import Base: getindex, time
import GZip

abstract type UCElement end

abstract type Time <: UCElement end

mutable struct Bus <: UCElement
    name::String
    offset::Int
    load::Array{Float64}
    units::Array
    price_sensitive_loads::Array
end


mutable struct CostSegment <: UCElement
    mw::Array{Float64}
    cost::Array{Float64}
end


mutable struct StartupCategory <: UCElement
    delay::Int
    cost::Float64
end


mutable struct Unit <: UCElement
    name::String
    bus::Bus
    max_power::Array{Float64}
    min_power::Array{Float64}
    must_run::Array{Bool}
    min_power_cost::Array{Float64}
    cost_segments::Array{CostSegment}
    min_uptime::Int
    min_downtime::Int
    ramp_up_limit::Float64
    ramp_down_limit::Float64
    startup_limit::Float64
    shutdown_limit::Float64
    initial_status::Union{Int,Nothing}
    initial_power::Union{Float64,Nothing}
    provides_spinning_reserves::Array{Bool}
    startup_categories::Array{StartupCategory}
end # Unit


mutable struct TransmissionLine <: UCElement
    name::String
    offset::Int
    source::Bus
    target::Bus
    reactance::Float64
    susceptance::Float64
    normal_flow_limit::Array{Float64}
    emergency_flow_limit::Array{Float64}
    flow_limit_penalty::Array{Float64}
end


mutable struct Reserves <: UCElement
    spinning::Array{Float64}
end


mutable struct Contingency <: UCElement
    name::String
    lines::Array{TransmissionLine}
    units::Array{Unit}
end


mutable struct PriceSensitiveLoad <: UCElement
    name::String
    bus::Bus
    demand::Array{Float64}
    revenue::Array{Float64}
end


mutable struct UnitCommitmentInstance
    time::Int
    power_balance_penalty::Array{Float64}
    "Penalty for failing to meet reserve requirement."
    shortfall_penalty::Array{Float64}
    units::Array{Unit}
    buses::Array{Bus}
    lines::Array{TransmissionLine}
    reserves::Reserves
    contingencies::Array{Contingency}
    price_sensitive_loads::Array{PriceSensitiveLoad}
end


function Base.show(io::IO, instance::UnitCommitmentInstance)
    print(io, "UnitCommitmentInstance with ")
    print(io, "$(length(instance.units)) units, ")
    print(io, "$(length(instance.buses)) buses, ")
    print(io, "$(length(instance.lines)) lines, ")
    print(io, "$(length(instance.contingencies)) contingencies, ")
    print(io, "$(length(instance.price_sensitive_loads)) price sensitive loads")
end


function read_benchmark(name::AbstractString) :: UnitCommitmentInstance
    basedir = dirname(@__FILE__)
    return UnitCommitment.read("$basedir/../instances/$name.json.gz")
end


function read(path::AbstractString)::UnitCommitmentInstance
    if endswith(path, ".gz")
        return read(GZip.gzopen(path))
    else
        return read(open(path))
    end
end


function read(file::IO)::UnitCommitmentInstance
    return from_json(JSON.parse(file, dicttype=()->DefaultOrderedDict(nothing)))
end
    
function from_json(json; fix=true)
    units = Unit[]
    buses = Bus[]
    contingencies = Contingency[]
    lines = TransmissionLine[]
    loads = PriceSensitiveLoad[]
    T = json["Parameters"]["Time (h)"]
    
    name_to_bus = Dict{String, Bus}()
    name_to_line = Dict{String, TransmissionLine}()
    name_to_unit = Dict{String, Unit}()
    
    function timeseries(x; default=nothing)
        x !== nothing || return default
        x isa Array || return [x for t in 1:T]
        return x
    end
    
    function scalar(x; default=nothing)
        x !== nothing || return default
        x
    end
    
    # Read parameters
    power_balance_penalty = timeseries(json["Parameters"]["Power balance penalty (\$/MW)"],
                                       default=[1000.0 for t in 1:T])
    shortfall_penalty = timeseries(json["Parameters"]["Reserve shortfall penalty (\$/MW)"],
                                   default=[0. for t in 1:T])
    
    # Read buses
    for (bus_name, dict) in json["Buses"]
        bus = Bus(bus_name,
                  length(buses),
                  timeseries(dict["Load (MW)"]),
                  Unit[],
                  PriceSensitiveLoad[])
        name_to_bus[bus_name] = bus
        push!(buses, bus)
    end
    
    # Read units
    for (unit_name, dict) in json["Generators"]
        bus = name_to_bus[dict["Bus"]]
        
        # Read production cost curve
        K = length(dict["Production cost curve (MW)"])
        curve_mw   = hcat([timeseries(dict["Production cost curve (MW)"][k]) for k in 1:K]...)
        curve_cost = hcat([timeseries(dict["Production cost curve (\$)"][k]) for k in 1:K]...)
        min_power = curve_mw[:, 1]
        max_power = curve_mw[:, K]
        min_power_cost = curve_cost[:, 1]
        segments = CostSegment[]
        for k in 2:K
            amount = curve_mw[:, k] - curve_mw[:, k-1]
            cost = (curve_cost[:, k] - curve_cost[:, k-1]) ./ amount
            replace!(cost, NaN=>0.0)
            push!(segments, CostSegment(amount, cost))
        end
        
        # Read startup costs
        startup_delays = scalar(dict["Startup delays (h)"], default=[1])
        startup_costs  = scalar(dict["Startup costs (\$)"], default=[0.])
        startup_categories = StartupCategory[]
        for k in 1:length(startup_delays)
            push!(startup_categories, StartupCategory(startup_delays[k],
                                                      startup_costs[k]))
        end
        
        # Read and validate initial conditions
        initial_power = scalar(dict["Initial power (MW)"], default=nothing)
        initial_status = scalar(dict["Initial status (h)"], default=nothing)
        if initial_power === nothing
            initial_status === nothing || error("unit $unit_name has initial status but no initial power")
        else
            initial_status !== nothing || error("unit $unit_name has initial power but no initial status")
            initial_status != 0 || error("unit $unit_name has invalid initial status")
            if initial_status < 0 && initial_power > 1e-3
                error("unit $unit_name has invalid initial power")
            end
        end
        
        unit = Unit(unit_name,
                    bus,
                    max_power,
                    min_power,
                    timeseries(dict["Must run?"], default=[false for t in 1:T]),
                    min_power_cost,
                    segments,
                    scalar(dict["Minimum uptime (h)"], default=1),
                    scalar(dict["Minimum downtime (h)"], default=1),
                    scalar(dict["Ramp up limit (MW)"], default=1e6),
                    scalar(dict["Ramp down limit (MW)"], default=1e6),
                    scalar(dict["Startup limit (MW)"], default=1e6),
                    scalar(dict["Shutdown limit (MW)"], default=1e6),
                    initial_status,
                    initial_power,
                    timeseries(dict["Provides spinning reserves?"],
                               default=[true for t in 1:T]),
                    startup_categories)
        push!(bus.units, unit)
        name_to_unit[unit_name] = unit
        push!(units, unit)
    end
    
    # Read reserves
    reserves = Reserves(zeros(T))
    if "Reserves" in keys(json)
        reserves.spinning = timeseries(json["Reserves"]["Spinning (MW)"],
                                       default=zeros(T))
    end
    
    # Read transmission lines
    if "Transmission lines" in keys(json)
        for (line_name, dict) in json["Transmission lines"]
            line = TransmissionLine(line_name,
                                    length(lines) + 1,
                                    name_to_bus[dict["Source bus"]],
                                    name_to_bus[dict["Target bus"]],
                                    scalar(dict["Reactance (ohms)"]),
                                    scalar(dict["Susceptance (S)"]),
                                    timeseries(dict["Normal flow limit (MW)"],
                                               default=[1e8 for t in 1:T]),
                                    timeseries(dict["Emergency flow limit (MW)"],
                                               default=[1e8 for t in 1:T]),
                                    timeseries(dict["Flow limit penalty (\$/MW)"],
                                               default=[5000.0 for t in 1:T]))
            name_to_line[line_name] = line
            push!(lines, line)
        end
    end
    
    # Read contingencies
    if "Contingencies" in keys(json)
        for (cont_name, dict) in json["Contingencies"]
            affected_units = Unit[]
            affected_lines = TransmissionLine[]
            if "Affected lines" in keys(dict)
                affected_lines = [name_to_line[l] for l in dict["Affected lines"]]
            end
            if "Affected units" in keys(dict)
                affected_units = [name_to_unit[u] for u in dict["Affected units"]]
            end
            cont = Contingency(cont_name, affected_lines, affected_units)
            push!(contingencies, cont)
        end
    end
    
    # Read price-sensitive loads
    if "Price-sensitive loads" in keys(json)
        for (load_name, dict) in json["Price-sensitive loads"]
            bus = name_to_bus[dict["Bus"]]
            load = PriceSensitiveLoad(load_name,
                                      bus,
                                      timeseries(dict["Demand (MW)"]),
                                      timeseries(dict["Revenue (\$/MW)"]),
                                     )
            push!(bus.price_sensitive_loads, load)
            push!(loads, load)
        end
    end
    
    instance = UnitCommitmentInstance(T,
                                      power_balance_penalty,
                                      shortfall_penalty,
                                      units,
                                      buses,
                                      lines,
                                      reserves,
                                      contingencies,
                                      loads)
    if fix
        UnitCommitment.fix!(instance)
    end
    return instance
end


"""
    slice(instance, range)

Creates a new instance, with only a subset of the time periods.
This function does not modify the provided instance. The initial
conditions are also not modified.

Example
-------

    # Build a 2-hour UC instance
    instance = UnitCommitment.read_benchmark("test/case14")
    modified = UnitCommitment.slice(instance, 1:2)

"""
function slice(instance::UnitCommitmentInstance, range::UnitRange{Int})::UnitCommitmentInstance
    modified = deepcopy(instance)
    modified.time = length(range)
    modified.power_balance_penalty = modified.power_balance_penalty[range]
    modified.reserves.spinning = modified.reserves.spinning[range]
    for u in modified.units
        u.max_power = u.max_power[range]
        u.min_power = u.min_power[range]
        u.must_run = u.must_run[range]
        u.min_power_cost = u.min_power_cost[range]
        u.provides_spinning_reserves = u.provides_spinning_reserves[range]
        for s in u.cost_segments
            s.mw = s.mw[range]
            s.cost = s.cost[range]
        end
    end
    for b in modified.buses
        b.load = b.load[range]
    end
    for l in modified.lines
        l.normal_flow_limit = l.normal_flow_limit[range]
        l.emergency_flow_limit = l.emergency_flow_limit[range]
        l.flow_limit_penalty = l.flow_limit_penalty[range]
    end
    for ps in modified.price_sensitive_loads
        ps.demand = ps.demand[range]
        ps.revenue = ps.revenue[range]
    end
    return modified
end


export UnitCommitmentInstance
