############## param types

abstract type AccParams end

struct AccParamsSingle{V, W} <: AccParams
    fraglength::Int
    incfun::V
    dataentry::Int
    countfun::W
end

struct AccParamsPair{V, W} <: AccParams
    minfragsize::Int
    maxfragsize::Int
    incfun::V
    dataentry::Int
    countfun::W
end

### helper functions

@inline inc_meta_frag!(V, mpl, mpr, w) =  V[intersect(mpl:mpr, 1:size(V, 1))] .+= w
@inline inc_meta_cut!(V, mpl, mpr, w)  =  (inc_meta_index!(V, mpl, w) ; inc_meta_index!(V, mpr, w))
@inline inc_meta_mid!(V, mpl, mpr, w)  =  inc_meta_index!(V, div(mpl + mpr, 2), w)
@inline inc_meta_index!(V, i::Int, w)  =  (1 ≤ i ≤ size(V, 1)) && (V[i] += w)

@inline inc_heat_frag!(V, k, mpl, mpr, w) =  V[intersect(mpl:mpr, 1:size(V, 1)), k] .+= w
@inline inc_heat_cut!(V, k, mpl, mpr, w)  =  (inc_heat_index!(V, k, mpl, w) ; inc_heat_index!(V, k, mpr, w))
@inline inc_heat_left_cut!(V, k, mpl, mpr, w)  =  inc_heat_index!(V, k, mpl, w)
@inline inc_heat_mid!(V, k, mpl, mpr, w)  =  inc_heat_index!(V, k, div(mpl + mpr, 2), w)
@inline @inbounds inc_heat_index!(V, k, i::Int, w)  =  (1 ≤ i ≤ size(V, 1)) && (V[i, k] += w)

inc_meta_mid_width(bw) = (V, mpl, mpr, w) -> begin
    mid = div(mpl + mpr, 2)
    inc_meta_frag!(V, mid - bw, mid + bw, w)
end

inc_heat_mid_width(bw) = (V, k, mpl, mpr, w) -> begin
    mid = div(mpl + mpr, 2)
    bc = intersect((mid - bw):(mid + bw), 1:size(V, 1))
    V[bc, k] .+= w
end

inc_heat_atac_cut_width(bw) = (V, k, mpl, mpr, w, ao=4) -> begin
    inc_heat_frag!(V, k, mpl + ao - bw, mpl + ao + bw, w)
    inc_heat_frag!(V, k, mpr - ao - bw, mpr - ao + bw, w)
end

inc_meta_atac_cut_width(bw) = (V, mpl, mpr, w, ao=4) -> begin
    inc_meta_frag!(V, mpl + ao - bw, mpl + ao + bw, w)
    inc_meta_frag!(V, mpr - ao - bw, mpr - ao + bw, w)
end

@inline inc_heat_atac_cut!(V, k, mpl, mpr, w, ao=4)   =  (inc_heat_index!(V, k, mpl + ao, w) ; inc_heat_index!(V, k, mpr - ao, w))
@inline inc_heat_atac_frag!(V, k, mpl, mpr, w, ao=4)  =  inc_heat_frag!(V, k, mpl+ao, mpr-ao, w);
@inline inc_meta_atac_frag!(V, mpl, mpr, w, ao=4)     =  inc_meta_frag!(V, mpl+ao, mpr-ao, w);

###############################
function readsintersecting(chroms, locations, FM, dataentry=4, fn=identity)
    total = zeros(Int, length(chroms))
    for (k, (c, l)) in enumerate(zip(chroms, locations))

        v = get_frags(c, l, FM)
        for i = 1:size(v, 2)
            total[k] += fn(v[dataentry, i])
        end
    end
    total
end

function fragregion(chrom, location, strand, FM::FragMatrixSingle{T}, inc_fun=inc_meta_frag!, fraglength=120, dataentry=4, fn=identity; pos=true, neg=true) where {T}
    fw = length(location)
    P = zeros(fw)
    V = get_frags(chrom, location, FM)

    for i = 1:size(V, 2)
        rstrand, _ = GenomeFragments.get_strand_chrom_enc(V[3, i])
        !pos && (rstrand == STRAND_POS) && continue
        !neg && (rstrand == STRAND_NEG) && continue
        if rstrand == STRAND_POS
            s = V[1, i] - location.start + 1
            e = s + fraglength - 1
        else
            e = V[2, i]  - location.start + 1
            s = e - fraglength + 1
        end
        if strand == "-"
            s, e = fw - e + 1, fw - s + 1
        end
        w = fn(V[dataentry, i])
        inc_fun(P, s, e,  w)
    end
    P
end

@inline function fragregion(chrom, location, FM::FragMatrixPair{T}, inc_fun=inc_meta_frag!, minfrag=0, maxfrag=1000, dataentry=4, fn=identity) where {T}
    fw = length(location)
    P = zeros(fw)
    V = get_frags(chrom, location, FM)

    for col in eachcol(V)
        fp = col[2] - col[1] + 1
        s = col[1] - location.start + 1
        e = col[2] - location.start + 1
        w = fn(col[dataentry])
        (minfrag ≤ fp ≤ maxfrag) && inc_fun(P, s, e, w)
    end

    P
end

#################### fragheatmap

function fragheatmap(chroms, locations, strands, FM::Vector{FragMatrixSingle{T}}, inc_fun=inc_heat_mid!, fraglength=120, data_entry=4, fn=identity; norm_scale=1e+6, norm_type=:mpm, show_progress=true) where T

    fw = length(locations[1])
    H = zeros(Float64, fw, length(chroms))
    p = ProgressMeter.Progress(length(chroms)*length(FM))
    for F in FM
        TH = zeros(Float64, fw, length(chroms))
        fragheatmap!(TH, p, chroms, locations, strands, F, inc_fun, fraglength, data_entry, fn, show_progress=show_progress)
        if norm_type == :mpm
            H .+= norm_scale.*TH./F.totalecfrags[data_entry - 3]
        elseif norm_type == :mpm_f
            t = sum(fn, view(F.FM, data_entry, :))
            H .+= norm_scale.*TH./t
	    elseif norm_type == :frag_region
	        H .+= norm_scale.*TH/sum(TH)
        elseif norm_type == :total
            H += TH
        else
            H += TH
        end
    end
    if norm_type == :total
        H .*= norm_scale/sum(F.totalecfrags[data_entry - 3] for F in FM)
    else
        H ./= length(FM)
    end
    H
end

function libsize(FM::FragMatrixPair, dataentry, fn=identity, minfrag=0, maxfrag=1000000)

    σ = 0.0
    for i = 1:size(FM.FM, 2)
        fp = FM.FM[2, i] - FM.FM[1, i] + 1
        σ += ifelse(minfrag ≤ fp ≤ maxfrag, fn(FM.FM[dataentry, i]), 0.0)
    end
    σ
end

function libsize(FM::FragMatrixSingle, dataentry, fn=identity)
    σ = 0
    @inbounds for i = 1:size(FM.FM, 2)
        σ += fn(FM.FM[dataentry, i])
    end
    σ
end

function fragheatmap(chroms, locations, strands, FM::Vector{FragMatrixPair{T}}, inc_fun=inc_heat_mid!, minfragsize=0, maxfragsize=500, data_entry=4, fn=identity; norm_scale=1e+6, norm_type=:mpm, show_progress=true) where T

    fw = length(locations[1])
    H = zeros(Float64, fw, length(chroms))
    p = ProgressMeter.Progress(length(chroms)*length(FM))
    for F in FM
        TH = zeros(Float64, fw, length(chroms))
        fragheatmap!(TH, p, chroms, locations, strands, F, inc_fun, minfragsize, maxfragsize, data_entry, fn, show_progress=show_progress)
        if norm_type == :mpm
            H .+= norm_scale.*TH./F.totalecfrags[data_entry - 3]
        elseif norm_type == :mpm_f
            t = sum(fn, view(F.FM, data_entry, :))
            H .+= norm_scale.*TH./t
        elseif norm_type == :frag
            σ = libsize(F, data_entry, fn, minfragsize, maxfragsize)
            H .+= norm_scale.*TH./σ
	elseif norm_type == :frag_region
	    H .+= norm_scale.*TH/sum(TH)
        elseif norm_type == :total
            H += TH
        else
            H += TH
        end
    end
    if norm_type == :total
        H .*= 1e+6/sum(F.totalecfrags[data_entry - 3] for F in FM)
    else
        H ./= length(FM)
    end
    H
end

function fragheatmap!(H, p, chroms, locations, strands, FM::FragMatrixPair{T}, inc_fun=inc_heat_mid!, minfragsize=0, maxfragsize=500, data_entry=4, fn=identity; show_progress=true) where {T}

    fw = length(locations[1])

    for (k, (c, l, s)) in enumerate(zip(chroms, locations, strands))
        show_progress && next!(p)
        V = get_frags(c, l, FM)
        isempty(V) && continue
        for i = 1:size(V, 2)
            fp = V[2, i] - V[1, i] + 1
            ((fp < minfragsize) || (fp > maxfragsize)) && continue
            if s == "+"
                mpl = V[1, i] - l.start + 1
                mpr = V[2, i] - l.start + 1
                strand = 1
            else
                mpl = l.stop - V[2, i] + 1
                mpr = l.stop - V[1, i] + 1
                strand = -1
            end

            ### flip missing?

            w = fn(V[data_entry, i])
            inc_fun(H, k, mpl, mpr,  w)
        end
    end
    H
end

function fragheatmap!(H, p, chroms, locations, strands, FM::FragMatrixSingle{T}, inc_fun=inc_heat_mid!, fraglength=120, data_entry=4, fn=identity; show_progress=true) where {T}

    fw = length(locations[1])

    for (k, (c, l, str)) in enumerate(zip(chroms, locations, strands))
        show_progress && next!(p)
        V = get_frags(c, (l.start - 2*fraglength):(l.stop + 2*fraglength), FM)
        isempty(V) && continue
        for i = 1:size(V, 2)
            strand, _ = GenomeFragments.get_strand_chrom_enc(V[3, i])

            if strand == STRAND_POS
                s = V[1, i] - l.start + 1
                e = s + fraglength - 1
            else
                e = V[2, i]  - l.start + 1
                s = e - fraglength + 1
            end
            if str == "-"
                s, e = fw - e + 1, fw - s + 1
            end
            w = fn(V[data_entry, i])
            inc_fun(H, k, s, e,  w)
        end
    end
    H
end

function fragheatmap!(H, p, chroms, locations, strands, FM::FragMatrixPair{T}, P::AccParamsPair{PV, PW}, show_progress=true) where {T, PV, PW}

    fw = length(locations[1])

    for (k, (c, l, s)) in enumerate(zip(chroms, locations, strands))
        show_progress && next!(p)
        V = get_frags(c, l, FM)
        isempty(V) && continue
        for i = 1:size(V, 2)
            fp = V[2, i] - V[1, i] + 1
            ((fp < P.minfragsize) || (fp > P.maxfragsize)) && continue
            if s == "+"
                mpl = V[1, i] - l.start + 1
                mpr = V[2, i] - l.start + 1
                strand = 1
            else
                mpl = l.stop - V[2, i] + 1
                mpr = l.stop - V[1, i] + 1
                strand = -1
            end

            ### flip missing?

            w = P.countfun(V[data_entry, i])
            P.incfun(H, k, mpl, mpr,  w)
        end
    end
    H
end

function fragheatmap!(H, p, chroms, locations, strands, FM::FragMatrixSingle{T}, P::AccParamsSingle{PV, PW}; show_progress=true) where {T, PV, PW}

    fw = length(locations[1])

    for (k, (c, l, str)) in enumerate(zip(chroms, locations, strands))
        show_progress && next!(p)
        V = get_frags(c, (l.start - 2*P.fraglength):(l.stop + 2*P.fraglength), FM)
        isempty(V) && continue
        for i = 1:size(V, 2)
            strand, _ = GenomeFragments.get_strand_chrom_enc(V[3, i])

            if strand == STRAND_POS
                s = V[1, i] - l.start + 1
                e = s + fraglength - 1
            else
                e = V[2, i]  - l.start + 1
                s = e - fraglength + 1
            end
            if str == "-"
                s, e = fw - e + 1, fw - s + 1
            end
            w = P.countfun(V[data_entry, i])
            P.incfun(H, k, s, e,  w)
        end
    end
    H
end


""" Write model in DynamicsTrainer to verilog-A netlist
    input/output config format: list of tuples, (header, type, port number)
    * header: either "" or "ddt".
    * type: either I or V
    * port number: must be between 1 and port number
    One tuple per model input/output. For example, ("ddt","V",2) interprets the input as the 2nd port's voltage derivative
    Similarly, ("", "I", 4) interprets the input as the 4th port's input current """
function generate_netlist_task(;
    model_name = "model",
    num_ports = 0,
    input_config = [],
    output_config = [],
    time_scale = 1.0,
    input_scale,
    input_bias,
    output_scale,
    output_bias,
)
    # create a function that can be directly run by a DynamicsTrainer as a callback task
    function gen_netlist(f)
        # extract parameters
        _, _, _, p_feed, p_ode, p_link = extract_params(f)

        write_netlist(
            f.ode,
            f.link,
            f.feedthrough,
            model_name,
            p_ode,
            p_link,
            p_feed,
            num_ports,
            input_config,
            output_config;
            time_scale = time_scale,
            input_scale = input_scale,
            input_bias = input_bias,
            output_scale = output_scale,
            output_bias = output_bias,
            dir = f.savepath,
        )
    end
    return gen_netlist
end

""" Produces model called <model_name>_model """
function write_netlist(
    dyn::O,
    link::L,
    feedthrough::F,
    model_name::String,
    p_ode,
    p_link,
    p_feed,
    num_ports,
    input_config,
    output_config;
    time_scale = 1.0,
    input_scale = ones(dyn.input_dim),
    input_bias = zeros(dyn.input_dim),
    output_scale = ones(link.output_dim),
    output_bias = zeros(link.output_dim),
    dir = ".",
) where {O<:Ode} where {L<:Link} where {F<:Feedthrough}
    # dimension data
    input_dim = dyn.input_dim
    state_dim = dyn.state_dim

    # TODO: currently assumes σ_u is either identity or tanh
    if dyn.σ_u == identity
        gate_u = ""
    elseif dyn.σ_u == tanh
        gate_u = "tanh"
    else
        gate_u = ""
    end

    # odelist: list of strings, one for each dxdt
    # hidden_layers: dict of strings, one for each hidden layer unit
    # param_dict: dict of param name, param value
    odelist, hidden_layers, param_dict_ode = convert_verilogA(dyn, p_ode)

    # param_dict_output: dict of param name, param value
    # output_list: list of strings, one for each output dim
    param_dict_output, output_list = convert_verilogA(
        link,
        feedthrough,
        p_link,
        p_feed;
        i_scale = output_scale,
        i_bias = -output_scale .* output_bias,
    )

    # write verilog A file
    # implements architecture-specific connections
    # instance specific parameter values written to the .inc netlist
    open(dir * "/" * model_name * ".va", "w") do io
        # write includes
        write(io, "`include \"constants.vams\"\n")
        write(io, "`include \"disciplines.vams\"\n")
        write(io, "(* compact_module *)\n")
        # write(io, "(* ignore_hidden_state=\"all\" *)\n")

        # write inputs & outputs: ports
        iolist = ""
        for i = 1:num_ports
            iolist *= "a$(i),"
        end

        # write states as inouts as well in order to set initial condition
        for i = 1:state_dim
            iolist *= "x$(i),"
        end

        write(io, "module " * model_name * "(" * iolist * "c);\n")

        # define inouts, with branches
        write(io, "inout " * iolist * "c;\n")
        inlist = ""
        for i = 1:input_dim
            inlist *= "_u$(i),"
            inlist *= "u$(i),"
        end
        write(io, "electrical " * iolist * inlist * "c;\n")

        for i = 1:num_ports
            write(io, "branch (a$(i),c) Ia$(i);\n")
        end

        # define state variables: electrical & branch
        write(io, "electrical ")
        for i = 1:state_dim-1
            write(io, "dx$(i),")
            # write(io, "x$(i),dx$(i),")
        end
        write(io, "dx$(state_dim);\n")
        # write(io, "x$(i),dx$(i);\n")

        for i = 1:state_dim
            write(io, "branch (x$(i),c) Ix$(i);\n")
        end

        # write parameter definitions
        for param in keys(param_dict_ode)
            if param != "tauinv"
                write(io, "parameter real " * param * " = 0 from (-inf:inf);\n")
            else
                write(io, "parameter real " * param * " = 0 from(0:inf);\n")
            end
        end

        for param in keys(param_dict_output)
            write(io, "parameter real " * param * " = 0 from (-inf:inf);\n")
        end

        # initialize hidden layer units
        if length(hidden_layers) > 0
            write(io, "real")
            key_list = [key for key in keys(hidden_layers)]
            for i = 1:length(key_list)
                write(io, " " * key_list[i])
                if i == length(hidden_layers)
                    write(io, ";\n")
                else
                    write(io, ",")
                end
            end
        end

        # begin writing connections
        write(io, "analog begin\n")

        # connect inputs
        for i = 1:length(input_config)
            header = input_config[i][1]
            type = input_config[i][2]
            port = input_config[i][3]

            # write(io, "V(u$(i),c)<+V(a$(i),c);\n")
            write(
                io,
                "V(_u$(i),c) <+ $(1.0/input_scale[i])*$(header)($(type)(a$(port),c)) + $(input_bias[i]);\n",
            )
            write(io, "V(u$(i),c) <+ $(gate_u)(V(_u$(i),c));\n")
        end

        # connect derivatives
        for i = 1:state_dim
            write(io, "V(dx$(i),c)<+(" * string(time_scale) * ")*ddt(V(x$(i),c));\n")
        end

        # define hidden layers
        for key in keys(hidden_layers)
            write(io, key * " = " * hidden_layers[key] * "\n")
        end

        # write ode
        for ode in odelist
            write(io, ode * "\n")
        end

        # write output layer
        for i = 1:length(output_config)
            type = output_config[i][2]
            port = output_config[i][3]

            if type == "V"
                header = "V(a$(port),c) <+ "
            elseif type == "I"
                header = "I(Ia$(port)) <+ "
            elseif type == "O"
                write(io, "I(Ia$(port)) <+ 0.0; \n")
                continue
            else
                header = "$(type)(Ia$(port)) <+ "
            end

            write(io, header * output_list[i] * "\n")
        end

        # end
        write(io, "end // end analog\n")
        write(io, "endmodule\n")

    end # io for .va file

    #
    # write netlist file
    # instance specific parameter values written to the .inc netlist
    open(dir * "/" * model_name * ".inc", "w") do io
        write(io, "// subcircuit for learned Verilog-A model\n")
        write(io, "simulator lang=spectre\n")
        write(io, "ahdl_include \"" * model_name * ".va\"\n")

        # terminal definition
        iolist = ""
        for i = 1:num_ports
            iolist *= "a$(i) "
        end
        write(io, "subckt " * model_name * "_model " * iolist * "c\n")

        # # set initial condition guess to zero
        # write(io, "nodeset ")
        # for i=1:state_dim
        #     write(io, "x$(i)=0 ")
        # end
        # write(io, "\n")

        # include states in inner verilog model
        for i = 1:state_dim
            iolist *= "x$(i) "
        end
        write(io, model_name * "0 (" * iolist * "c) " * model_name)

        for k in sort([string(val) for val in keys(param_dict_ode)])
            write(io, " " * k * "=" * string(Float64(param_dict_ode[k])))
        end
        for k in sort([string(val) for val in keys(param_dict_output)])
            write(io, " " * k * "=" * string(Float64(param_dict_output[k])))
        end
        write(io, "\n")
        write(io, "ends\n")
    end # io for .inc file
end

""" Convert CTRNN0 to VerilogA format """
function convert_verilogA(f::CTRNN0, p)
    # unpack params
    logτ, A, B, μ, ν = unpack_params(f, p)

    # write dictionary of parameters with values
    param_dict = Dict()
    for i = 1:f.state_dim
        for j = 1:f.state_dim
            param_dict["A_$(i)_$(j)"] = A[i, j]
        end
        for j = 1:f.input_dim
            param_dict["B_$(i)_$(j)"] = B[i, j]
        end
        param_dict["mu_$(i)"] = μ[i]
    end
    for i = 1:f.state_dim
        param_dict["nu_$(i)"] = ν[i]
    end
    param_dict["tauinv"] = exp.(-logτ)[1]

    # hidden layer
    hidden_layers = Dict()
    # hidden_layer = []
    for i = 1:f.state_dim
        layer_name = "h_$(i)"
        layer_string = "(mu_$(i))"
        for j = 1:f.state_dim
            layer_string *= "+(A_$(i)_$(j))*(V(x$(j),c))"
        end
        for j = 1:f.input_dim
            layer_string *= "+(B_$(i)_$(j))*(V(u$(j),c))"
        end

        # TODO: currently assumes σ is tanh or identity
        if f.σ == tanh
            hidden_layers[layer_name] = "tanh($(layer_string));"
        elseif f.σ == relu
            hidden_layers[layer_name] = "max(0,$(layer_string));"
        else
            hidden_layers[layer_name] = "$(layer_string);"
        end
        # push!(hidden_layer, "$(layer_name) = $(activation)($(layer_string));")
    end

    # write ode derivative assignments
    odelist = []
    for i = 1:f.state_dim
        string_i = "I(Ix$(i)) <+ (V(dx$(i),c)) + (tauinv)*(V(x$(i),c)) - (nu_$(i)) - h_$(i)"

        push!(odelist, string_i * ";")
    end

    return odelist, hidden_layers, param_dict
end

""" Convert CTRNN1 to VerilogA format """
function convert_verilogA(f::CTRNN1, p)
    # unpack parameters
    logτ, W, A, B, μ, ν = unpack_params(f, p)

    # write dictionary of parameters with values
    param_dict = Dict()
    for i = 1:f.hidden_dim
        for j = 1:f.state_dim
            param_dict["A_$(i)_$(j)"] = A[i, j]
            param_dict["W_$(j)_$(i)"] = W[j, i]
        end
        for j = 1:f.input_dim
            param_dict["B_$(i)_$(j)"] = B[i, j]#/v_scale[j]
        end
        param_dict["mu_$(i)"] = μ[i]
    end
    for i = 1:f.state_dim
        param_dict["nu_$(i)"] = ν[i]
    end
    param_dict["tauinv"] = exp.(-logτ)[1]

    # hidden layer
    hidden_layers = Dict()
    # hidden_layer = []
    for i = 1:f.hidden_dim
        layer_name = "h_$(i)"
        layer_string = "(mu_$(i))"
        for j = 1:f.state_dim
            layer_string *= "+(A_$(i)_$(j))*(V(x$(j),c))"
        end
        for j = 1:f.input_dim
            layer_string *= "+(B_$(i)_$(j))*(V(u$(j),c))"
        end

        # TODO: currently assumes σ is tanh or identity
        if f.σ == tanh
            hidden_layers[layer_name] = "tanh($(layer_string));"
        elseif f.σ == relu
            hidden_layers[layer_name] = "max(0,$(layer_string));"
        else
            hidden_layers[layer_name] = "$(layer_string);"
        end
        # push!(hidden_layer, "$(layer_name) = $(activation)($(layer_string));")
    end

    # write ode derivative assignments
    odelist = []
    for i = 1:f.state_dim
        string_i = "I(Ix$(i)) <+ (V(dx$(i),c)) + (tauinv)*(V(x$(i),c)) - (nu_$(i))"
        for j = 1:f.hidden_dim
            string_i *= " - (W_$(i)_$(j))*h_$(j)"
        end

        push!(odelist, string_i * ";")
    end

    return odelist, hidden_layers, param_dict
end

""" Convert AffineLayer to VerilogA format """
function convert_verilogA(
    f::AffineLayer,
    g::F,
    p_link,
    p_feed;
    v_scale = ones(f.output_dim),
    i_scale = ones(f.output_dim),
    i_bias = zeros(f.output_dim),
) where {F<:Feedthrough}
    # TODO: assumes either tanh or identity
    if f.σ == identity
        gate_link = ""
    elseif f.σ == tanh
        gate_link = "tanh"
    else
        gate_link = ""
    end

    # unpack parameters
    C, D, b = unpack_params(f, p_link)
    D = unpack_params(g, p_feed)

    # write dictionary of parameters with values for output layer
    param_dict_output = Dict()
    for i = 1:f.output_dim
        for j = 1:f.state_dim
            param_dict_output["C_$(i)_$(j)"] = C[i, j] * i_scale[i]
        end
        if D != false
            for j = 1:g.input_dim
                param_dict_output["D_$(i)_$(j)"] = D[i, j] * i_scale[i]
            end
        end
        param_dict_output["b_$i"] = b[i] * i_scale[i] + i_bias[i]
    end

    # write state,input -> output assignments
    output_list = []
    for i = 1:f.output_dim
        string_i = "(b_$(i))"
        for j = 1:f.state_dim
            string_i *= "+(C_$(i)_$(j))*$(gate_link)(V(x$(j),c))"
        end
        if D != false
            for j = 1:g.input_dim
                if D[i, j] != 0.0
                    string_i *= "+(D_$(i)_$(j))*(V(u$(j),c))"
                end
            end
        end
        push!(output_list, string_i * ";")
    end

    return param_dict_output, output_list
end


#-------------------------------------------------------------------------------
# File: functions.jl
# Description: This file contains functions that are used in ILS and VNS.
# Date: October 31, 2019
# Author: Jonathan Fontaine, Killian Fretaud, Rémi Garcia,
#         Boualem Lamraoui, Benoît Le Badezet, Benoit Loger
#-------------------------------------------------------------------------------

# More readable
include("move_insertion.jl")
include("move_exchange.jl")

"""
    sequence_insert!(sequence::Array{Int, 1}, index_remove::Int, index_insert::Int)

Delete the item at `index_remove` and insert at `index_insert` in the sequence.
"""
function sequence_insert!(sequence::Array{Int, 1}, index_remove::Int, index_insert::Int)
    temp = sequence[index_remove]
    if index_remove < index_insert
        for k in index_remove:(index_insert-1)
            sequence[k] = sequence[k+1]
        end
    else
        for k in index_remove:-1:(index_insert+1)
            sequence[k] = sequence[k-1]
        end
    end
    sequence[index_insert] = temp
end

"""

"""
function penalize_costs!(costs::Array{Int, 2}, init_position::Int,
                         solution::Solution, instance::Instance)
    sequence = copy(solution.sequence)
    b0 = instance.nb_late_prec_day+1
    sequence_insert!(sequence, init_position, b0)
    if !is_sequence_valid(sequence, solution.length, instance)
        costs[b0, :] .= instance.nb_cars
    end

    for position in b0:(solution.length-1)
        sequence[position], sequence[position+1] = sequence[position+1], sequence[position]
        if !is_sequence_valid(sequence, solution.length, instance)
            costs[position+1, :] .= instance.nb_cars
        end
    end

    return costs
end

"""
    greedy_add!(solution::Solution, instance::Instance, k::Int, objectives::Int)

Reinserts last car in the sequence of `solution`.
"""
function greedy_add!(solution::Solution, instance::Instance, position_car::Int,
                     objectives::Int, valid_sequence::Bool = false)
    @assert objectives <= 3
    @assert objectives >= 1
    b0 = instance.nb_late_prec_day + 1
    costs = cost_move_insertion(solution, position_car, instance, objectives)
    if valid_sequence
        penalize_costs!(costs, position_car, solution, instance)
    end
    delta = weighted_sum(costs[b0, :])
    best_delta = delta
    best_position = b0
    for position in (b0+1):solution.length
        delta = weighted_sum(costs[position, :])
        if delta < best_delta
            best_delta = delta
            best_position = position
        end
    end
    #Limit case:
    # No position allows a valid sequence
    if valid_sequence && best_delta == instance.nb_cars
        return greedy_add!(solution, instance, position_car-1, objectives, valid_sequence)
    end
    solution.length += 1
    move_insertion!(solution, position_car, best_position, instance)

    return solution
end

"""
    remove!(solution_init::Solution, instance::Instance,
            k::Int, crit::Array{Int, 1})

Removes `k` critical cars of the sequence of `solution_init`.
"""
function remove!(solution::Solution, instance::Instance,
                 k::Int, crit::Array{Int, 1})
    indices = sort(randperm(length(crit))[1:k])
    crit_sort = sort(crit, rev = true)
    for i in 1:k
        position = crit_sort[indices[i]]
        solution.length -= 1
        move_insertion!(solution, position, instance.nb_cars, instance)
    end

    return solution
end

"""
    find_critical_cars(solution::Solution, instance::Instance,
                       objectives::BitArray{1})

Returns the set cars involved in at least one HPRC violation and/or one LPRC
violation.
"""
function find_critical_cars(solution::Solution, instance::Instance,
                            objectives::BitArray{1})
    @assert length(objectives) == 2
    @assert true in objectives
    if objectives[1]
        first_option = 1
    else
        first_option = instance.nb_HPRC+1
    end
    if objectives[2]
        last_option = instance.nb_HPRC+instance.nb_LPRC
    else
        last_option = instance.nb_HPRC
    end

    critical_cars = Set{Int}()
    b0 = instance.nb_late_prec_day + 1
    for index_car in b0:solution.length
        for option in first_option:last_option
            if solution.M1[option, index_car] > instance.RC_p[option]
                index_car_lim = index_car + min(instance.RC_p[option], solution.length-index_car)
                for index_car_add in index_car:index_car_lim
                    if instance.RC_flag[solution.sequence[index_car_add], option]
                        push!(critical_cars, index_car_add)
                    end
                end
            end
        end
    end
    return collect(critical_cars)
end

find_critical_cars(solution::Solution, instance::Instance, objectives::Int) =
    find_critical_cars(solution::Solution, instance::Instance, [trues(objectives) ; falses(2-objectives)])

"""
    cost(solution::Solution, instance::Instance, objectives::BitArray{1})

Returns the (partial) set of objective values (without weights) of `solution`.
"""
function cost(solution::Solution, instance::Instance, objectives::BitArray{1})
    @assert length(objectives) == 3
    @assert true in objectives
    @assert solution.length == instance.nb_cars
    cost_on_objective = zeros(Int, 3)

    if objectives[1]
        for car in 1:solution.length
            for option in 1:instance.nb_HPRC
                cost_on_objective[1] += max(0 , solution.M1[option, car] - instance.RC_p[option])
            end
        end
    end

    if objectives[2]
        for car in 1:solution.length
            for option in (instance.nb_HPRC+1):(instance.nb_HPRC+instance.nb_LPRC)
                cost_on_objective[2] += max(0 , solution.M1[option, car] - instance.RC_p[option])
            end
        end
    end

    if objectives[3]
        if solution.colors === nothing
            for i in (instance.nb_late_prec_day+2):solution.length
                if instance.color_code[solution.sequence[i]] != instance.color_code[solution.sequence[i-1]]
                    cost_on_objective[3] += 1
                end
            end
        else
            cost_on_objective[3] = -1
            index_batch = instance.nb_late_prec_day + 1
            while index_batch <= solution.length
                index_batch += solution.colors[index_batch].width
                cost_on_objective[3] += 1
            end
        end
    end

    return cost_on_objective
end

"""
    cost(solution::Solution, instance::Instance, objectives::Int)

Returns the set of unweighted values for objectives 1 to `objectives` of `solution`.
"""
cost(solution::Solution, instance::Instance, objectives::Int) =
    cost(solution::Solution, instance::Instance, [trues(objectives) ; falses(3-objectives)])

cost(solution::Solution, instance::Instance) =
    cost(solution::Solution, instance::Instance, 3)

"""
    weighted_sum(cost_solution::Array{Int, 1})

Returns the partial weighted sum of the objective values.
"""
function weighted_sum(cost_solution::Array{Int, 1})
    return sum(cost_solution[i] * WEIGHTS_OBJECTIVE_FUNCTION[i] for i in 1:length(cost_solution))
end

weighted_sum(solution::Solution, instance::Instance) =
    weighted_sum(cost(solution, instance))

"""
    print_cost(solution::Solution, instance::Instance)

Prints the objective values of the solution.
"""
function print_cost(solution::Solution, instance::Instance)
    cost_solution = cost(solution, instance, 3)
    println("\tHPRC violations: ", cost_solution[1])
    println("\tLPRC violations: ", cost_solution[2])
    println("\tPCC  violations: ", cost_solution[3])
    println("\tObjective value is ", weighted_sum(cost_solution))
end

"""

"""
#TODO: why n, why sequence, why not solution ?
function is_sequence_valid(sequence::Array{Int, 1}, n::Int, instance::Instance)
    counter = 1
    for car_pos in (instance.nb_late_prec_day+2):n
        if instance.color_code[sequence[car_pos-1]] == instance.color_code[sequence[car_pos]]
            counter += 1
        else
            counter = 1
        end
        if counter > instance.nb_paint_limitation
            return false
        end
    end
    return true
end


mutable struct LocalApproximationValueIterationSolver{I<:LocalFunctionApproximator, RNG<:AbstractRNG} <: Solver
    interp::I # Will be copied over by value to each policy
    max_iterations::Int64 # max number of iterations
    belres::Float64 # the Bellman Residual
    verbose::Bool # Whether to print while solving or not
    rng::RNG # Seed if req'd
    is_mdp_generative::Bool # Whether to treat underlying MDP model as generative
    n_generative_samples::Int64 # If underlying model generative, how many samples to use
end

# Default constructor
function LocalApproximationValueIterationSolver(interp::I;
                                                max_iterations::Int64=100, belres::Float64=1e-3,
                                                verbose::Bool=false, rng::RNG=Random.GLOBAL_RNG,
                                                is_mdp_generative::Bool=false, n_generative_samples::Int64=0) where {I<:LocalFunctionApproximator, RNG<:AbstractRNG}
    return LocalApproximationValueIterationSolver(interp,max_iterations, belres, verbose, rng, is_mdp_generative, n_generative_samples)
end

# Unparameterized constructor just for getting requirements
function LocalApproximationValueIterationSolver()
    throw(ArgumentError("LocalApproximationValueIterationSolver needs a LocalFunctionApproximator object for construction!"))
end

# NOTE : We work directly with the value function
# And extract actions at the end by using the interpolation object
mutable struct LocalApproximationValueIterationPolicy{I<:LocalFunctionApproximator, RNG<:AbstractRNG} <: Policy
    interp::I # General approximator to be used in VI
    action_map::Vector # Maps the action index to the concrete action type
    mdp::Union{MDP,POMDP} # Uses the model for indexing in the action function
    is_mdp_generative::Bool # (Copied from solver.is_mdp_generative)
    n_generative_samples::Int64 # (Copied from solver.n_generative_samples)
    rng::RNG # (Copied from solver.rng)
end

# The policy can be created using the MDP and solver information
# The policy's function approximation object (interp) is obtained by deep-copying over the
# solver's interp object. The other policy parameters are also obtained from the solver
function LocalApproximationValueIterationPolicy(mdp::Union{MDP,POMDP},
                                                solver::LocalApproximationValueIterationSolver)
    return LocalApproximationValueIterationPolicy(deepcopy(solver.interp),ordered_actions(mdp),mdp,
                                                  solver.is_mdp_generative,solver.n_generative_samples,solver.rng)
end

@POMDP_require solve(solver::LocalApproximationValueIterationSolver, mdp::Union{MDP,POMDP}) begin

    P = typeof(mdp)
    S = statetype(P)
    A = actiontype(P)
    @req discount(::P)
    @req n_actions(::P)
    @subreq ordered_actions(mdp)

    @req action_index(::P, ::A)
    @req actions(::P, ::S)
    as = actions(mdp)
    a = first(as)

    @req convert_s(::Type{S},::V where V <: AbstractVector{Float64},::P)
    @req convert_s(::Type{V} where V <: AbstractVector{Float64},::S,::P)

    # Have different requirements depending on whether solver MDP is generative or explicit
    if solver.is_mdp_generative
        @req generate_sr(::P, ::S, ::A, ::typeof(solver.rng))
    else
        @req transition(::P, ::S, ::A)
        pts = get_all_interpolating_points(solver.interp)
        pt = first(pts)
        ss = POMDPs.convert_s(S,pt,mdp)
        dist = transition(mdp, ss, a)
        D = typeof(dist)
        @req support(::D)
    end

end

function solve(solver::LocalApproximationValueIterationSolver, mdp::Union{MDP,POMDP})

    @warn_requirements solve(solver,mdp)

    # Ensure that generative model has a non-zero number of samples
    if solver.is_mdp_generative
        @assert solver.n_generative_samples > 0
    end

    # Solver parameters
    max_iterations = solver.max_iterations
    belres = solver.belres
    discount_factor = discount(mdp)

    # Initialize the policy
    policy = LocalApproximationValueIterationPolicy(mdp,solver)

    total_time::Float64 = 0.0
    iter_time::Float64 = 0.0

    # Get attributes of interpolator
    # Since the policy object is created by the solver, it directly
    # modifies the value of the interpolator of the created policy
    num_interps::Int = n_interpolating_points(policy.interp)
    interp_points::Vector = get_all_interpolating_points(policy.interp)
    interp_values::Vector = get_all_interpolating_values(policy.interp)

    # Obtain the vector of states by converting the corresponding
    # vector of interpolation points/samples to the state type
    # using the user-provided convert_s function
    S = statetype(typeof(mdp))
    interp_states = Vector{S}(undef, num_interps)
    for (i,pt) in enumerate(interp_points)
        interp_states[i] = POMDPs.convert_s(S, pt, mdp)
    end

    # Outer loop for Value Iteration
    for i = 1 : max_iterations
        residual::Float64 = 0.0
        iter_time = @elapsed begin

        for (istate,s) in enumerate(interp_states)
            sub_aspace = actions(mdp,s)

            if isterminal(mdp, s)
                interp_values[istate] = 0.0
            else
                old_util = interp_values[istate]
                max_util = -Inf

                for a in sub_aspace
                    iaction = action_index(mdp,a)
                    u::Float64 = 0.0

                    # Do bellman backup based on generative / explicit model
                    if solver.is_mdp_generative
                        # Generative Model
                        for j in 1:solver.n_generative_samples
                            sp, r = generate_sr(mdp, s, a, solver.rng)
                            u += r

                            # Only interpolate sp if it is non-terminal
                            if !isterminal(mdp,sp)
                                sp_point = POMDPs.convert_s(Vector{Float64}, sp, mdp)
                                u += discount_factor*compute_value(policy.interp, sp_point)
                            end
                        end
                        u = u / solver.n_generative_samples
                    else
                        # Explicit Model
                        dist = transition(mdp,s,a)
                        for (sp, p) in weighted_iterator(dist)
                            p == 0.0 ? continue : nothing
                            r = reward(mdp, s, a, sp)
                            u += p*r

                            # Only interpolate sp if it is non-terminal
                            if !isterminal(mdp,sp)
                                sp_point = POMDPs.convert_s(Vector{Float64}, sp, mdp)
                                u += p * (discount_factor*compute_value(policy.interp, sp_point))
                            end
                        end # next-states
                    end

                    max_util = (u > max_util) ? u : max_util
                end #action

                # Update this interpolant value
                interp_values[istate] = max_util
                util_diff = abs(max_util - old_util)
                util_diff > residual ? (residual = util_diff) : nothing
            end
        end #state

        end #time
        total_time += iter_time
        solver.verbose ? @printf("[Iteration %-4d] residual: %10.3G | iteration runtime: %10.3f ms, (%10.3G s total)\n", i, residual, iter_time*1000.0, total_time) : nothing
        residual < belres ? break : nothing

    end #main
    return policy
end

function value(policy::LocalApproximationValueIterationPolicy, s::S) where S

    # Call the conversion function on the state to get the corresponding vector
    # That represents the point at which to interpolate the function
    s_point = POMDPs.convert_s(Vector{Float64}, s, policy.mdp)
    val = compute_value(policy.interp, s_point)
    return val
end

# Not explicitly stored in policy - extract from value function interpolation
function action(policy::LocalApproximationValueIterationPolicy, s::S) where S

    mdp = policy.mdp
    best_a_idx = -1
    max_util = -Inf
    sub_aspace = actions(mdp,s)
    discount_factor = discount(mdp)

    for a in iterator(sub_aspace)

        iaction = action_index(mdp, a)
        u::Float64 = action_value(policy,s,a)

        if u > max_util
            max_util = u
            best_a_idx = iaction
        end
    end

    return policy.action_map[best_a_idx]
end

# Compute the action-value for some state-action pair
# This is also used in the above function
function action_value(policy::LocalApproximationValueIterationPolicy, s::S, a::A) where {S,A}

    mdp = policy.mdp
    discount_factor = discount(mdp)

    u::Float64 = 0.0

    # As in solve(), do different things based on whether
    # mdp is generative or explicit
    if policy.is_mdp_generative
        for j in 1:policy.n_generative_samples
            sp, r = generate_sr(mdp, s, a, policy.rng)
            sp_point = POMDPs.convert_s(Vector{Float64}, sp, mdp)
            u += r + discount_factor*compute_value(policy.interp, sp_point)
        end
        u = u / policy.n_generative_samples
    else
        dist = transition(mdp,s,a)
        for (sp, p) in weighted_iterator(dist)
            p == 0.0 ? continue : nothing
            r = reward(mdp, s, a, sp)
            u += p*r

            # Only interpolate sp if it is non-terminal
            if !isterminal(mdp,sp)
                sp_point = POMDPs.convert_s(Vector{Float64}, sp, mdp)
                u += p*(discount_factor*compute_value(policy.interp, sp_point))
            end
        end
    end

    return u
end


################################################################################
######################### LowLevelSolution Constructors ########################
################################################################################
export
    get_initial_solution,
    get_infeasible_solution,
    default_solution

"""
    `get_initial_solution`
"""
function get_initial_solution(mapf::MAPF{E,S,G}) where {S,A,G,T,C<:AbstractCostModel{T},E<:AbstractLowLevelEnv{S,A,C}}
    LowLevelSolution{S,A,T,C}(
        paths = map(i->Path{S,A,T}(
                    s0=get_start(mapf,i),
                    cost=get_initial_cost(mapf.env)),
                1:num_agents(mapf)),
        cost_model = get_cost_model(mapf.env),
        costs = Vector{T}(map(a->get_initial_cost(mapf.env),1:num_agents(mapf))),
        cost = get_initial_cost(mapf.env),
    )
end
"""
    `get_infeasible_solution`
"""
function get_infeasible_solution(mapf::MAPF{E,S,G}) where {S,A,G,T,C<:AbstractCostModel{T},E<:AbstractLowLevelEnv{S,A,C}}
    LowLevelSolution{S,A,T,C}(
        paths = Vector{Path{S,A,T}}(map(a->Path{S,A,T}(cost=get_initial_cost(mapf.env)),1:num_agents(mapf))),
        cost_model = get_cost_model(mapf.env),
        costs = Vector{T}(map(a->get_initial_cost(mapf.env),1:num_agents(mapf))),
        cost = get_infeasible_cost(mapf.env),
    )
end
"""
    `default_solution(solver, mapf::AbstractMAPF)`

    Defines what is returned by the solver in case of failure to find a feasible
    solution.
"""
function default_solution(mapf::M) where {M<:AbstractMAPF}
    return get_infeasible_solution(mapf), get_infeasible_cost(mapf.env)
end

export add_to_path!

"""
    add_to_path!(path,env,s,a,sp)

Adds the new (s,a,sp) tuple and its cost (under env) to path.
"""
function add_to_path!(path::Path,env,s,a,sp)
    push!(path,PathNode(s,a,sp))
    set_cost!(path,accumulate_cost(env,get_cost(path),
        get_transition_cost(env,s,a,sp)))
    path
end

################################################################################
################################# Extend Paths #################################
################################################################################
export
    extend_path!

function extend_path!(env::E,path::P,T::Int) where {E<:AbstractLowLevelEnv,P<:Path}
    # while get_index_from_time(path,get_end_index(path)) < T
    while get_end_index(path) < T
        s = get_final_state(path)
        a = wait(env,s)
        sp = get_next_state(env,s,a)
        push!(path,PathNode(s,a,sp))
        set_cost!(path, accumulate_cost(env, get_cost(path), get_transition_cost(env,s,a,sp)))
    end
    return path
end
function extend_path!(env::E,path::P,T::Float64) where {E<:AbstractLowLevelEnv,P<:Path}
    @assert abs(T - Int(round(T))) < 0.01
    extend_path!(env,path,Int(round(T)))
end
function extend_path(env::E,path::P,args...) where {E<:AbstractLowLevelEnv,P<:Path}
    new_path = copy(path)
    extend_path!(new_path,args...)
    return new_path
end

get_transition_cost(env,n::PathNode) = get_transition_cost(env,get_s(n),get_a(n),get_sp(n))

export recompute_cost

"""
    recompute_cost

Recompute the cost of `path` (according to `env`), beginning from initial cost
`c0`.
"""
function recompute_cost(env,path,c0=get_initial_cost(env))
    cost = c0
    for n in path.path_nodes
        cost = accumulate_cost(env,cost,get_transition_cost(env,n))
    end
    return cost
end

export replace_cost_model

export trim_path!

"""
    trim_path!(env,path,T)

Modify `path` to terminate at time step `T`. If `length(path) < T`, the path
will be extended to time step `T`.
"""
function trim_path!(env,path,T)
    while length(path) > T
        # deleteat!(path.path_nodes,T)
        pop!(path.path_nodes)
    end
    if length(path) < T
        extend_path!(env,path,T)
    end
    set_cost!(path,recompute_cost(env,path))
    @assert length(path) == T
    return path
end

export trim_solution!

"""
    trim_solution!

Modify `solution` so that all paths terminate at time step `T`.
"""
function trim_solution!(env,solution,T)
    for (agent_id,path) in enumerate(get_paths(solution))
        trim_path!(env,path,T)
        set_path_cost!(solution,get_cost(path),agent_id)
    end
    set_cost!(solution,
        aggregate_costs(get_cost_model(env),get_path_costs(solution)))
    return solution
end

export sorted_actions

"""
    sorted_actions(env,s)

Return a vector of actions sorted lowest cost to highest cost.
"""
function sorted_actions(env,s)
    f = (s,a,sp)->compute_heuristic_cost(env,get_transition_cost(env,s,a,sp),sp)
    sort(
        collect(get_possible_actions(env,s)),
        by=a->f(s,a,get_next_state(env,s,a))
    )
end

################################################################################
################################## Utilities ###################################
################################################################################
export
    is_consistent,
    assert_valid,
    is_valid

"""
    is_consistent(solution,mapf)

    Check if solution satisfies start and end constraints
"""
function is_consistent(path::P,start::S,goal::G) where {S,G,P<:Path}
    return (states_match(get_initial_state(path), start)
        && states_match(get_final_state(path), goal))

end
function is_consistent(paths::Vector{P},starts::Vector{S},goals::Vector{G}) where {S,G,P <: Path}
    for (i,path) in enumerate(paths)
        if !is_consistent(path,starts[i],goals[i])
            return false
        end
    end
    return true
end
function is_consistent(solution::L,starts::Vector{S},goals::Vector{G}) where {S,G,L<:LowLevelSolution}
    is_consistent(get_paths(solution),starts,goals)
end
function is_consistent(solution::L,mapf::M) where {L<:LowLevelSolution, M<:AbstractMAPF}
    is_consistent(solution,get_starts(mapf),get_goals(mapf))
end
assert_valid(args...) = @assert(is_consistent(args...))
function is_valid(r)
    try
        assert_valid(r)
    catch e
        if isa(e,AssertionError)
           showerror(stdout,e)
           return false
        else
           rethrow(e)
        end
    end
    return true
end

################################################################################
######################### Visualization and Debugging ##########################
################################################################################

export
    convert_to_vertex_lists

function convert_to_vertex_lists(path) end
function convert_to_vertex_lists(solution::S) where {S<:LowLevelSolution}
    return [convert_to_vertex_lists(path) for path in get_paths(solution)]
end

export
    sprint_route_plan

function sprint_route_plan(io::IO,solution::LowLevelSolution;kwargs...)
    sprint_indexed_list_array(io::IO,convert_to_vertex_lists(solution);kwargs...)
end
function sprint_route_plan(route_plan::LowLevelSolution;kwargs...)
    buffer = IOBuffer()
    sprint_route_plan(buffer,route_plan;kwargs...)
    String(take!(buffer))
end
function Base.show(io::IO,route_plan::LowLevelSolution)
    # buffer = IOBuffer()
    # print(buffer,"LowLevelSolution:\n")
    print(io,"LowLevelSolution:\n")
    # sprint_route_plan(buffer,route_plan;leftaligned=true)
    sprint_route_plan(io,route_plan;leftaligned=true)
    # print(io,String(take!(buffer)))
end

export
    summarize

summarize(env) = ""


""" Utilities for integrating GPS with InSAR maps

Links:

1. list of LLH for all gps stations: ftp://gneiss.nbmg.unr.edu/rapids/llh
Note: ^^ This file is stored in the `STATION_LLH_FILE`

2. Clickable names to explore: http://geodesy.unr.edu/NGLStationPages/GlobalStationList
3. Map of stations: http://geodesy.unr.edu/NGLStationPages/gpsnetmap/GPSNetMap.html

"""

import CSV

const GPS_BASE_URL = "http://geodesy.unr.edu/gps_timeseries/tenv3/NA12/{station}.NA12.tenv3"

const STATION_LLH_FILE = joinpath(@__DIR__, "station_llh_all.csv")
const START_YEAR = 2014  # So far I don't care about older data

"""Returns the config folder for the application.  The default behavior
is to return whatever is most appropriate for the operating system.

This is used to store gps timeseries data

the following folders could be returned:
Mac OS X:
  ``~/Library/Application Support/apertools``
Mac OS X (POSIX):
  ``~/.apertools``
Unix:
  ``~/.cache/apertools``
Unix (POSIX):
  ``~/.apertools``

force_posix: if this is set to `true` then on any POSIX system the
    folder will be stored in the home folder with a leading
    dot instead of the XDG config home or darwin's
    application support folder.

Base on: https://github.com/pallets/click/blob/master/click/utils.py#L368
"""
function get_cache_dir(force_posix=false, app_name="gps")
    if force_posix
        path = joinpath(expanduser("~/.$app_name"))
    end
    # if Sys.isapple()
    #     path = joinpth(expanduser("~/Library/Application Support"), app_name)
    # end
    path = joinpath(
        get(ENV, "XDG_CONFIG_HOME", expanduser("~/.cache")),
        app_name
    )
    !exists(path) && mkdir(path)
    return path
end

"Read in a DataFrame of gps stations with columns name, lat, lon, alt"
read_station_llas() = CSV.read(STATION_LLH_FILE)
# columns = ["name", "lat", "lon", "alt"]

"Return the (lon, lat) of a `station_name`"
function station_lonlat(station_name::AbstractString)
    df = read_station_llas()
    !(station_name in df.name)&& error("No station named $station_name found.")
    # TODO:
    # closest_names = difflib.get_close_matches(station_name, df.name, n=5)
    # error("No station named %s found. Closest: %s" % (station_name, closest_names))
    name, lat, lon, alt = df[df.name .== station_name, :][1, :]
    return lon, lat
end

station_latlon(station_name) = reverse(station_lonlat(station_name))

station_rowcol(name::AbstractString, demrsc::DemRsc) = latlon_to_rowcol(demrsc, station_latlon(name)...)
station_rowcol(name::AbstractString, img::MapImage) = latlon_to_rowcol(img.demrsc, station_latlon(name)...)

# function station_distance(station_name1, station_name2)
#     lonlat1 = station_lonlat(station_name1)
#     lonlat2 = station_lonlat(station_name2)
#     return apertools.latlon.latlon_to_dist(lonlat1[::-1], lonlat2[::-1])
# end

# function stations_within_rsc(rsc_filename=nothing, demrsc=nothing, gps_filename=nothing)
#     if demrsc is nothing
#         if rsc_filename is nothing
#             error("Need demrsc or rsc_filename")
#         end
#         demrsc = apertools.sario.load(rsc_filename)
#     end
#
#     df = read_station_llas(filename=gps_filename)
#     station_lon_lat_arr = df[["lon", "lat"]].values
#     contains_bools = [apertools.latlon.grid_contains(s, **demrsc) for s in station_lon_lat_arr]
#     return df[contains_bools][["name", "lon", "lat"]].values
# end

function download_station_data(station_name::AbstractString)
    station_name = station_name.upper()
    url = GPS_BASE_URL.format(station=station_name)
    response = requests.get(url)

    stationfile = split(url, "/")[-1]  # Just blah.NA12.tenv3
    filename = "$GPS_DIR/$stationfile"
    println("Saving to $filename")

    write(filename, response.text)
end

function load_station_enu(station::AbstractString; start_year=START_YEAR, end_year=nothing, to_cm=true)
    """Loads one gps station's ENU data since start_year until end_year
    as separate Series items

    Will scale to cm by default, and center the first data point
    to 0
    """
    station_df = load_station_data(station, to_cm=to_cm, start_year=start_year, end_year=end_year)

    start_val = station_df[["east", "north", "up"]].iloc[:10].mean()
    enu_zeroed = station_df[["east", "north", "up"]] - start_val
    dts = station_df["dt"]
    return dts, enu_zeroed
end

function load_station_data(station_name::AbstractString;
                      start_year=START_YEAR,
                      end_year=nothing,
                      download_if_missing=true,
                      to_cm=true)
    """Loads one gps station's ENU data since start_year until end_year as a dataframe

    Args:
        station_name (str): 4 Letter name of GPS station
            See http://geodesy.unr.edu/NGLStationPages/gpsnetmap/GPSNetMap.html for map
        start_year (int), default 2014, cutoff for beginning of GPS data
        end_year (int): default nothing, cut off for end of GPS data
        download_if_missing (bool): default true
    """
    station_name = station_name.upper()
    gps_data_file = os.path.join(GPS_DIR, "%s.NA12.tenv3" % station_name)
    if not os.path.exists(gps_data_file)
        logger.warning("%s does not exist.", gps_data_file)
        if download_if_missing
            logger.info("Downloading %s", station_name)
            download_station_data(station_name)
        end
    end

    df = pd.read_csv(gps_data_file, header=0, sep=r"\s+")
    clean_df = _clean_gps_df(df, start_year, end_year)
    if to_cm
        # logger.info("Converting %s GPS to cm" % station_name)
        clean_df[["east", "north", "up"]] = 100 * clean_df[["east", "north", "up"]]
    end
    return clean_df
end

function _clean_gps_df(df, start_year=nothing, end_year=nothing)
    df["dt"] = pd.to_datetime(df["YYMMMDD"], format="%y%b%d")

    if start_year
        df_ranged = df[df["dt"] >= datetime.datetime(start_year, 1, 1)]
    end
    if end_year
        df_ranged = df_ranged[df_ranged["dt"] <= datetime.datetime(end_year, 1, 1)]
    end
    df_enu = df_ranged[["dt", "__east(m)", "_north(m)", "____up(m)"]]
    df_enu = df_enu.rename(mapper=s -> replace(replace(s, "_" => ""), "(m)" => ""), axis="columns")
    df_enu.reset_index(inplace=true, drop=true)
    return df_enu
end

# function stations_within_image(image_ll=nothing, filename=nothing, mask_invalid=true, gps_filename=nothing)
#     """Given an image, find gps stations contained in area
#
#     Must have an associated .rsc file, either from being a LatlonImage (passed
#     by image_ll), or have the file in same directory as `filename`
#
#     Args
#         image_ll (LatlonImage) LatlonImage of area with data
#         filename (str) filename to load into a LatlonImage
#         mask_invalid (bool) Default true. if true, don't return stations
#             where the image value is NaN or exactly 0
#
#     Returns
#         ndarray Nx3, with columns ["name", "lon", "lat"]
#     """
#     if image_ll is nothing
#         image_ll = apertools.latlon.LatlonImage(filename=filename)
#     end
#
#     df = read_station_llas(filename=gps_filename)
#     station_lon_lat_arr = df[[:lon, :lat]].values
#     contains_bools = image_ll.contains(station_lon_lat_arr)
#     candidates = df[contains_bools][["name", "lon", "lat"]].values
#     good_stations = []
#     if mask_invalid
#         for name, lon, lat in candidates
#             val = image_ll[..., lat, lon]
#             if np.isnan(val)  # or val == 0 TODO with window 1 reference, it's 0
#                 continue
#             else
#                 good_stations.append([name, lon, lat])
#             end
#         end
#     else
#         good_stations = candidates
#     end
#     return good_stations
# end

# function save_station_points_kml(station_iter)
#     for name, lat, lon, alt in station_iter
#         apertools.kml.create_kml(
#             title=name,
#             desc="GPS station location",
#             lon_lat=(lon, lat),
#             kml_out="%s.kml" % name,
#             shape="point",
#         )
#     end
# end


# The methods below are specialized on the mortar type
# and called from the basic `create_cache` method at the top.
function create_cache(mesh::P4estMesh{3}, equations, mortar_l2::LobattoLegendreMortarL2, uEltype)
  # TODO: Taal compare performance of different types
  fstar_threaded = [Array{uEltype, 4}(undef, nvariables(equations), nnodes(mortar_l2), nnodes(mortar_l2), 4)
                    for _ in 1:Threads.nthreads()]

  fstar_tmp_threaded = [Array{uEltype, 3}(undef, nvariables(equations), nnodes(mortar_l2), nnodes(mortar_l2))
                         for _ in 1:Threads.nthreads()]
  u_threaded          = [Array{uEltype, 3}(undef, nvariables(equations), nnodes(mortar_l2), nnodes(mortar_l2))
                         for _ in 1:Threads.nthreads()]

  (; fstar_threaded, fstar_tmp_threaded, u_threaded)
end

function prolong2interfaces!(cache, u,
                             mesh::P4estMesh{3},
                             equations, surface_integral, dg::DG)
  @unpack interfaces = cache

  size_ = (nnodes(dg), nnodes(dg), nnodes(dg))

  @threaded for interface in eachinterface(dg, cache)
    primary_element   = interfaces.element_ids[1, interface]
    secondary_element = interfaces.element_ids[2, interface]

    primary_indices   = interfaces.node_indices[1, interface]
    secondary_indices = interfaces.node_indices[2, interface]

    # Use Tuple `node_indices` and `evaluate_index` to copy values
    # from the correct face and in the correct orientation
    for j in eachnode(dg), i in eachnode(dg)
      for v in eachvariable(equations)
        interfaces.u[1, v, i, j, interface] = u[v, evaluate_index(primary_indices, size_, 1, i, j),
                                                   evaluate_index(primary_indices, size_, 2, i, j),
                                                   evaluate_index(primary_indices, size_, 3, i, j),
                                                   primary_element]

        interfaces.u[2, v, i, j, interface] = u[v, evaluate_index(secondary_indices, size_, 1, i, j),
                                                   evaluate_index(secondary_indices, size_, 2, i, j),
                                                   evaluate_index(secondary_indices, size_, 3, i, j),
                                                   secondary_element]
      end
    end
  end

  return nothing
end

function calc_interface_flux!(surface_flux_values,
                              mesh::P4estMesh{3},
                              nonconservative_terms::Val{false},
                              equations, surface_integral, dg::DG, cache)
  @unpack surface_flux = surface_integral
  @unpack u, element_ids, node_indices = cache.interfaces

  size_ = (nnodes(dg), nnodes(dg), nnodes(dg))

  @threaded for interface in eachinterface(dg, cache)
    # Get neighboring elements
    primary_element   = element_ids[1, interface]
    secondary_element = element_ids[2, interface]

    primary_indices   = node_indices[1, interface]
    secondary_indices = node_indices[2, interface]

    primary_direction   = indices2direction(primary_indices)
    secondary_direction = indices2direction(secondary_indices)

    # Use Tuple `node_indices` and `evaluate_index` to access node indices
    # at the correct face and in the correct orientation to get normal vectors
    for j in eachnode(dg), i in eachnode(dg)
      u_ll, u_rr = get_surface_node_vars(u, equations, dg, i, j, interface)

      normal_vector = get_normal_vector(primary_direction, cache,
                                        evaluate_index(primary_indices, size_, 1, i, j),
                                        evaluate_index(primary_indices, size_, 2, i, j),
                                        evaluate_index(primary_indices, size_, 3, i, j),
                                        primary_element)

      flux_ = surface_flux(u_ll, u_rr, normal_vector, equations)

      # Use Tuple `node_indices` and `evaluate_index_surface` to copy flux
      # to left and right element storage in the correct orientation
      for v in eachvariable(equations)
        surf_i = evaluate_index_surface(primary_indices, size_, 1, i, j)
        surf_j = evaluate_index_surface(primary_indices, size_, 2, i, j)
        surface_flux_values[v, surf_i, surf_j, primary_direction, primary_element] = flux_[v]

        surf_i = evaluate_index_surface(secondary_indices, size_, 1, i, j)
        surf_j = evaluate_index_surface(secondary_indices, size_, 2, i, j)
        surface_flux_values[v, surf_i, surf_j, secondary_direction, secondary_element] = -flux_[v]
      end
    end
  end

  return nothing
end

function prolong2boundaries!(cache, u,
                             mesh::P4estMesh{3},
                             equations, surface_integral, dg::DG)
  @unpack boundaries = cache

  size_ = (nnodes(dg), nnodes(dg), nnodes(dg))

  @threaded for boundary in eachboundary(dg, cache)
    element       = boundaries.element_ids[boundary]
    node_indices  = boundaries.node_indices[boundary]

    # Use Tuple `node_indices` and `evaluate_index` to copy values
    # from the correct face and in the correct orientation
    for j in eachnode(dg), i in eachnode(dg)
      for v in eachvariable(equations)
        boundaries.u[v, i, j, boundary] = u[v, evaluate_index(node_indices, size_, 1, i, j),
                                               evaluate_index(node_indices, size_, 2, i, j),
                                               evaluate_index(node_indices, size_, 3, i, j),
                                               element]
      end
    end
  end

  return nothing
end

function calc_boundary_flux!(cache, t, boundary_condition, boundary_indexing,
                             mesh::P4estMesh{3},
                             equations, surface_integral, dg::DG)
  @unpack boundaries = cache
  @unpack surface_flux_values, node_coordinates = cache.elements
  @unpack surface_flux = surface_integral

  size_ = (nnodes(dg), nnodes(dg), nnodes(dg))

  @threaded for local_index in eachindex(boundary_indexing)
    # Use the local index to get the global boundary index from the pre-sorted list
    boundary = boundary_indexing[local_index]

    element       = boundaries.element_ids[boundary]
    node_indices  = boundaries.node_indices[boundary]
    direction     = indices2direction(node_indices)

    # Use Tuple `node_indices` and `evaluate_index` to access node indices
    # at the correct face and in the correct orientation to get normal vectors
    for j in eachnode(dg), i in eachnode(dg)
      node_i = evaluate_index(node_indices, size_, 1, i, j)
      node_j = evaluate_index(node_indices, size_, 2, i, j)
      node_k = evaluate_index(node_indices, size_, 3, i, j)

      # Extract solution data from boundary container
      u_inner = get_node_vars(boundaries.u, equations, dg, i, j, boundary)

      # Outward-pointing normal vector
      normal_vector = get_normal_vector(direction, cache, node_i, node_j, node_k, element)

      # Coordinates at boundary node
      x = get_node_coords(node_coordinates, equations, dg, node_i, node_j, node_k, element)

      flux_ = boundary_condition(u_inner, normal_vector, x, t, surface_flux, equations)

      # Use Tuple `node_indices` and `evaluate_index_surface` to copy flux
      # to left and right element storage in the correct orientation
      for v in eachvariable(equations)
        surf_i = evaluate_index_surface(node_indices, size_, 1, i, j)
        surf_j = evaluate_index_surface(node_indices, size_, 2, i, j)
        surface_flux_values[v, surf_i, surf_j, direction, element] = flux_[v]
      end
    end
  end
end

function prolong2mortars!(cache, u,
                          mesh::P4estMesh{3}, equations,
                          mortar_l2::LobattoLegendreMortarL2,
                          surface_integral, dg::DGSEM)
  # temporary buffer for projections
  @unpack fstar_tmp_threaded = cache
  @unpack element_ids, node_indices = cache.mortars

  size_ = (nnodes(dg), nnodes(dg), nnodes(dg))

  @threaded for mortar in eachmortar(dg, cache)
    fstar_tmp = fstar_tmp_threaded[Threads.threadid()]

    small_indices = node_indices[1, mortar]
    large_indices = node_indices[2, mortar]

    # Copy solution small to small
    for pos in 1:4
      for j in eachnode(dg), i in eachnode(dg)
        for v in eachvariable(equations)
          # Use Tuple `node_indices` and `evaluate_index` to copy values
          # from the correct face and in the correct orientation
          cache.mortars.u[1, v, pos, i, j, mortar] = u[v, evaluate_index(small_indices, size_, 1, i, j),
                                                          evaluate_index(small_indices, size_, 2, i, j),
                                                          evaluate_index(small_indices, size_, 3, i, j),
                                                          element_ids[pos, mortar]]
        end
      end
    end

    # Buffer to copy solution values of the large element in the correct orientation
    # before interpolating
    u_buffer = cache.u_threaded[Threads.threadid()]

    # Copy solution of large element face to buffer in the correct orientation
    for j in eachnode(dg), i in eachnode(dg)
      for v in eachvariable(equations)
        # Use Tuple `node_indices` and `evaluate_index` to copy values
        # from the correct face and in the correct orientation
        u_buffer[v, i, j] = u[v, evaluate_index(large_indices, size_, 1, i, j),
                                 evaluate_index(large_indices, size_, 2, i, j),
                                 evaluate_index(large_indices, size_, 3, i, j),
                                 element_ids[5, mortar]]
      end
    end

    # Interpolate large element face data from buffer to small face locations
    multiply_dimensionwise!(view(cache.mortars.u, 2, :, 1, :, :, mortar),
                            mortar_l2.forward_lower,
                            mortar_l2.forward_lower,
                            u_buffer,
                            fstar_tmp)
    multiply_dimensionwise!(view(cache.mortars.u, 2, :, 2, :, :, mortar),
                            mortar_l2.forward_upper,
                            mortar_l2.forward_lower,
                            u_buffer,
                            fstar_tmp)
    multiply_dimensionwise!(view(cache.mortars.u, 2, :, 3, :, :, mortar),
                            mortar_l2.forward_lower,
                            mortar_l2.forward_upper,
                            u_buffer,
                            fstar_tmp)
    multiply_dimensionwise!(view(cache.mortars.u, 2, :, 4, :, :, mortar),
                            mortar_l2.forward_upper,
                            mortar_l2.forward_upper,
                            u_buffer,
                            fstar_tmp)
  end

  return nothing
end

function calc_mortar_flux!(surface_flux_values,
                           mesh::P4estMesh{3},
                           nonconservative_terms::Val{false}, equations,
                           mortar_l2::LobattoLegendreMortarL2,
                           surface_integral, dg::DG, cache)
  @unpack u, element_ids, node_indices = cache.mortars
  @unpack fstar_threaded, fstar_tmp_threaded = cache
  @unpack surface_flux = surface_integral

  size_ = (nnodes(dg), nnodes(dg), nnodes(dg))

  @threaded for mortar in eachmortar(dg, cache)
    # Choose thread-specific pre-allocated container
    fstar = fstar_threaded[Threads.threadid()]
    fstar_tmp = fstar_tmp_threaded[Threads.threadid()]

    small_indices = node_indices[1, mortar]
    small_direction = indices2direction(small_indices)

    # Use Tuple `node_indices` and `evaluate_index` to access node indices
    # at the correct face and in the correct orientation to get normal vectors
    for pos in 1:4
      for j in eachnode(dg), i in eachnode(dg)
        u_ll, u_rr = get_surface_node_vars(u, equations, dg, pos, i, j, mortar)

        normal_vector = get_normal_vector(small_direction, cache,
                                          evaluate_index(small_indices, size_, 1, i, j),
                                          evaluate_index(small_indices, size_, 2, i, j),
                                          evaluate_index(small_indices, size_, 3, i, j),
                                          element_ids[pos, mortar])

        flux_ = surface_flux(u_ll, u_rr, normal_vector, equations)

        # Copy flux to buffer
        set_node_vars!(fstar, flux_, equations, dg, i, j, pos)
      end
    end

    # Buffer to interpolate flux values of the large element to before copying
    # in the correct orientation
    u_buffer = cache.u_threaded[Threads.threadid()]

    mortar_fluxes_to_elements!(surface_flux_values,
                               mesh, equations, mortar_l2, dg, cache,
                               mortar, fstar, u_buffer, fstar_tmp)
  end

  return nothing
end

@inline function mortar_fluxes_to_elements!(surface_flux_values,
                                            mesh::P4estMesh{3}, equations,
                                            mortar_l2::LobattoLegendreMortarL2,
                                            dg::DGSEM, cache, mortar, fstar, u_buffer, fstar_tmp)
  @unpack element_ids, node_indices = cache.mortars

  small_indices  = node_indices[1, mortar]
  large_indices  = node_indices[2, mortar]

  small_direction = indices2direction(small_indices)
  large_direction = indices2direction(large_indices)

  size_ = (nnodes(dg), nnodes(dg), nnodes(dg))

  # Copy solution small to small
  for pos in 1:4
    for j in eachnode(dg), i in eachnode(dg)
      for v in eachvariable(equations)
        # Use Tuple `node_indices` and `evaluate_index_surface` to copy flux
        # to left and right element storage in the correct orientation
        surface_index1 = evaluate_index_surface(small_indices, size_, 1, i, j)
        surface_index2 = evaluate_index_surface(small_indices, size_, 2, i, j)
        surface_flux_values[v, surface_index1, surface_index2, small_direction,
                            element_ids[pos, mortar]] = fstar[v, i, j, pos]
      end
    end
  end

  large_element = element_ids[5, mortar]

  # Project small fluxes to large element.
  multiply_dimensionwise!(
    u_buffer,
    mortar_l2.reverse_lower, mortar_l2.reverse_lower,
    view(fstar, .., 1),
    fstar_tmp)
  add_multiply_dimensionwise!(
    u_buffer,
    mortar_l2.reverse_upper, mortar_l2.reverse_lower,
    view(fstar, .., 2),
    fstar_tmp)
  add_multiply_dimensionwise!(
    u_buffer,
    mortar_l2.reverse_lower, mortar_l2.reverse_upper,
    view(fstar, .., 3),
    fstar_tmp)
  add_multiply_dimensionwise!(
    u_buffer,
    mortar_l2.reverse_upper, mortar_l2.reverse_upper,
    view(fstar, .., 4),
    fstar_tmp)

  # The flux is calculated in the outward direction of the small elements,
  # so the sign must be switched to get the flux in outward direction
  # of the large element.
  # The contravariant vectors of the large element (and therefore the normal vectors
  # of the large element as well) are four times as large as the contravariant vectors
  # of the small elements. Therefore, the flux need to be scaled by a factor of 4
  # to obtain the flux of the large element.
  u_buffer .*= -4

  # Copy interpolated flux values from buffer to large element face in the correct orientation
  for j in eachnode(dg), i in eachnode(dg)
    for v in eachvariable(equations)
      # Use Tuple `node_indices` and `evaluate_index_surface` to copy flux
      # to surface flux storage in the correct orientation
      surface_index1 = evaluate_index_surface(large_indices, size_, 1, i, j)
      surface_index2 = evaluate_index_surface(large_indices, size_, 2, i, j)
      surface_flux_values[v, surface_index1, surface_index2,
                          large_direction, large_element] = u_buffer[v, i, j]
    end
  end

  return nothing
end

function calc_surface_integral!(du, u,
                                mesh::P4estMesh{3},
                                equations,
                                surface_integral::SurfaceIntegralWeakForm,
                                dg::DGSEM, cache)
  @unpack boundary_interpolation = dg.basis
  @unpack surface_flux_values = cache.elements

  # Note that all fluxes have been computed with outward-pointing normal vectors
  @threaded for element in eachelement(dg, cache)
    for m in eachnode(dg), l in eachnode(dg)
      for v in eachvariable(equations)
        # surface at -x
        du[v, 1,          l, m, element] += (surface_flux_values[v, l, m, 1, element]
                                             * boundary_interpolation[1, 1])
        # surface at +x
        du[v, nnodes(dg), l, m, element] += (surface_flux_values[v, l, m, 2, element]
                                             * boundary_interpolation[nnodes(dg), 2])
        # surface at -y
        du[v, l, 1,          m, element] += (surface_flux_values[v, l, m, 3, element]
                                             * boundary_interpolation[1, 1])
        # surface at +y
        du[v, l, nnodes(dg), m, element] += (surface_flux_values[v, l, m, 4, element]
                                             * boundary_interpolation[nnodes(dg), 2])
        # surface at -z
        du[v, l, m, 1,          element] += (surface_flux_values[v, l, m, 5, element]
                                             * boundary_interpolation[1,          1])
        # surface at +z
        du[v, l, m, nnodes(dg), element] += (surface_flux_values[v, l, m, 6, element]
                                             * boundary_interpolation[nnodes(dg), 2])
      end
    end
  end

  return nothing
end


######## CONSTRAINTS ############

# Generic fallback functions
function get_startup_shutdown(
    device,
    ::Type{<:VariableType},
    ::Type{<:AbstractDeviceFormulation},
) #  -> Union{Nothing, NamedTuple{(:startup, :shutdown), Tuple{Float64, Float64}}}
    nothing
end

function get_min_max_limits(
    device,
    ::Type{<:VariableType},
    ::Type{<:AbstractDeviceFormulation},
) #  -> Union{Nothing, NamedTuple{(:min, :max), Tuple{Float64, Float64}}}
    return (min=0.0, max=0.0)
end

@doc raw"""
Constructs min/max range constraint from device variable.

If min and max within an epsilon width:

``` variable[name, t] == limits.max ```

Otherwise:

``` limits.min <= variable[name, t] <= limits.max ```

where limits in constraint_infos.

# LaTeX

`` x = limits^{max}, \text{ for } |limits^{max} - limits^{min}| < \varepsilon ``

`` limits^{min} \leq x \leq limits^{max}, \text{ otherwise } ``
"""
function add_range_constraints!(
    container::OptimizationContainer,
    ::Type{T},
    ::Type{U},
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
    ::Type{X},
) where {
    T <: ConstraintType,
    U <: VariableType,
    V <: PSY.Component,
    W <: AbstractDeviceFormulation,
    X <: PM.AbstractPowerModel,
}
    array = get_variable(container, U(), V)
    _add_lower_bound_range_constraints_impl!(container, T, array, devices, model)
    _add_upper_bound_range_constraints_impl!(container, T, array, devices, model)
    return
end

function add_range_constraints!(
    container::OptimizationContainer,
    ::Type{T},
    ::Type{U},
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
    ::Type{X},
) where {
    T <: ConstraintType,
    U <: RangeConstraintLBExpressions,
    V <: PSY.Component,
    W <: AbstractDeviceFormulation,
    X <: PM.AbstractPowerModel,
}
    array = get_expression(container, U(), V)
    _add_lower_bound_range_constraints_impl!(container, T, array, devices, model)
    return
end

function add_range_constraints!(
    container::OptimizationContainer,
    ::Type{T},
    ::Type{U},
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
    ::Type{X},
) where {
    T <: ConstraintType,
    U <: RangeConstraintUBExpressions,
    V <: PSY.Component,
    W <: AbstractDeviceFormulation,
    X <: PM.AbstractPowerModel,
}
    array = get_expression(container, U(), V)
    _add_upper_bound_range_constraints_impl!(container, T, array, devices, model)
    return
end

function add_range_constraints!(
    container::OptimizationContainer,
    ::Type{T},
    ::Type{U},
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
    ::Type{X},
) where {
    T <: ComponentActivePowerVariableLimitsConstraint,
    U <: VariableType,
    V <: PSY.Component,
    W <: AbstractDeviceFormulation,
    X <: PM.AbstractPowerModel,
}
    array = get_variable(container, U(), V)
    _add_lower_bound_range_constraints_impl!(container, T, array, devices, model)
    _add_upper_bound_range_constraints_impl!(container, T, array, devices, model)
    _add_parameterized_upper_bound_range_constraints_impl!(
        container,
        T,
        array,
        ActivePowerTimeSeriesParameter,
        devices,
        model,
    )
    return
end

function _add_lower_bound_range_constraints_impl!(
    container::OptimizationContainer,
    ::Type{T},
    array,
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
) where {T <: ConstraintType, V <: PSY.Component, W <: AbstractDeviceFormulation}
    time_steps = get_time_steps(container)
    device_names = [PSY.get_name(d) for d in devices]

    con_lb =
        add_constraints_container!(container, T(), V, device_names, time_steps, meta="lb")

    for device in devices, t in time_steps
        ci_name = PSY.get_name(device)
        limits = get_min_max_limits(device, T, W) # depends on constraint type and formulation type
        con_lb[ci_name, t] =
            JuMP.@constraint(container.JuMPmodel, array[ci_name, t] >= limits.min)
    end
    return
end

function _add_upper_bound_range_constraints_impl!(
    container::OptimizationContainer,
    ::Type{T},
    array,
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
) where {T <: ConstraintType, V <: PSY.Component, W <: AbstractDeviceFormulation}
    time_steps = get_time_steps(container)
    device_names = [PSY.get_name(d) for d in devices]

    con_ub =
        add_constraints_container!(container, T(), V, device_names, time_steps, meta="ub")

    for device in devices, t in time_steps
        ci_name = PSY.get_name(device)
        limits = get_min_max_limits(device, T, W) # depends on constraint type and formulation type
        con_ub[ci_name, t] =
            JuMP.@constraint(container.JuMPmodel, array[ci_name, t] <= limits.max)
    end
    return
end

@doc raw"""
Constructs min/max range constraint from device variable and on/off decision variable.

If device min = 0:

``` varcts[name, t] <= limits.max*varbin[name, t]) ```

``` varcts[name, t] >= 0.0 ```

Otherwise:

``` varcts[name, t] <= limits.max*varbin[name, t] ```

``` varcts[name, t] >= limits.min*varbin[name, t] ```

where limits in constraint_infos.

# LaTeX

`` 0 \leq x^{cts} \leq limits^{max} x^{bin}, \text{ for } limits^{min} = 0 ``

`` limits^{min} x^{bin} \leq x^{cts} \leq limits^{max} x^{bin}, \text{ otherwise } ``
"""
function add_semicontinuous_range_constraints!(
    container::OptimizationContainer,
    ::Type{T},
    ::Type{U},
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
    ::Type{X},
) where {
    T <: ConstraintType,
    U <: VariableType,
    V <: PSY.Component,
    W <: AbstractDeviceFormulation,
    X <: PM.AbstractPowerModel,
}
    array = get_variable(container, U(), V)
    _add_semicontinuous_lower_bound_range_constraints_impl!(
        container,
        T,
        array,
        devices,
        model,
    )
    _add_semicontinuous_upper_bound_range_constraints_impl!(
        container,
        T,
        array,
        devices,
        model,
    )
    return
end

function add_semicontinuous_range_constraints!(
    container::OptimizationContainer,
    ::Type{T},
    ::Type{U},
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
    ::Type{X},
) where {
    T <: ConstraintType,
    U <: RangeConstraintLBExpressions,
    V <: PSY.Component,
    W <: AbstractDeviceFormulation,
    X <: PM.AbstractPowerModel,
}
    array = get_expression(container, U(), V)
    _add_semicontinuous_lower_bound_range_constraints_impl!(
        container,
        T,
        array,
        devices,
        model,
    )
    return
end

function add_semicontinuous_range_constraints!(
    container::OptimizationContainer,
    ::Type{T},
    ::Type{U},
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
    ::Type{X},
) where {
    T <: ConstraintType,
    U <: RangeConstraintUBExpressions,
    V <: PSY.Component,
    W <: AbstractDeviceFormulation,
    X <: PM.AbstractPowerModel,
}
    array = get_expression(container, U(), V)
    _add_semicontinuous_upper_bound_range_constraints_impl!(
        container,
        T,
        array,
        devices,
        model,
    )
    return
end

function _add_semicontinuous_lower_bound_range_constraints_impl!(
    container::OptimizationContainer,
    ::Type{T},
    array,
    devices::IS.FlattenIteratorWrapper{V},
    ::DeviceModel{V, W},
) where {T <: ConstraintType, V <: PSY.Component, W <: AbstractDeviceFormulation}
    time_steps = get_time_steps(container)
    names = [PSY.get_name(d) for d in devices]
    binary_variables = [OnVariable()]

    con_lb = add_constraints_container!(container, T(), V, names, time_steps, meta="lb")

    @assert length(binary_variables) == 1 "Expected $(binary_variables) for $U $V $T $W to be length 1"
    varbin = get_variable(container, only(binary_variables), V)

    for device in devices, t in time_steps
        ci_name = PSY.get_name(device)
        limits = get_min_max_limits(device, T, W) # depends on constraint type and formulation type
        con_lb[ci_name, t] = JuMP.@constraint(
            container.JuMPmodel,
            array[ci_name, t] >= limits.min * varbin[ci_name, t]
        )
    end
    return
end

function _add_semicontinuous_upper_bound_range_constraints_impl!(
    container::OptimizationContainer,
    ::Type{T},
    array,
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
) where {T <: ConstraintType, V <: PSY.Component, W <: AbstractDeviceFormulation}
    time_steps = get_time_steps(container)
    names = [PSY.get_name(d) for d in devices]
    binary_variables = [OnVariable()]

    con_ub = add_constraints_container!(container, T(), V, names, time_steps, meta="ub")

    @assert length(binary_variables) == 1 "Expected $(binary_variables) for $U $V $T $W to be length 1"
    varbin = get_variable(container, only(binary_variables), V)

    for device in devices, t in time_steps
        ci_name = PSY.get_name(device)
        limits = get_min_max_limits(device, T, W) # depends on constraint type and formulation type
        con_ub[ci_name, t] = JuMP.@constraint(
            container.JuMPmodel,
            array[ci_name, t] <= limits.max * varbin[ci_name, t]
        )
    end
    return
end

@doc raw"""
Constructs min/max range constraint from device variable and reservation decision variable.

``` varcts[name, t] <= limits.max * (1 - varbin[name, t]) ```

``` varcts[name, t] >= limits.min * (1 - varbin[name, t]) ```

where limits in constraint_infos.

# LaTeX

`` 0 \leq x^{cts} \leq limits^{max} (1 - x^{bin}), \text{ for } limits^{min} = 0 ``

`` limits^{min} (1 - x^{bin}) \leq x^{cts} \leq limits^{max} (1 - x^{bin}), \text{ otherwise } ``
"""
function add_reserve_range_constraints!(
    container::OptimizationContainer,
    ::Type{T},
    ::Type{U},
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
    ::Type{X},
) where {
    T <: InputActivePowerVariableLimitsConstraint,
    U <: VariableType,
    V <: PSY.Component,
    W <: AbstractDeviceFormulation,
    X <: PM.AbstractPowerModel,
}
    array = get_variable(container, U(), V)
    _add_reserve_upper_bound_range_constraints_impl!(container, T, array, devices, model)
    _add_reserve_lower_bound_range_constraints_impl!(container, T, array, devices, model)
    return
end

function add_reserve_range_constraints!(
    container::OptimizationContainer,
    ::Type{T},
    ::Type{U},
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
    ::Type{X},
) where {
    T <: InputActivePowerVariableLimitsConstraint,
    U <: ExpressionType,
    V <: PSY.Component,
    W <: AbstractDeviceFormulation,
    X <: PM.AbstractPowerModel,
}
    array = get_expression(container, U(), W)
    _add_reserve_upper_bound_range_constraints_impl!(container, T, array, devices, model)
    _add_reserve_lower_bound_range_constraints_impl!(container, T, array, devices, model)
    return
end

function _add_reserve_lower_bound_range_constraints_impl!(
    container::OptimizationContainer,
    ::Type{T},
    array,
    devices::IS.FlattenIteratorWrapper{V},
    ::DeviceModel{V, W},
) where {
    T <: InputActivePowerVariableLimitsConstraint,
    V <: PSY.Component,
    W <: AbstractDeviceFormulation,
}
    time_steps = get_time_steps(container)
    names = [PSY.get_name(d) for d in devices]
    binary_variables = [ReservationVariable()]

    IS.@assert_op length(binary_variables) == 1
    varbin = get_variable(container, only(binary_variables), V)

    names = [PSY.get_name(x) for x in devices]
    # MOI has a semicontinous set, but after some tests is not clear most MILP solvers support it.
    # In the future this can be updated
    con_lb = add_constraints_container!(container, T(), V, names, time_steps, meta="lb")

    for device in devices, t in time_steps
        ci_name = PSY.get_name(device)
        limits = get_min_max_limits(device, T, W)
        con_lb[ci_name, t] = JuMP.@constraint(
            container.JuMPmodel,
            array[ci_name, t] >= limits.min * (1 - varbin[ci_name, t])
        )
    end
    return
end

function _add_reserve_upper_bound_range_constraints_impl!(
    container::OptimizationContainer,
    ::Type{T},
    array,
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
) where {
    T <: InputActivePowerVariableLimitsConstraint,
    V <: PSY.Component,
    W <: AbstractDeviceFormulation,
}
    time_steps = get_time_steps(container)
    names = [PSY.get_name(d) for d in devices]
    binary_variables = [ReservationVariable()]

    IS.@assert_op length(binary_variables) == 1
    varbin = get_variable(container, only(binary_variables), V)

    names = [PSY.get_name(x) for x in devices]
    # MOI has a semicontinous set, but after some tests is not clear most MILP solvers support it.
    # In the future this can be updated
    con_ub = add_constraints_container!(container, T(), V, names, time_steps, meta="ub")

    for device in devices, t in time_steps
        ci_name = PSY.get_name(device)
        limits = get_min_max_limits(device, T, W)
        con_ub[ci_name, t] = JuMP.@constraint(
            container.JuMPmodel,
            array[ci_name, t] <= limits.max * (1 - varbin[ci_name, t])
        )
    end
    return
end

@doc raw"""
Constructs min/max range constraint from device variable and reservation decision variable.

``` varcts[name, t] <= limits.max * varbin[name, t] ```

``` varcts[name, t] >= limits.min * varbin[name, t] ```

where limits in constraint_infos.

# LaTeX

`` limits^{min} x^{bin} \leq x^{cts} \leq limits^{max} x^{bin},``
"""
function add_reserve_range_constraints!(
    container::OptimizationContainer,
    ::Type{T},
    ::Type{U},
    devices::IS.FlattenIteratorWrapper{W},
    model::DeviceModel{W, X},
    ::Type{Y},
) where {
    T <:
    Union{ReactivePowerVariableLimitsConstraint, OutputActivePowerVariableLimitsConstraint},
    U <: VariableType,
    W <: PSY.Component,
    X <: AbstractDeviceFormulation,
    Y <: PM.AbstractPowerModel,
}
    array = get_variable(container, U(), W)
    _add_reserve_upper_bound_range_constraints_impl!(container, T, array, devices, model)
    _add_reserve_lower_bound_range_constraints_impl!(container, T, array, devices, model)
    return
end

@doc raw"""
Constructs min/max range constraint from device variable and reservation decision variable.

``` varcts[name, t] <= limits.max * varbin[name, t] ```

``` varcts[name, t] >= limits.min * varbin[name, t] ```

where limits in constraint_infos.

# LaTeX

`` limits^{min} x^{bin} \leq x^{cts} \leq limits^{max} x^{bin},``
"""
function add_reserve_range_constraints!(
    container::OptimizationContainer,
    ::Type{T},
    ::Type{U},
    devices::IS.FlattenIteratorWrapper{W},
    model::DeviceModel{W, X},
    ::Type{Y},
) where {
    T <:
    Union{ReactivePowerVariableLimitsConstraint, OutputActivePowerVariableLimitsConstraint},
    U <: ExpressionType,
    W <: PSY.Component,
    X <: AbstractDeviceFormulation,
    Y <: PM.AbstractPowerModel,
}
    array = get_expression(container, U(), W)
    _add_reserve_upper_bound_range_constraints_impl!(container, T, array, devices, model)
    _add_reserve_lower_bound_range_constraints_impl!(container, T, array, devices, model)
    return
end

function _add_reserve_lower_bound_range_constraints_impl!(
    container::OptimizationContainer,
    ::Type{T},
    array,
    devices::IS.FlattenIteratorWrapper{W},
    ::DeviceModel{W, X},
) where {
    T <:
    Union{ReactivePowerVariableLimitsConstraint, OutputActivePowerVariableLimitsConstraint},
    W <: PSY.Component,
    X <: AbstractDeviceFormulation,
}
    time_steps = get_time_steps(container)
    names = [PSY.get_name(d) for d in devices]
    binary_variables = [ReservationVariable()]

    con_lb = add_constraints_container!(container, T(), W, names, time_steps, meta="lb")

    @assert length(binary_variables) == 1 "Expected $(binary_variables) for $U $V $T $W to be length 1"
    varbin = get_variable(container, only(binary_variables), W)

    for device in devices, t in time_steps
        ci_name = PSY.get_name(device)
        limits = get_min_max_limits(device, T, X) # depends on constraint type and formulation type
        con_lb[ci_name, t] = JuMP.@constraint(
            container.JuMPmodel,
            array[ci_name, t] >= limits.min * varbin[ci_name, t]
        )
    end
    return
end

function _add_reserve_upper_bound_range_constraints_impl!(
    container::OptimizationContainer,
    ::Type{T},
    array,
    devices::IS.FlattenIteratorWrapper{W},
    ::DeviceModel{W, X},
) where {
    T <:
    Union{ReactivePowerVariableLimitsConstraint, OutputActivePowerVariableLimitsConstraint},
    W <: PSY.Component,
    X <: AbstractDeviceFormulation,
}
    time_steps = get_time_steps(container)
    names = [PSY.get_name(d) for d in devices]
    binary_variables = [ReservationVariable()]

    con_ub = add_constraints_container!(container, T(), W, names, time_steps, meta="ub")

    @assert length(binary_variables) == 1 "Expected $(binary_variables) for $U $V $T $W to be length 1"
    varbin = get_variable(container, only(binary_variables), W)

    for device in devices, t in time_steps
        ci_name = PSY.get_name(device)
        limits = get_min_max_limits(device, T, X) # depends on constraint type and formulation type
        con_ub[ci_name, t] = JuMP.@constraint(
            container.JuMPmodel,
            array[ci_name, t] <= limits.max * varbin[ci_name, t]
        )
    end
    return
end

function add_parameterized_lower_bound_range_constraints(
    container::OptimizationContainer,
    ::Type{T},
    ::Type{U},
    ::Type{P},
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
    ::Type{X},
) where {
    T <: ConstraintType,
    U <: ExpressionType,
    P <: ParameterType,
    V <: PSY.Component,
    W <: AbstractDeviceFormulation,
    X <: PM.AbstractPowerModel,
}
    array = get_expression(container, U(), V)
    _add_parameterized_lower_bound_range_constraints_impl!(
        container,
        T,
        array,
        P,
        devices,
        model,
    )
    return
end

function add_parameterized_lower_bound_range_constraints(
    container::OptimizationContainer,
    ::Type{T},
    ::Type{U},
    ::Type{P},
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
    ::Type{X},
) where {
    T <: ConstraintType,
    U <: VariableType,
    P <: ParameterType,
    V <: PSY.Component,
    W <: AbstractDeviceFormulation,
    X <: PM.AbstractPowerModel,
}
    array = get_variable(container, U(), V)
    _add_parameterized_lower_bound_range_constraints_impl!(
        container,
        T,
        array,
        P,
        devices,
        model,
    )
    return
end

# This function is re-used in SemiContinuousFeedforward
function lower_bound_range_with_parameter!(
    jump_model::JuMP.Model,
    constraint_container::JuMPConstraintArray,
    lhs_array,
    param_multiplier::JuMPFloatArray,
    param_array::Union{JuMPParamArray, JuMPFloatArray},
    devices::IS.FlattenIteratorWrapper{V},
) where {V <: PSY.Component}
    time_steps = axes(constraint_container)[2]
    for device in devices, t in time_steps
        name = PSY.get_name(device)
        constraint_container[name, t] = JuMP.@constraint(
            jump_model,
            lhs_array[name, t] >= param_multiplier[name, t] * param_array[name, t]
        )
    end
    return
end

function _add_parameterized_lower_bound_range_constraints_impl!(
    container::OptimizationContainer,
    ::Type{T},
    array,
    ::Type{U},
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
) where {
    T <: ConstraintType,
    U <: ParameterType,
    V <: PSY.Component,
    W <: AbstractDeviceFormulation,
}
    time_steps = get_time_steps(container)
    names = [PSY.get_name(d) for d in devices]

    constraint = add_constraints_container!(container, T(), V, names, time_steps, meta="lb")

    parameter = get_parameter_array(container, U(), V)
    multiplier = get_parameter_multiplier_array(container, U(), V)
    jump_model = get_jump_model(container)
    lower_bound_range_with_parameter!(
        jump_model,
        constraint,
        array,
        multiplier,
        parameter,
        devices,
    )
    return
end

function add_parameterized_upper_bound_range_constraints(
    container::OptimizationContainer,
    ::Type{T},
    ::Type{U},
    ::Type{P},
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
    ::Type{X},
) where {
    T <: ConstraintType,
    U <: ExpressionType,
    P <: ParameterType,
    V <: PSY.Component,
    W <: AbstractDeviceFormulation,
    X <: PM.AbstractPowerModel,
}
    array = get_expression(container, U(), V)
    _add_parameterized_upper_bound_range_constraints_impl!(
        container,
        T,
        array,
        P,
        devices,
        model,
    )
    return
end

function add_parameterized_upper_bound_range_constraints(
    container::OptimizationContainer,
    ::Type{T},
    ::Type{U},
    ::Type{P},
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
    ::Type{X},
) where {
    T <: ConstraintType,
    U <: VariableType,
    P <: ParameterType,
    V <: PSY.Component,
    W <: AbstractDeviceFormulation,
    X <: PM.AbstractPowerModel,
}
    array = get_variable(container, U(), V)
    _add_parameterized_upper_bound_range_constraints_impl!(
        container,
        T,
        array,
        P,
        devices,
        model,
    )
    return
end

# This function is re-used in SemiContinuousFeedforward
function upper_bound_range_with_parameter!(
    jump_model::JuMP.Model,
    constraint_container::JuMPConstraintArray,
    lhs_array,
    param_multiplier::JuMPFloatArray,
    param_array::Union{JuMPParamArray, JuMPFloatArray},
    devices::IS.FlattenIteratorWrapper{V},
) where {V <: PSY.Component}
    time_steps = axes(constraint_container)[2]
    for device in devices, t in time_steps
        name = PSY.get_name(device)
        constraint_container[name, t] = JuMP.@constraint(
            jump_model,
            lhs_array[name, t] <= param_multiplier[name, t] * param_array[name, t]
        )
    end
    return
end

function _add_parameterized_upper_bound_range_constraints_impl!(
    container::OptimizationContainer,
    ::Type{T},
    array,
    ::Type{P},
    devices::IS.FlattenIteratorWrapper{V},
    model::DeviceModel{V, W},
) where {
    T <: ConstraintType,
    P <: ParameterType,
    V <: PSY.Component,
    W <: AbstractDeviceFormulation,
}
    time_steps = get_time_steps(container)
    names = [PSY.get_name(d) for d in devices]

    constraint = add_constraints_container!(container, T(), V, names, time_steps, meta="ub")

    parameter = get_parameter_array(container, P(), V)
    multiplier = get_parameter_multiplier_array(container, P(), V)
    jump_model = get_jump_model(container)
    upper_bound_range_with_parameter!(
        jump_model,
        constraint,
        array,
        multiplier,
        parameter,
        devices,
    )
    return
end