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)