Closed brynpickering closed 9 months ago
Always sets for each tech
and tech_group
to allow you to easily attach per-tech custom constraints via those sets?
Here's a work-in-progress suggestion of how custom constraints could look like in the yaml file. The idea is that next to the user-friendly Calliope structure, one can manually add Sets
, Parameters
, Variables
, and Constraints
.
The constraints defined below are the group constraints in Calliope 0.6.5. It requires #322 to be solved first.
custom_constraints:
sets:
wind_techs:
elements:
- onshore_wind
- offshore_wind
within: techs
ac_transmission:
elements:
- ac_transmission
within: techs
nordic_countries:
elements:
- FIN
- CHE
- NOR
within: locs
tims_favorite_timesteps:
elements:
- "2016-04-01 04:00"
- "2016-06-16 08:00"
within: timesteps
monetary_cost:
elements:
- monetary
within: costs
parameters:
carrier_prod_share: 0.25
net_import_share: 0.7
energy_cap_share: 0.2
energy_cap: 200
cost_cap: 1e6
cost_var_cap: 1e4
cost_investment_cap: 1e3
resource_area_cap: 100
# TODO add variables
constraints:
net_import_share:
foreach: [nordic_locs, carriers]
eq: net_import_sum <= net_import_share * demand_sum
components:
net_import_sum:
sum: carrier_prod + carrier_con
over: [ac_transmission, timesteps]
demand_sum:
sum: carrier_con
over: [demand, timesteps]
carrier_prod_share:
eq: energy_sum_wind <= carrier_prod_share * total_energy_sum
foreach: [nordic_locs, carriers]
components:
energy_sum_wind:
sum: carrier_prod
over: [wind_techs, timesteps]
total_energy_sum:
sum: carrier_prod
over: [supply_techs, timesteps]
# TODO: allow for conditional statements
energy_cap_share:
eq: energy_cap_sum_wind <= energy_cap_share * energy_cap_sum_all
foreach: [locs, electricity]
components:
energy_cap_sum_wind:
sum: energy_cap
over: [wind_techs]
energy_cap_sum_all:
sum: energy_cap
over: [techs_supply]
energy_cap:
eq: energy_cap_sum_wind <= energy_cap
foreach: [locs, electricity]
components:
energy_cap_sum_wind:
sum: energy_cap
over: [wind_techs]
carrier_prod_share_per_timestep:
eq: energy_sum_wind <= carrier_prod_share * total_energy_sum
foreach: [nordic_locs, carriers, tims_favorite_timesteps]
components:
energy_sum_wind:
sum: carrier_prod
over: [wind_techs]
total_energy_sum:
sum: carrier_prod
over: [supply_techs]
demand_share:
foreach: [locs, carriers]
eq: net_import_sum <= demand_share * -1 * demand_sum
components:
net_import_sum:
sum: carrier_prod
over: [wind_techs, timesteps]
demand_sum:
sum: carrier_con
over: [demand, timesteps]
resource_area:
foreach: []
eq: resource_area_sum <= resource_area_cap
components:
resource_area_sum:
sum: resource_area
over: [wind_techs, nordic_countries]
cost_cap:
foreach: [monetary_cost]
eq: cost_sum <= cost_cap
components:
cost_sum:
sum: cost
over: [locs, techs, timesteps]
cost_var_cap:
foreach: [monetary_cost]
eq: cost_sum <= cost_cap
components:
cost_sum:
sum: cost_var
over: [locs, techs, timesteps]
cost_investment_cap:
foreach: [monetary_cost]
eq: cost_sum <= cost_cap
components:
cost_sum:
sum: cost_investment
over: [locs, techs]
#TODO needs a variable
# c1: carrier_prod[hp, t] / resource[demand_tech, t] == decision_var[hp]
# c2: decision_var[hp] + decision_var[boiler] = 0.8
Some thoughts on this. We can leverage the update to how constraint sets are built in #346 to make it easier to define constraints in YAML. In config/subsets.yaml
, each named constraint has the dimensions over which the constraint is built and a where
"mask" to tell it whether to consider an indexed component or not. E.g.:
carrier_consumption_max:
foreach: [nodes, techs, carriers, timesteps]
where: [carrier, and, [inheritance(transmission), or, inheritance(demand), or, inheritance(storage)], and, [not cap_method='integer', or, inheritance(demand)], and, allowed_carrier_con=True]
subset:
carrier_tiers: [in]
This means that the internal constraints should never need to check if the indexed component is valid, since it is pre-filtered when generating the constraint set.
For the above, the YAML constraint should then be able to look like this, without any additional 'components':
carrier_consumption_max:
eq: carrier_con >= -1 * energy_cap * timestep_resolution
For the above, the YAML constraint should then be able to look like this, without any additional 'components':
carrier_consumption_max: eq: carrier_con >= -1 * energy_cap * timestep_resolution
On another note, I do wonder if this would be better looking like this:
carrier_consumption_max:
eq: carrier_con[carrier, node, tech, timestep] >= -1 * energy_cap[node, tech] * timestep_resolution[timestep]
I.e., is the mapping of dimension names to the relevant variables/parameters something we should infer automatically (the first option) or should they be explicitly given?
Another thing we've now implemented, that makes this particular addition easier, is the definition of variables. This means that the commented section at the end of @timtroendle's work-in-progress suggestion can be dealt with there.
Still, there are some issues I can see coming up:
component
, third we check that sub-components of constraint components are internally defined parameters/variables.carrier_con[carrier, node, tech, timestep]
), how do we enable a constraint to be defined that fixes a specific dimension (e.g., we build over all technologies at a specific location)? Perhaps this is a reason to be explicit with dimension referencing, or something to account for in per-constraint components
. I.e., as well as sum
there could be a way to select a specific dimension value.Here are some examples of possible implementations that handle complex core calliope constraints. The idea is that if we can handle these constraints, there is high chance that we can handle most constraints a user might define.
It turns out that (2) above is particularly difficult. Splitting into individual constraints creates a massive amount of repetition of constraint components and doesn't seem tenable. Below are two possible options that could work: using an 'if-else' syntax within the constraint definition and using the 'where' syntax that we introduced in constraint subsetting
In the 'balance supply' constraint, storage is there and so is the need for a link between timesteps. One option is to introduce two helper functions (to add to 'inheritance' in subsetting): get_index
and get_item
. get_index would get the index number of an item in a list. get_item would get the item from a list based on the index position.
New thinking summarised:
The approach for processing constraints in three steps:
Human-readable constraint definitions are parsed into a list of equations, where each list entry is a dict with keys "foreach", "where", "equation"
Iterate over the list of equations from (1), safely parsing each equation string from using pyparsing
The final step is backend-specific - here the actual model objects are built:
The top-level YAML key is "constraints", which is a list of dicts.
Allowed keys:
foreach
where
(optional)equation
or equations
components
(optional)index_items
(optional)The equation can be given as as string (equation
) or a list of expression dicts ({"where": ..., "expression": ...}
).
The keys equations
, components
, index_items
only accept a list of expression dicts ({"where": ..., "expression": ...}
).
For index_items
, an additional key is required: on_dimension
. This specifies which dimension name in the main expression(s) the index item applies to.
where
is always optional: if not given, it is assumed to be an empty list. Thus, if an expression applies without further subsetting, it can either be written as:
- where: []
expression: ...
or
- expression: ...
This is available and documented in v0.7
In 0.6.0 we have (temporarily?) removed the ability to introduce custom constraints via the YAML format1.
To bring it back in, ideally a user could have one Python script which introduces any new sets, parameters, decision variables, and constraints. If we provide a template for this, it would ensure that a user includes all the necessary information to produce the desired results.
Steps to introduce the functionality
1 A user familiar with Pyomo can currently add a custom constraint after building the backend model (
model.run(build_only=True)
) by accessing the Pyomo ConcreteModel directly viamodel._backend_model
.