diff --git a/RELEASE_NOTES.rst b/RELEASE_NOTES.rst index 932621b7d..6c98452b1 100644 --- a/RELEASE_NOTES.rst +++ b/RELEASE_NOTES.rst @@ -22,6 +22,10 @@ Adjust any imports like the following: All changes ----------- +- Add subannual generic relation constraints to MESSAGE (:pull:`NNNN`). + New parameters ``relation_upper_time``, ``relation_lower_time``, ``relation_activity_time`` + bound ``REL_TIME(r, n, y, h)`` per time slice, complementing the annual ``relation_*`` parameters. + - :mod:`message_ix` is tested and compatible with `Python 3.14 `__ (:pull:`985`). - Support for Python 3.9 is dropped (:pull:`985`), as it has reached end-of-life. - :mod:`message_ix` is tested and compatible with `Pandas 3.0.0 `_, diff --git a/message_ix/message.py b/message_ix/message.py index 135e7ff05..02ed68dc1 100644 --- a/message_ix/message.py +++ b/message_ix/message.py @@ -86,7 +86,16 @@ def enforce(scenario: "ixmp.Scenario") -> None: # handled in JDBCBackend. For the moment, this code does not backstop that # behaviour. # TODO Extend to handle all masks, e.g. for new backends. - for par_name in ("capacity_factor",): + # + # The JDBC backend composes ``is_relation_*`` for the annual parameters but + # not the subannual ``_time`` variants, so their flag sets are composed here. + # Composition is key-based, so a zero-valued bound (``relation_upper_time = 0``, + # i.e. "REL_TIME <= 0") is preserved. + for par_name in ( + "capacity_factor", + "relation_lower_time", + "relation_upper_time", + ): # Name of the corresponding set set_name = f"is_{par_name}" @@ -296,6 +305,8 @@ def run(self, scenario: "ixmp.Scenario") -> None: _set("cat_tec", "type_tec t") _set("cat_year", "type_year y") _set("is_capacity_factor", "nl t yv ya h") +_set("is_relation_lower_time", "r nr yr h") +_set("is_relation_upper_time", "r nr yr h") _set("level_renewable", "l") _set("level_resource", "l") _set("level_stocks", "l") @@ -397,11 +408,14 @@ def run(self, scenario: "ixmp.Scenario") -> None: par("ref_new_capacity", "nl t yv") par("ref_relation", "r nr yr") par("relation_activity", "r nr yr nl t ya m") +par("relation_activity_time", "r nr yr nl t ya m h") par("relation_cost", "r nr yr") par("relation_lower", "r nr yr") +par("relation_lower_time", "r nr yr h") par("relation_new_capacity", "r nr yr t") par("relation_total_capacity", "r nr yr t") par("relation_upper", "r nr yr") +par("relation_upper_time", "r nr yr h") par("reliability_factor", "n t ya c l h q") par("renewable_capacity_factor", "n c g l y") par("renewable_potential", "n c g l y") @@ -497,6 +511,11 @@ def run(self, scenario: "ixmp.Scenario") -> None: "r nr yr", "Auxiliary variable for left-hand side of user-defined relations", ) +var( + "REL_TIME", + "r nr yr h", + "Auxiliary variable for left-hand side of user-defined subannual relations", +) var( "REN", "n t c g y h", diff --git a/message_ix/model/MESSAGE/data_load.gms b/message_ix/model/MESSAGE/data_load.gms index a3316b8ea..5bfd0b56e 100644 --- a/message_ix/model/MESSAGE/data_load.gms +++ b/message_ix/model/MESSAGE/data_load.gms @@ -13,6 +13,7 @@ $LOAD level_resource, level_renewable $LOAD lvl_spatial, lvl_temporal, map_spatial_hierarchy, map_temporal_hierarchy $LOAD map_node, map_time, map_commodity, map_resource, map_stocks, map_tec, map_tec_time, map_tec_mode $LOAD is_capacity_factor +$LOAD is_relation_upper_time, is_relation_lower_time $LOAD map_land, map_relation $LOAD type_tec, cat_tec, type_year, cat_year, type_emission, cat_emission, type_tec_land $LOAD inv_tec, renewable_tec @@ -125,11 +126,14 @@ Execute_load '%in%', peak_load_factor, rating_bin, relation_activity, + relation_activity_time, relation_cost, relation_lower, + relation_lower_time, relation_new_capacity, relation_total_capacity, relation_upper, + relation_upper_time, reliability_factor, renewable_capacity_factor, renewable_potential, diff --git a/message_ix/model/MESSAGE/model_core.gms b/message_ix/model/MESSAGE/model_core.gms index 18c263955..4a6ef2c1f 100644 --- a/message_ix/model/MESSAGE/model_core.gms +++ b/message_ix/model/MESSAGE/model_core.gms @@ -130,6 +130,8 @@ Variables EMISS(node,emission,type_tec,year_all) aggregate emissions by technology type and land-use model emulator * auxiliary variable for left-hand side of relations (linear constraints) REL(relation,node,year_all) auxiliary variable for left-hand side of user-defined relations +* auxiliary variable for left-hand side of subannual relations (linear constraints at time-slice resolution) + REL_TIME(relation,node,year_all,time) auxiliary variable for left-hand side of user-defined subannual relations * change in the content of storage device STORAGE_CHARGE(node,tec,mode,level,commodity,year_all,time) charging of storage in each time slice (negative for discharge) ; @@ -307,6 +309,9 @@ Equations RELATION_EQUIVALENCE auxiliary equation to simplify the implementation of relations RELATION_CONSTRAINT_UP upper bound of relations (linear constraints) RELATION_CONSTRAINT_LO lower bound of relations (linear constraints) + RELATION_EQUIVALENCE_TIME auxiliary equation to simplify the implementation of subannual relations + RELATION_CONSTRAINT_UP_TIME upper bound of subannual relations (linear constraints at time-slice resolution) + RELATION_CONSTRAINT_LO_TIME lower bound of subannual relations (linear constraints at time-slice resolution) STORAGE_CHANGE change in the state of charge of storage STORAGE_BALANCE balance of the state of charge of storage STORAGE_BALANCE_INIT balance of the state of charge of storage at sub-annual time slices with initial storage content @@ -2331,6 +2336,67 @@ RELATION_CONSTRAINT_LO(relation,node,year)$( is_relation_lower(relation,node,yea %SLACK_RELATION_BOUND_LO% + SLACK_RELATION_BOUND_LO(relation,node,year) =G= relation_lower(relation,node,year) ; +*** +* Subannual generic relations +* ^^^^^^^^^^^^^^^^^^^^^^^^^^^ +* +* The subannual generic relations mirror :ref:`equation_relation_equivalence`, :ref:`equation_relation_constraint_up` and :ref:`equation_relation_constraint_lo` at subannual time-slice resolution. +* They share the annual-relation convention: +* generic linear constraints intended for development and testing, where specific features should use purpose-built equations. +* The subannual variants are activated when scenarios populate ``relation_activity_time``, ``relation_upper_time`` or ``relation_lower_time``; +* scenarios without these parameters are unaffected. +* +* .. _equation_relation_equivalence_time: +* +* Equation RELATION_EQUIVALENCE_TIME +* """""""""""""""""""""""""""""""""" +* .. math:: +* \text{REL\_TIME}_{r,n,y,h} = \sum_{t,n^L,y',m} \ \text{relation\_activity\_time}_{r,n,y,n^L,t,y',m,h} \\ +* \cdot \Big( \sum_{y^V \leq y'} \text{ACT}_{n^L,t,y^V,y',m,h} +* + \text{historical\_activity}_{n^L,t,y',m,h} \Big) +* +* ``REL_TIME`` is the subannual analogue of ``REL``. +* The subannual relation carries only the activity term: +* the capacity-side factors :math:`\text{relation\_new\_capacity}` and :math:`\text{relation\_total\_capacity}` stay annual, +* because capacity itself has no subannual dimension in |MESSAGEix|. +*** + +RELATION_EQUIVALENCE_TIME(relation,node,year,time).. + REL_TIME(relation,node,year,time) + =E= + SUM(tec, + SUM((location,year_all2,mode)$( map_tec_act(location,tec,year_all2,mode,time) ), + relation_activity_time(relation,node,year,location,tec,year_all2,mode,time) + * ( SUM(vintage$( map_tec_lifetime(location,tec,vintage,year_all2) ), + ACT(location,tec,vintage,year_all2,mode,time) ) + + historical_activity(location,tec,year_all2,mode,time) ) + ) + ) ; + +*** +* .. _equation_relation_constraint_up_time: +* +* Equation RELATION_CONSTRAINT_UP_TIME +* """""""""""""""""""""""""""""""""""" +* .. math:: +* \text{REL\_TIME}_{r,n,y,h} \leq \text{relation\_upper\_time}_{r,n,y,h} +*** +RELATION_CONSTRAINT_UP_TIME(relation,node,year,time)$( is_relation_upper_time(relation,node,year,time) ).. + REL_TIME(relation,node,year,time) + =L= relation_upper_time(relation,node,year,time) ; + +*** +* .. _equation_relation_constraint_lo_time: +* +* Equation RELATION_CONSTRAINT_LO_TIME +* """""""""""""""""""""""""""""""""""" +* .. math:: +* \text{REL\_TIME}_{r,n,y,h} \geq \text{relation\_lower\_time}_{r,n,y,h} +*** +RELATION_CONSTRAINT_LO_TIME(relation,node,year,time)$( is_relation_lower_time(relation,node,year,time) ).. + REL_TIME(relation,node,year,time) + =G= relation_lower_time(relation,node,year,time) ; + *----------------------------------------------------------------------------------------------------------------------* *** * .. _gams-storage: diff --git a/message_ix/model/MESSAGE/parameter_def.gms b/message_ix/model/MESSAGE/parameter_def.gms index a9681e94d..bee2f520f 100644 --- a/message_ix/model/MESSAGE/parameter_def.gms +++ b/message_ix/model/MESSAGE/parameter_def.gms @@ -867,6 +867,18 @@ Parameters * - ``relation`` | ``node_rel`` | ``year_rel`` | ``tec`` * * - relation_activity * - ``relation`` | ``node_rel`` | ``year_rel`` | ``node_loc`` | ``tec`` | ``year_act`` | ``mode`` +* * - relation_upper_time +* - ``relation`` | ``node_rel`` | ``year_rel`` | ``time`` +* * - relation_lower_time +* - ``relation`` | ``node_rel`` | ``year_rel`` | ``time`` +* * - relation_activity_time +* - ``relation`` | ``node_rel`` | ``year_rel`` | ``node_loc`` | ``tec`` | ``year_act`` | ``mode`` | ``time`` +* +* The ``_time`` variants extend the generic-relation mechanism to subannual time slices. +* They are independent of the annual variants: +* an annual ``relation_upper(r,n,y)`` constrains :math:`\text{REL}_{r,n,y}` summed over all time, +* while ``relation_upper_time(r,n,y,h)`` constrains the per-slice :math:`\text{REL\_TIME}_{r,n,y,h}`. +* Scenarios may use either or both. * *** @@ -877,6 +889,9 @@ Parameters relation_new_capacity(relation,node,year_all,tec) new capacity factor (multiplier) of generic relation relation_total_capacity(relation,node,year_all,tec) total capacity factor (multiplier) of generic relation relation_activity(relation,node,year_all,node,tec,year_all,mode) activity factor (multiplier) of generic relation + relation_upper_time(relation,node,year_all,time) upper bound of generic relation at subannual time slice + relation_lower_time(relation,node,year_all,time) lower bound of generic relation at subannual time slice + relation_activity_time(relation,node,year_all,node,tec,year_all,mode,time) activity factor (multiplier) of generic relation at subannual time slice ; *----------------------------------------------------------------------------------------------------------------------* diff --git a/message_ix/model/MESSAGE/sets_maps_def.gms b/message_ix/model/MESSAGE/sets_maps_def.gms index 2aad8147e..94a1dc94c 100644 --- a/message_ix/model/MESSAGE/sets_maps_def.gms +++ b/message_ix/model/MESSAGE/sets_maps_def.gms @@ -486,6 +486,8 @@ Sets is_relation_upper(relation,node,year_all) flag whether upper bounds exists for generic relation is_relation_lower(relation,node,year_all) flag whether lower bounds exists for generic relation + is_relation_upper_time(relation,node,year_all,time) flag whether upper bound exists for generic relation at subannual time slice + is_relation_lower_time(relation,node,year_all,time) flag whether lower bound exists for generic relation at subannual time slice ; *----------------------------------------------------------------------------------------------------------------------* diff --git a/message_ix/tests/data/reporter-keys-ixmp.txt b/message_ix/tests/data/reporter-keys-ixmp.txt index d78963dd2..e4f9f6067 100644 --- a/message_ix/tests/data/reporter-keys-ixmp.txt +++ b/message_ix/tests/data/reporter-keys-ixmp.txt @@ -9835,3 +9835,309 @@ var_cost:ya var_cost:yv-ya var_cost:yv y +REL_TIME: +REL_TIME:h +REL_TIME:nr +REL_TIME:nr-h +REL_TIME:nr-yr +REL_TIME:nr-yr-h +REL_TIME:r +REL_TIME:r-h +REL_TIME:r-nr +REL_TIME:r-nr-h +REL_TIME:r-nr-yr +REL_TIME:r-nr-yr-h +REL_TIME:r-yr +REL_TIME:r-yr-h +REL_TIME:yr +REL_TIME:yr-h +is_relation_lower_time +is_relation_upper_time +relation_activity_time: +relation_activity_time:h +relation_activity_time:m +relation_activity_time:m-h +relation_activity_time:nl +relation_activity_time:nl-h +relation_activity_time:nl-m +relation_activity_time:nl-m-h +relation_activity_time:nl-t +relation_activity_time:nl-t-h +relation_activity_time:nl-t-m +relation_activity_time:nl-t-m-h +relation_activity_time:nl-t-ya +relation_activity_time:nl-t-ya-h +relation_activity_time:nl-t-ya-m +relation_activity_time:nl-t-ya-m-h +relation_activity_time:nl-ya +relation_activity_time:nl-ya-h +relation_activity_time:nl-ya-m +relation_activity_time:nl-ya-m-h +relation_activity_time:nr +relation_activity_time:nr-h +relation_activity_time:nr-m +relation_activity_time:nr-m-h +relation_activity_time:nr-nl +relation_activity_time:nr-nl-h +relation_activity_time:nr-nl-m +relation_activity_time:nr-nl-m-h +relation_activity_time:nr-nl-t +relation_activity_time:nr-nl-t-h +relation_activity_time:nr-nl-t-m +relation_activity_time:nr-nl-t-m-h +relation_activity_time:nr-nl-t-ya +relation_activity_time:nr-nl-t-ya-h +relation_activity_time:nr-nl-t-ya-m +relation_activity_time:nr-nl-t-ya-m-h +relation_activity_time:nr-nl-ya +relation_activity_time:nr-nl-ya-h +relation_activity_time:nr-nl-ya-m +relation_activity_time:nr-nl-ya-m-h +relation_activity_time:nr-t +relation_activity_time:nr-t-h +relation_activity_time:nr-t-m +relation_activity_time:nr-t-m-h +relation_activity_time:nr-t-ya +relation_activity_time:nr-t-ya-h +relation_activity_time:nr-t-ya-m +relation_activity_time:nr-t-ya-m-h +relation_activity_time:nr-ya +relation_activity_time:nr-ya-h +relation_activity_time:nr-ya-m +relation_activity_time:nr-ya-m-h +relation_activity_time:nr-yr +relation_activity_time:nr-yr-h +relation_activity_time:nr-yr-m +relation_activity_time:nr-yr-m-h +relation_activity_time:nr-yr-nl +relation_activity_time:nr-yr-nl-h +relation_activity_time:nr-yr-nl-m +relation_activity_time:nr-yr-nl-m-h +relation_activity_time:nr-yr-nl-t +relation_activity_time:nr-yr-nl-t-h +relation_activity_time:nr-yr-nl-t-m +relation_activity_time:nr-yr-nl-t-m-h +relation_activity_time:nr-yr-nl-t-ya +relation_activity_time:nr-yr-nl-t-ya-h +relation_activity_time:nr-yr-nl-t-ya-m +relation_activity_time:nr-yr-nl-t-ya-m-h +relation_activity_time:nr-yr-nl-ya +relation_activity_time:nr-yr-nl-ya-h +relation_activity_time:nr-yr-nl-ya-m +relation_activity_time:nr-yr-nl-ya-m-h +relation_activity_time:nr-yr-t +relation_activity_time:nr-yr-t-h +relation_activity_time:nr-yr-t-m +relation_activity_time:nr-yr-t-m-h +relation_activity_time:nr-yr-t-ya +relation_activity_time:nr-yr-t-ya-h +relation_activity_time:nr-yr-t-ya-m +relation_activity_time:nr-yr-t-ya-m-h +relation_activity_time:nr-yr-ya +relation_activity_time:nr-yr-ya-h +relation_activity_time:nr-yr-ya-m +relation_activity_time:nr-yr-ya-m-h +relation_activity_time:r +relation_activity_time:r-h +relation_activity_time:r-m +relation_activity_time:r-m-h +relation_activity_time:r-nl +relation_activity_time:r-nl-h +relation_activity_time:r-nl-m +relation_activity_time:r-nl-m-h +relation_activity_time:r-nl-t +relation_activity_time:r-nl-t-h +relation_activity_time:r-nl-t-m +relation_activity_time:r-nl-t-m-h +relation_activity_time:r-nl-t-ya +relation_activity_time:r-nl-t-ya-h +relation_activity_time:r-nl-t-ya-m +relation_activity_time:r-nl-t-ya-m-h +relation_activity_time:r-nl-ya +relation_activity_time:r-nl-ya-h +relation_activity_time:r-nl-ya-m +relation_activity_time:r-nl-ya-m-h +relation_activity_time:r-nr +relation_activity_time:r-nr-h +relation_activity_time:r-nr-m +relation_activity_time:r-nr-m-h +relation_activity_time:r-nr-nl +relation_activity_time:r-nr-nl-h +relation_activity_time:r-nr-nl-m +relation_activity_time:r-nr-nl-m-h +relation_activity_time:r-nr-nl-t +relation_activity_time:r-nr-nl-t-h +relation_activity_time:r-nr-nl-t-m +relation_activity_time:r-nr-nl-t-m-h +relation_activity_time:r-nr-nl-t-ya +relation_activity_time:r-nr-nl-t-ya-h +relation_activity_time:r-nr-nl-t-ya-m +relation_activity_time:r-nr-nl-t-ya-m-h +relation_activity_time:r-nr-nl-ya +relation_activity_time:r-nr-nl-ya-h +relation_activity_time:r-nr-nl-ya-m +relation_activity_time:r-nr-nl-ya-m-h +relation_activity_time:r-nr-t +relation_activity_time:r-nr-t-h +relation_activity_time:r-nr-t-m +relation_activity_time:r-nr-t-m-h +relation_activity_time:r-nr-t-ya +relation_activity_time:r-nr-t-ya-h +relation_activity_time:r-nr-t-ya-m +relation_activity_time:r-nr-t-ya-m-h +relation_activity_time:r-nr-ya +relation_activity_time:r-nr-ya-h +relation_activity_time:r-nr-ya-m +relation_activity_time:r-nr-ya-m-h +relation_activity_time:r-nr-yr +relation_activity_time:r-nr-yr-h +relation_activity_time:r-nr-yr-m +relation_activity_time:r-nr-yr-m-h +relation_activity_time:r-nr-yr-nl +relation_activity_time:r-nr-yr-nl-h +relation_activity_time:r-nr-yr-nl-m +relation_activity_time:r-nr-yr-nl-m-h +relation_activity_time:r-nr-yr-nl-t +relation_activity_time:r-nr-yr-nl-t-h +relation_activity_time:r-nr-yr-nl-t-m +relation_activity_time:r-nr-yr-nl-t-m-h +relation_activity_time:r-nr-yr-nl-t-ya +relation_activity_time:r-nr-yr-nl-t-ya-h +relation_activity_time:r-nr-yr-nl-t-ya-m +relation_activity_time:r-nr-yr-nl-t-ya-m-h +relation_activity_time:r-nr-yr-nl-ya +relation_activity_time:r-nr-yr-nl-ya-h +relation_activity_time:r-nr-yr-nl-ya-m +relation_activity_time:r-nr-yr-nl-ya-m-h +relation_activity_time:r-nr-yr-t +relation_activity_time:r-nr-yr-t-h +relation_activity_time:r-nr-yr-t-m +relation_activity_time:r-nr-yr-t-m-h +relation_activity_time:r-nr-yr-t-ya +relation_activity_time:r-nr-yr-t-ya-h +relation_activity_time:r-nr-yr-t-ya-m +relation_activity_time:r-nr-yr-t-ya-m-h +relation_activity_time:r-nr-yr-ya +relation_activity_time:r-nr-yr-ya-h +relation_activity_time:r-nr-yr-ya-m +relation_activity_time:r-nr-yr-ya-m-h +relation_activity_time:r-t +relation_activity_time:r-t-h +relation_activity_time:r-t-m +relation_activity_time:r-t-m-h +relation_activity_time:r-t-ya +relation_activity_time:r-t-ya-h +relation_activity_time:r-t-ya-m +relation_activity_time:r-t-ya-m-h +relation_activity_time:r-ya +relation_activity_time:r-ya-h +relation_activity_time:r-ya-m +relation_activity_time:r-ya-m-h +relation_activity_time:r-yr +relation_activity_time:r-yr-h +relation_activity_time:r-yr-m +relation_activity_time:r-yr-m-h +relation_activity_time:r-yr-nl +relation_activity_time:r-yr-nl-h +relation_activity_time:r-yr-nl-m +relation_activity_time:r-yr-nl-m-h +relation_activity_time:r-yr-nl-t +relation_activity_time:r-yr-nl-t-h +relation_activity_time:r-yr-nl-t-m +relation_activity_time:r-yr-nl-t-m-h +relation_activity_time:r-yr-nl-t-ya +relation_activity_time:r-yr-nl-t-ya-h +relation_activity_time:r-yr-nl-t-ya-m +relation_activity_time:r-yr-nl-t-ya-m-h +relation_activity_time:r-yr-nl-ya +relation_activity_time:r-yr-nl-ya-h +relation_activity_time:r-yr-nl-ya-m +relation_activity_time:r-yr-nl-ya-m-h +relation_activity_time:r-yr-t +relation_activity_time:r-yr-t-h +relation_activity_time:r-yr-t-m +relation_activity_time:r-yr-t-m-h +relation_activity_time:r-yr-t-ya +relation_activity_time:r-yr-t-ya-h +relation_activity_time:r-yr-t-ya-m +relation_activity_time:r-yr-t-ya-m-h +relation_activity_time:r-yr-ya +relation_activity_time:r-yr-ya-h +relation_activity_time:r-yr-ya-m +relation_activity_time:r-yr-ya-m-h +relation_activity_time:t +relation_activity_time:t-h +relation_activity_time:t-m +relation_activity_time:t-m-h +relation_activity_time:t-ya +relation_activity_time:t-ya-h +relation_activity_time:t-ya-m +relation_activity_time:t-ya-m-h +relation_activity_time:ya +relation_activity_time:ya-h +relation_activity_time:ya-m +relation_activity_time:ya-m-h +relation_activity_time:yr +relation_activity_time:yr-h +relation_activity_time:yr-m +relation_activity_time:yr-m-h +relation_activity_time:yr-nl +relation_activity_time:yr-nl-h +relation_activity_time:yr-nl-m +relation_activity_time:yr-nl-m-h +relation_activity_time:yr-nl-t +relation_activity_time:yr-nl-t-h +relation_activity_time:yr-nl-t-m +relation_activity_time:yr-nl-t-m-h +relation_activity_time:yr-nl-t-ya +relation_activity_time:yr-nl-t-ya-h +relation_activity_time:yr-nl-t-ya-m +relation_activity_time:yr-nl-t-ya-m-h +relation_activity_time:yr-nl-ya +relation_activity_time:yr-nl-ya-h +relation_activity_time:yr-nl-ya-m +relation_activity_time:yr-nl-ya-m-h +relation_activity_time:yr-t +relation_activity_time:yr-t-h +relation_activity_time:yr-t-m +relation_activity_time:yr-t-m-h +relation_activity_time:yr-t-ya +relation_activity_time:yr-t-ya-h +relation_activity_time:yr-t-ya-m +relation_activity_time:yr-t-ya-m-h +relation_activity_time:yr-ya +relation_activity_time:yr-ya-h +relation_activity_time:yr-ya-m +relation_activity_time:yr-ya-m-h +relation_lower_time: +relation_lower_time:h +relation_lower_time:nr +relation_lower_time:nr-h +relation_lower_time:nr-yr +relation_lower_time:nr-yr-h +relation_lower_time:r +relation_lower_time:r-h +relation_lower_time:r-nr +relation_lower_time:r-nr-h +relation_lower_time:r-nr-yr +relation_lower_time:r-nr-yr-h +relation_lower_time:r-yr +relation_lower_time:r-yr-h +relation_lower_time:yr +relation_lower_time:yr-h +relation_upper_time: +relation_upper_time:h +relation_upper_time:nr +relation_upper_time:nr-h +relation_upper_time:nr-yr +relation_upper_time:nr-yr-h +relation_upper_time:r +relation_upper_time:r-h +relation_upper_time:r-nr +relation_upper_time:r-nr-h +relation_upper_time:r-nr-yr +relation_upper_time:r-nr-yr-h +relation_upper_time:r-yr +relation_upper_time:r-yr-h +relation_upper_time:yr +relation_upper_time:yr-h diff --git a/message_ix/tests/test_feature_relation_time.py b/message_ix/tests/test_feature_relation_time.py new file mode 100644 index 000000000..3b86e96f0 --- /dev/null +++ b/message_ix/tests/test_feature_relation_time.py @@ -0,0 +1,396 @@ +"""Tests for subannual generic relations (``relation_*_time``). + +|MESSAGEix| supports subannual generic relations via parameters +``relation_upper_time``, ``relation_lower_time`` and +``relation_activity_time``, and flag sets ``is_relation_upper_time`` / +``is_relation_lower_time``. The equations ``RELATION_EQUIVALENCE_TIME``, +``RELATION_CONSTRAINT_UP_TIME`` and ``RELATION_CONSTRAINT_LO_TIME`` bind the +auxiliary variable ``REL_TIME(r, n, y, h)`` per time slice. +""" + +import pandas as pd +import pytest +from ixmp import Platform + +from message_ix import Scenario, make_df + +pytestmark = pytest.mark.ixmp4_209 + +NODE = "node" +YEAR = 2020 +MODE = "mode" +TIMES = ["h1", "h2"] +DURATION = 0.5 # equal halves of the year +SLICE = "h1" # slice at which the per-slice bounds in these tests apply + +CHEAP_VAR_COST = 10.0 +EXPENSIVE_VAR_COST = 50.0 +DEMAND_PER_SLICE = 5.0 + +CAP_VALUE = 2.0 # below DEMAND_PER_SLICE so the expensive tech must help at h1 +FLOOR_VALUE = 2.0 # expensive_ppl must run at least this much at h1 +ANNUAL_CAP = 6.0 # below the 10 GWa cheap_ppl would supply unconstrained +COEXIST_SLICE_CAP = 3.0 # tighter cheap_ppl bound at h1 +# cheap_ppl is dearer at h2 (still below EXPENSIVE_VAR_COST) so the solution +# prefers cheap at h1 and the per-slice cap binds rather than sitting slack +# under solver degeneracy. +COEXIST_H2_VAR_COST = 20.0 + + +def _act_at(act: pd.DataFrame, technology: str, time: str) -> float: + """Total solved ``ACT`` for ``technology`` at ``time``.""" + rows = act[(act["technology"] == technology) & (act["time"] == time)] + return float(rows["lvl"].sum()) + + +def _act_total(act: pd.DataFrame, technology: str) -> float: + """Total solved ``ACT`` for ``technology`` summed over time slices.""" + return float(act[act["technology"] == technology]["lvl"].sum()) + + +def _build_subannual_baseline(mp: Platform, scenario_name: str) -> Scenario: + """Minimal 2-slice RES with a cheap and expensive supply technology. + + Each slice has ``DEMAND_PER_SLICE`` GWa electricity demand at + ``level=useful``. Both technologies have subannual ACT at every time + slice. With no additional constraint the solution uses the cheap + technology exclusively. + """ + scen = Scenario( + mp, model="test_relation_time", scenario=scenario_name, version="new" + ) + scen.add_horizon(year=[YEAR], firstmodelyear=YEAR) + scen.add_spatial_sets({"country": NODE}) + + with scen.transact("structure"): + scen.add_set("mode", MODE) + scen.add_set("level", "useful") + scen.add_set("commodity", "electr") + scen.add_set("technology", ["cheap_ppl", "expensive_ppl"]) + scen.add_set("lvl_temporal", "subannual") + for t in TIMES: + scen.add_set("time", t) + scen.add_set("map_temporal_hierarchy", ["subannual", t, "year"]) + + with scen.transact("temporal structure and demand"): + scen.remove_par("duration_time", scen.par("duration_time")) + # the "year" slice must remain in duration_time at value 1.0 + scen.add_par( + "duration_time", + make_df( + "duration_time", + time=TIMES + ["year"], + value=[DURATION] * len(TIMES) + [1.0], + unit="%", + ), + ) + scen.add_par( + "demand", + make_df( + "demand", + node=NODE, + commodity="electr", + level="useful", + year=YEAR, + time=TIMES, + value=DEMAND_PER_SLICE, + unit="GWa", + ), + ) + + common = dict(node_loc=NODE, year_vtg=YEAR, year_act=YEAR, time=TIMES) + with scen.transact("technology data"): + for tech, var_cost in ( + ("cheap_ppl", CHEAP_VAR_COST), + ("expensive_ppl", EXPENSIVE_VAR_COST), + ): + scen.add_par( + "output", + make_df( + "output", + **common, + technology=tech, + mode=MODE, + commodity="electr", + level="useful", + node_dest=NODE, + time_dest=TIMES, + value=1.0, + unit="GWa", + ), + ) + scen.add_par( + "var_cost", + make_df( + "var_cost", + **common, + technology=tech, + mode=MODE, + value=var_cost, + unit="USD/GWa", + ), + ) + scen.add_par( + "capacity_factor", + make_df( + "capacity_factor", **common, technology=tech, value=1.0, unit="-" + ), + ) + + return scen + + +def _add_time_relation( + scen: Scenario, + bound_par: str, + bound_value: float, + *, + relation: str = "slice_bound", + technology: str = "cheap_ppl", + times: tuple[str, ...] = (SLICE,), + set_flag: bool = False, +) -> None: + """Bound ``technology`` activity at each slice in ``times``. + + ``bound_par`` is ``relation_upper_time`` or ``relation_lower_time``. The + ``relation_*_time`` parameters and ``is_relation_*_time`` flag sets are + initialized by :meth:`.MESSAGE.initialize` (see :attr:`.MESSAGE.items`). + With ``set_flag=False`` the flag set is composed from parameter keys by + :meth:`.MESSAGE.enforce`. + """ + with scen.transact("subannual relation bound"): + scen.add_set("relation", relation) + scen.add_par( + "relation_activity_time", + make_df( + "relation_activity_time", + relation=relation, + node_rel=NODE, + year_rel=YEAR, + node_loc=NODE, + technology=technology, + year_act=YEAR, + mode=MODE, + time=list(times), + value=1.0, + unit="-", + ), + ) + scen.add_par( + bound_par, + make_df( + bound_par, + relation=relation, + node_rel=NODE, + year_rel=YEAR, + time=list(times), + value=bound_value, + unit="GWa", + ), + ) + if set_flag: + for t in times: + scen.add_set(f"is_{bound_par}", [relation, NODE, str(YEAR), t]) + + +def _add_annual_cap( + scen: Scenario, relation: str, cap_value: float, *, technology: str = "cheap_ppl" +) -> None: + """Cap ``technology`` activity summed over the year via annual ``relation_*``. + + ``is_relation_upper`` is composed from ``relation_upper`` by the backend + at solve time, so it is not set here. + """ + with scen.transact("annual relation bound"): + scen.add_set("relation", relation) + scen.add_par( + "relation_activity", + make_df( + "relation_activity", + relation=relation, + node_rel=NODE, + year_rel=YEAR, + node_loc=NODE, + technology=technology, + year_act=YEAR, + mode=MODE, + value=1.0, + unit="-", + ), + ) + scen.add_par( + "relation_upper", + make_df( + "relation_upper", + relation=relation, + node_rel=NODE, + year_rel=YEAR, + value=cap_value, + unit="GWa", + ), + ) + + +def test_subannual_relation_not_populated(test_mp: Platform) -> None: + """Baseline without ``relation_*_time`` populated must solve cleanly. + + Regression guard for scenarios that never use the feature: + ``data_load.gms`` must handle the case where the new ``relation_*_time`` + symbols are absent from the scenario GDX, and the new + ``RELATION_*_TIME`` equations must not introduce infeasibility or solve + errors. + """ + scen = _build_subannual_baseline(test_mp, "baseline_no_cap") + scen.solve(quiet=True) + expected = len(TIMES) * DEMAND_PER_SLICE * CHEAP_VAR_COST + assert float(scen.var("OBJ")["lvl"]) == pytest.approx(expected, rel=1e-3) + + +def test_subannual_relation_upper_binds(test_mp: Platform) -> None: + """A subannual upper bound on ``cheap_ppl`` at ``h1`` binds. + + ``cheap_ppl.ACT`` at ``h1`` rides the cap and ``expensive_ppl`` picks up + the slack; ``h2`` is unchanged. + """ + scen = _build_subannual_baseline(test_mp, "baseline_for_upper_cap") + capped = scen.clone(scenario="upper_cap", keep_solution=False) + _add_time_relation(capped, "relation_upper_time", CAP_VALUE, set_flag=True) + capped.solve(quiet=True) + + act = capped.var("ACT") + assert _act_at(act, "cheap_ppl", SLICE) == pytest.approx(CAP_VALUE, rel=1e-3) + assert _act_at(act, "expensive_ppl", SLICE) == pytest.approx( + DEMAND_PER_SLICE - CAP_VALUE, rel=1e-3 + ) + # the cap at h1 leaves h2 unconstrained + assert _act_at(act, "cheap_ppl", "h2") == pytest.approx(DEMAND_PER_SLICE, rel=1e-3) + + expected = (CAP_VALUE + DEMAND_PER_SLICE) * CHEAP_VAR_COST + ( + DEMAND_PER_SLICE - CAP_VALUE + ) * EXPENSIVE_VAR_COST + assert float(capped.var("OBJ")["lvl"]) == pytest.approx(expected, rel=1e-3) + + +def test_subannual_relation_flag_composed_without_explicit_flag( + test_mp: Platform, +) -> None: + """``is_relation_upper_time`` composes from parameter keys on solve. + + The caller populates only ``relation_activity_time`` / + ``relation_upper_time`` without adding entries to + ``is_relation_upper_time``. :meth:`.MESSAGE.enforce` composes the flag + set from parameter keys at solve time. The constraint must still bind. + """ + scen = _build_subannual_baseline(test_mp, "baseline_for_composed_flag") + capped = scen.clone(scenario="composed_flag_cap", keep_solution=False) + _add_time_relation(capped, "relation_upper_time", CAP_VALUE) + capped.solve(quiet=True) + + act = capped.var("ACT") + assert _act_at(act, "cheap_ppl", SLICE) == pytest.approx(CAP_VALUE, rel=1e-3) + + +def test_subannual_relation_upper_zero_bound_binds(test_mp: Platform) -> None: + """A zero-valued ``relation_upper_time`` is a valid binding constraint. + + ``relation_upper_time = 0`` expresses "REL_TIME <= 0", forcing + ``cheap_ppl`` activity at ``SLICE`` to zero. The flag set composition + must preserve the key regardless of value (key-based, not value-based), + so the constraint still fires. + """ + scen = _build_subannual_baseline(test_mp, "baseline_for_zero_cap") + capped = scen.clone(scenario="zero_cap", keep_solution=False) + _add_time_relation(capped, "relation_upper_time", 0.0) + capped.solve(quiet=True) + + act = capped.var("ACT") + assert _act_at(act, "cheap_ppl", SLICE) == pytest.approx(0.0, abs=1e-6) + assert _act_at(act, "expensive_ppl", SLICE) == pytest.approx( + DEMAND_PER_SLICE, rel=1e-3 + ) + + +def test_subannual_relation_lower_binds(test_mp: Platform) -> None: + """A subannual lower bound forces ``expensive_ppl`` activity at ``h1``. + + Exercises ``RELATION_CONSTRAINT_LO_TIME`` and the lower branch of the + :meth:`.MESSAGE.enforce` flag composition (``is_relation_lower_time`` is + not set explicitly). ``expensive_ppl`` runs the floor at ``h1`` and + ``cheap_ppl`` covers the remaining demand. + """ + scen = _build_subannual_baseline(test_mp, "baseline_for_lower") + floored = scen.clone(scenario="lower_floor", keep_solution=False) + _add_time_relation( + floored, + "relation_lower_time", + FLOOR_VALUE, + relation="slice_floor", + technology="expensive_ppl", + ) + floored.solve(quiet=True) + + act = floored.var("ACT") + assert _act_at(act, "expensive_ppl", SLICE) == pytest.approx(FLOOR_VALUE, rel=1e-3) + assert _act_at(act, "cheap_ppl", SLICE) == pytest.approx( + DEMAND_PER_SLICE - FLOOR_VALUE, rel=1e-3 + ) + + +def test_subannual_relation_matches_annual(test_mp: Platform) -> None: + """Even per-slice ``_time`` caps reproduce the annual relation. + + An annual ``relation_upper = C`` on ``cheap_ppl`` and a + ``relation_upper_time = C / 2`` at each of two equal slices give the same + objective and the same total ``cheap_ppl`` activity. + """ + base = _build_subannual_baseline(test_mp, "baseline_for_equivalence") + + annual = base.clone(scenario="annual_cap", keep_solution=False) + _add_annual_cap(annual, "annual_cap", ANNUAL_CAP) + annual.solve(quiet=True) + + subannual = base.clone(scenario="subannual_cap", keep_solution=False) + _add_time_relation( + subannual, + "relation_upper_time", + ANNUAL_CAP / len(TIMES), + relation="uniform_cap", + times=tuple(TIMES), + ) + subannual.solve(quiet=True) + + assert float(annual.var("OBJ")["lvl"]) == pytest.approx( + float(subannual.var("OBJ")["lvl"]), rel=1e-3 + ) + cheap_annual = _act_total(annual.var("ACT"), "cheap_ppl") + cheap_subannual = _act_total(subannual.var("ACT"), "cheap_ppl") + assert cheap_annual == pytest.approx(cheap_subannual, rel=1e-3) + assert cheap_subannual == pytest.approx(ANNUAL_CAP, rel=1e-3) + + +def test_subannual_and_annual_relation_coexist(test_mp: Platform) -> None: + """Annual and subannual bounds on one relation both bind. + + An annual ``relation_upper`` caps total ``cheap_ppl`` activity; a tighter + ``relation_upper_time`` caps it further at ``h1``. The solution rides both + the annual total and the per-slice cap. + """ + base = _build_subannual_baseline(test_mp, "baseline_for_coexist") + scen = base.clone(scenario="coexist", keep_solution=False) + with scen.transact("dearer cheap_ppl at h2"): + h2_cost = scen.par( + "var_cost", filters={"technology": "cheap_ppl", "time": "h2"} + ) + scen.remove_par("var_cost", h2_cost) + scen.add_par("var_cost", h2_cost.assign(value=COEXIST_H2_VAR_COST)) + _add_annual_cap(scen, "coexist_cap", ANNUAL_CAP) + _add_time_relation( + scen, "relation_upper_time", COEXIST_SLICE_CAP, relation="coexist_cap" + ) + scen.solve(quiet=True) + + act = scen.var("ACT") + assert _act_at(act, "cheap_ppl", SLICE) == pytest.approx( + COEXIST_SLICE_CAP, rel=1e-3 + ) + assert _act_total(act, "cheap_ppl") == pytest.approx(ANNUAL_CAP, rel=1e-3) diff --git a/message_ix/util/scenario_data.py b/message_ix/util/scenario_data.py index 906e4eeb7..1c0f3ce97 100644 --- a/message_ix/util/scenario_data.py +++ b/message_ix/util/scenario_data.py @@ -1740,6 +1740,12 @@ class HelperTableInfo(HelperIndexSetInfo): ), HelperTableInfo(name="is_relation_upper", sources={"relation_upper": None}), HelperTableInfo(name="is_relation_lower", sources={"relation_lower": None}), + HelperTableInfo( + name="is_relation_upper_time", sources={"relation_upper_time": None} + ), + HelperTableInfo( + name="is_relation_lower_time", sources={"relation_lower_time": None} + ), # auxiliary mapping sets for fixing decision variables # flags for fixed decision variable values (for 'slicing', i.e., fixing of decision # variables)