Problem. The capacity constraints of a node are selected by the node type only. Since 0.9.5 an extension package can attach physics to a resource instead of a node type and add constraints to every node carrying it through constraints_resource. It cannot, however, replace one of the standard constraints that way. Example from a power-flow package under development: its resources carry reactive power, and for a load the installed capacity is the apparent power the connection is rated for, while cap_use is the active power that drives the variable OPEX. The sink balance therefore has to read cap_use + sink_deficit == cap_inst · pf + sink_surplus with the power factor pf of the load, a node-specific parameter that the package provides as ExtensionData on the sink. The only way to get there today is a copy of RefSink with one additional field, and the same copying is needed for every other reference node whose capacity equation the resource changes. This defeats the purpose of the resource hooks: a RefSink from EMB, or a node from another extension package, cannot be used as a load in such a model.
Proposal. Split constraints_capacity into an entry point and resource-aware methods, selected by a boolean trait on the resource and an optional override on the node:
# structures/resource.jl
is_capacity_resource(p::Resource) = false # "p brings its own capacity constraints"
# structures/data.jl
struct CapacityResource{T} <: ExtensionData # explicit choice of the dispatch object
resource::T # untyped: a Resource or a composite type
end
# structures/node.jl
function capacity_resource(n::Node)
𝒟 = filter(d -> isa(d, CapacityResource), node_data(n))
isempty(𝒟) || return first(𝒟).resource
𝒫 = unique(vcat(inputs(n), outputs(n)))
𝒫ᶜᵃᵖ = filter(is_capacity_resource, 𝒫)
return isempty(𝒫ᶜᵃᵖ) ? first(𝒫) : first(𝒫ᶜᵃᵖ)
end
# constraint_functions.jl
constraints_capacity(m, n::Node, 𝒯, modeltype) =
constraints_capacity(m, n, 𝒯, capacity_resource(n), modeltype)
constraints_capacity(m, n::Node, 𝒯, p::Resource, modeltype) = <current Node body>
constraints_capacity(m, n::Storage, 𝒯, p::Resource, modeltype) = <current Storage body>
constraints_capacity(m, n::Sink, 𝒯, p::Resource, modeltype) = <current Sink body>
# checks.jl, called from check_elements for nodes with has_capacity(n)
check_capacity_resource(n::Node) # ≤ 1 CapacityResource; flagged resources of ≤ 1 family
An extension package declares is_capacity_resource(::ElectricPower) = true and writes, for example, constraints_capacity(m, n::Sink, 𝒯, p::ElectricPower, modeltype) with the balance above, reading the power factor from the data of the node. The resource instance is passed, not its type, because the instance carries the resource-level parameters (voltage limits, the linearisation of the capacity limits); node-specific parameters such as the power factor of a load stay in the ExtensionData of the node.
Why a boolean and not a rank. A node usually carries several resources, so the entry point needs a rule for which one to pass on. The first design that comes to mind is a priority: an integer resource_rank(p) with default 0, raised by extension packages for their resources, and argmax over the resources of the node. It is one line and it also lets a package declare precedence when a node carries advanced resources from two packages. That last property is the reason to reject it. A node whose capacity is governed by two different physics (a heat pump with an apparent-power limit on the electric side and a temperature-dependent limit on the heat side) has no automatic answer, and a rank would resolve it silently and arbitrarily, depending on which number each package happened to pick. The trait therefore only separates resources that bring their own capacity constraints from ordinary carriers, and never decides between two of them. With a boolean a collision between packages is impossible by construction, and the conflict becomes an explicit error in the consistency log with an explicit way out, described next.
Conflicts and combination. A node has one cap_inst and one cap_use in one unit, so exactly one resource owns the capacity equation of a node. Every other advanced resource adds its limits formulated on its own flow variables through constraints_resource, which already runs per resource family on every node; that combination needs no code that knows both packages. The conflict case is two resources of different families that both need to replace the equation. check_capacity_resource rejects such a node unless it carries a CapacityResource, and because the payload of CapacityResource is passed through untyped, a package extension that depends on both packages (the ext/ mechanism every EMX package already uses for EnergyModelsInvestments, known as a glue package before Julia 1.9) can combine the physics in one method on a composite type it owns:
# A heat package owns the capacity equation of a heat pump (RefNetworkNode, capacity in MW heat)
is_capacity_resource(::TemperatureHeat) = true
function constraints_capacity(m, n::NetworkNode, 𝒯, p::TemperatureHeat, modeltype)
@constraint(m, [t ∈ 𝒯], m[:cap_use][n, t] ≤ m[:cap_inst][n, t] * derating(p, t))
constraints_capacity_installed(m, n, 𝒯, modeltype)
end
# A power-flow package adds the electric side on the flows of its resource through the hook
function constraints_resource(m, n::NetworkNode, 𝒯, 𝒫::Vector{<:ElectricPower}, modeltype)
@constraint(m, [t ∈ 𝒯, p ∈ intersect(inputs(n), 𝒫)],
m[:flow_in][n, t, p] / power_factor(n, p) ≤ electric_rating(n, p, t) # both from node data
)
end
# If both packages flag their resource, the modeller resolves the conflict with a composite
# type owned by a package extension that is loaded when both packages are present
struct HeatPumpCapacity{H,P}; heat::H; power::P; end
function constraints_capacity(m, n::NetworkNode, 𝒯, c::HeatPumpCapacity, modeltype)
# thermal derating, electric rating and their coupling written once; installed called once
end
RefNetworkNode(..., [CapacityResource(HeatPumpCapacity(Heat, Power))])
Why non-breaking.
create_node keeps calling the four-argument method, so every existing four-argument method on a node type (EnergyModelsInvestments, EnergyModelsRenewableProducers, EnergyModelsHydrogen, EnergyModelsHeat) keeps precedence over the new entry point, because it is more specific in the node argument.
- Without flagged resources the dispatch object is the first input or output resource and the five-argument fallbacks are the current bodies, which ignore it, so the constraints are identical.
CapacityResource is a new data type; existing data is untouched. The investment extension dispatches on constraints_capacity_installed, which is untouched.
- A node with neither inputs nor outputs has no dispatch object; no such node type exists in the ecosystem, and
check_elements would report it before the model is built.
Tests (in the accompanying pull request). With two test resource types that both declare is_capacity_resource = true: a node carrying both is rejected by the checks with a message naming the two families; a node with two CapacityResource entries is rejected; a CapacityResource with a plain resource selects the standard fallback; a CapacityResource with a composite payload reaches a method defined for that composite type. With one test resource type: a five-argument method on Sink changes the sink balance for a RefSink carrying it, and the existing suite is unchanged for resources with the default trait.
Problem. The capacity constraints of a node are selected by the node type only. Since 0.9.5 an extension package can attach physics to a resource instead of a node type and add constraints to every node carrying it through
constraints_resource. It cannot, however, replace one of the standard constraints that way. Example from a power-flow package under development: its resources carry reactive power, and for a load the installed capacity is the apparent power the connection is rated for, whilecap_useis the active power that drives the variable OPEX. The sink balance therefore has to readcap_use + sink_deficit == cap_inst · pf + sink_surpluswith the power factorpfof the load, a node-specific parameter that the package provides asExtensionDataon the sink. The only way to get there today is a copy ofRefSinkwith one additional field, and the same copying is needed for every other reference node whose capacity equation the resource changes. This defeats the purpose of the resource hooks: aRefSinkfrom EMB, or a node from another extension package, cannot be used as a load in such a model.Proposal. Split
constraints_capacityinto an entry point and resource-aware methods, selected by a boolean trait on the resource and an optional override on the node:An extension package declares
is_capacity_resource(::ElectricPower) = trueand writes, for example,constraints_capacity(m, n::Sink, 𝒯, p::ElectricPower, modeltype)with the balance above, reading the power factor from the data of the node. The resource instance is passed, not its type, because the instance carries the resource-level parameters (voltage limits, the linearisation of the capacity limits); node-specific parameters such as the power factor of a load stay in theExtensionDataof the node.Why a boolean and not a rank. A node usually carries several resources, so the entry point needs a rule for which one to pass on. The first design that comes to mind is a priority: an integer
resource_rank(p)with default 0, raised by extension packages for their resources, andargmaxover the resources of the node. It is one line and it also lets a package declare precedence when a node carries advanced resources from two packages. That last property is the reason to reject it. A node whose capacity is governed by two different physics (a heat pump with an apparent-power limit on the electric side and a temperature-dependent limit on the heat side) has no automatic answer, and a rank would resolve it silently and arbitrarily, depending on which number each package happened to pick. The trait therefore only separates resources that bring their own capacity constraints from ordinary carriers, and never decides between two of them. With a boolean a collision between packages is impossible by construction, and the conflict becomes an explicit error in the consistency log with an explicit way out, described next.Conflicts and combination. A node has one
cap_instand onecap_usein one unit, so exactly one resource owns the capacity equation of a node. Every other advanced resource adds its limits formulated on its own flow variables throughconstraints_resource, which already runs per resource family on every node; that combination needs no code that knows both packages. The conflict case is two resources of different families that both need to replace the equation.check_capacity_resourcerejects such a node unless it carries aCapacityResource, and because the payload ofCapacityResourceis passed through untyped, a package extension that depends on both packages (theext/mechanism every EMX package already uses forEnergyModelsInvestments, known as a glue package before Julia 1.9) can combine the physics in one method on a composite type it owns:Why non-breaking.
create_nodekeeps calling the four-argument method, so every existing four-argument method on a node type (EnergyModelsInvestments, EnergyModelsRenewableProducers, EnergyModelsHydrogen, EnergyModelsHeat) keeps precedence over the new entry point, because it is more specific in the node argument.CapacityResourceis a new data type; existing data is untouched. The investment extension dispatches onconstraints_capacity_installed, which is untouched.check_elementswould report it before the model is built.Tests (in the accompanying pull request). With two test resource types that both declare
is_capacity_resource = true: a node carrying both is rejected by the checks with a message naming the two families; a node with twoCapacityResourceentries is rejected; aCapacityResourcewith a plain resource selects the standard fallback; aCapacityResourcewith a composite payload reaches a method defined for that composite type. With one test resource type: a five-argument method onSinkchanges the sink balance for aRefSinkcarrying it, and the existing suite is unchanged for resources with the default trait.