diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index cee7fbb07..4516cc96b 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -32,11 +32,6 @@ jobs: # for the message_ix project. # - "3.10.0-alpha.1" # Development version - exclude: - # JPype1 (for ixmp) binary wheels are not available for this combination - - os: windows-latest - python-version: "3.10" - fail-fast: false runs-on: ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index 46a634d1e..7f0f42ffd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,35 +1,30 @@ -# GAMS, R, Python-specific auxiliary files -*.~gm +# Python, R, etc. +*-checkpoint.ipynb *.bak +*.dat *.egg-info -*.~op -*.pyc -*.gdx -*.lst -*.log +*.eggs *.lxi -*.gch -*.gpr -/model/2*/** -/model/$gms* -*.dat -*.tmp -*.RData* *.pyc -*-checkpoint.ipynb +*.RData* +*.tmp +*# *~ #* -*# -. -*.eggs -# MESSAGEix specifics +# GAMS +*.~gm +*.~op +*.gch +*.gdx +*.gpr +*.log +*.lst +message_ix/model/2*/** +message_ix/model/$gms* message_ix/model/MESSAGE_master.gms message_ix/model/cplex.opt -# Apple file system -.DS_Store - # Sphinx doc/_build doc/bibtex.json @@ -42,16 +37,20 @@ dist # testdb .cache/** tests/data/nightly -.Rproj.user # pytest and related .benchmarks .coverage* .mypy_cache -prof/ .pytest_cache coverage.xml htmlcov +prof/ -# JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# JetBrains IDEs incl. PyCharm /**/.idea +# RStudio +.Rproj.user + +# macOS +.DS_Store diff --git a/RELEASE_NOTES.rst b/RELEASE_NOTES.rst index 7cd1613f8..b544e38f2 100644 --- a/RELEASE_NOTES.rst +++ b/RELEASE_NOTES.rst @@ -1,8 +1,23 @@ -.. Next release -.. ============ +Next release +============ -.. All changes -.. ----------- +Migration notes +--------------- + +- The `in_horizon` argument to :meth:`.vintage_and_active_years` is deprecated, and will be removed in :mod:`message_ix` 4.0 or later. + At the same time, the behaviour will change to be the equivalent of providing `in_horizon` = :obj:`False`, i.e. the method will no longer filter to the scenario time horizon by default. + To prepare for this change, user code that expects values confined to the time horizon can be altered to use :meth:`.pandas.DataFrame.query`: + + .. code-block:: python + + df = scen.vintage_and_active_years().query(f"{scen.y0} <= year_vtg") + + +All changes +----------- + +- Extend functionality of :meth:`.vintage_and_active_years`; add aliases + :meth:`.yv_ya`, :meth:`.ya`, and :attr:`.y0` (:pull:`572`). .. _v3.5.0: diff --git a/doc/api.rst b/doc/api.rst index a742c358b..1154951f2 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -67,7 +67,10 @@ The full API is also available from R; see :doc:`rmessageix`. to_excel var vintage_and_active_years + y0 years_active + ya + yv_ya .. automethod:: add_macro diff --git a/message_ix/core.py b/message_ix/core.py index 881d1218c..6356f4425 100755 --- a/message_ix/core.py +++ b/message_ix/core.py @@ -1,7 +1,8 @@ import logging from collections.abc import Mapping from functools import lru_cache -from itertools import product +from itertools import chain, product +from typing import Iterable, List, Optional, Tuple, Union from warnings import warn import ixmp @@ -20,9 +21,9 @@ class Scenario(ixmp.Scenario): """|MESSAGEix| Scenario. - See :class:`ixmp.TimeSeries` for the meaning of arguments `mp`, `model`, - `scenario`, `version`, and `annotation`. The `scheme` of a newly-created - Scenario is always 'MESSAGE'. + See :class:`ixmp.TimeSeries` for the meaning of arguments `mp`, `model`, `scenario`, + `version`, and `annotation`. The `scheme` of a newly-created Scenario is always + "MESSAGE". """ def __init__( @@ -289,21 +290,26 @@ def recurse(k, v, parent="World"): self.add_set("lvl_spatial", levels) self.add_set("map_spatial_hierarchy", hierarchy) - def add_horizon(self, year=[], firstmodelyear=None, data=None): + def add_horizon( + self, + year: Iterable[int] = [], + firstmodelyear: Optional[int] = None, + data: Optional[dict] = None, + ) -> None: """Set the scenario time horizon via ``year`` and related categories. - :meth:`add_horizon` acts like ``add_set("year", ...)``, except with - additional conveniences: + :meth:`add_horizon` acts like ``add_set("year", ...)``, except with additional + conveniences: - - The `firstmodelyear` argument can be used to set the first period - handled by the MESSAGE optimization. This is equivalent to:: + - The `firstmodelyear` argument can be used to set the first period handled by + the MESSAGE optimization. This is equivalent to:: scenario.add_cat("year", "firstmodelyear", ..., is_unique=True) - - Parameter ``duration_period`` is assigned values based on `year`: - The duration of periods is calculated as the interval between - successive `year` elements, and the duration of the first period is - set to value that appears most frequently. + - Parameter ``duration_period`` is assigned values based on `year`: The duration + of periods is calculated as the interval between successive `year` elements, + and the duration of the first period is set to value that appears most + frequently. See :doc:`time` for a detailed terminology of years and periods in :mod:`message_ix`. @@ -314,25 +320,24 @@ def add_horizon(self, year=[], firstmodelyear=None, data=None): The set of periods. firstmodelyear : int, optional - First period for the model solution. If not given, the first entry - of `year` is used. + First period for the model solution. If not given, the first entry of `year` + is used. Other parameters ---------------- data : dict .. deprecated:: 3.1 - The "year" key corresponds to `year` and is required. - A "firstmodelyear" key corresponds to `firstmodelyear` and is - optional. + The "year" key corresponds to `year` and is required. A "firstmodelyear" + key corresponds to `firstmodelyear` and is optional. Raises ------ ValueError - If the ``year`` set of the Scenario is already populated. Changing - the time periods of an existing Scenario can entail complex - adjustments to data. For this purpose, adjust each set and - parameter individually, or see :mod:`.tools.add_year`. + If the ``year`` set of the Scenario is already populated. Changing the time + periods of an existing Scenario can entail complex adjustments to data. For + this purpose, adjust each set and parameter individually, or see + :mod:`.tools.add_year`. Examples -------- @@ -343,8 +348,8 @@ def add_horizon(self, year=[], firstmodelyear=None, data=None): >>> s.add_horizon([2020, 2030, 2040]) """ # Check arguments - # NB once the deprecated signature is removed, these two 'if' blocks - # and the data= argument can be deleted. + # NB once the deprecated signature is removed, these two 'if' blocks and the + # data= argument can be deleted. if isinstance(year, dict): # Move a dict argument to `data` to trigger the next block if data: @@ -353,7 +358,7 @@ def add_horizon(self, year=[], firstmodelyear=None, data=None): if data: warn( - "dict() argument to add_horizon(); use year= and " "firstmodelyear=", + "dict() argument to add_horizon(); use year= and firstmodelyear=", DeprecationWarning, ) @@ -391,8 +396,8 @@ def add_horizon(self, year=[], firstmodelyear=None, data=None): # Cannot infer any durations with only 1 period return elif len(set(duration)) == 1: - # All periods have the same duration; use this for the duration of - # the first period + # All periods have the same duration; use this for the duration of the first + # period duration_first = duration[0] else: # More than one period duration. Use the mode, i.e. the most common @@ -408,71 +413,167 @@ def add_horizon(self, year=[], firstmodelyear=None, data=None): self.add_par( "duration_period", pd.DataFrame( - { - "year": year, - "value": [duration_first] + duration, - "unit": "y", - } + {"year": year, "value": [duration_first] + duration, "unit": "y"} ), ) - def vintage_and_active_years(self, ya_args=None, in_horizon=True): - """Return sets of vintage and active years for use in data input. - - For a valid pair `(year_vtg, year_act)`, the following conditions are - satisfied: - - 1. Both the vintage year (`year_vtg`) and active year (`year_act`) are - in the model's ``year`` set. - 2. `year_vtg` <= `year_act`. - 3. `year_act` <= the model's first year **or** `year_act` is in the - smaller subset :meth:`ixmp.Scenario.years_active` for the given - `ya_args`. + def vintage_and_active_years( + self, + ya_args: Union[Tuple[str, str], Tuple[str, str, Union[int, str]]] = None, + tl_only: bool = True, + **kwargs, + ) -> pd.DataFrame: + r"""Return matched pairs of vintage and active periods for use in data input. + + Each returned pair of (vintage period :math:`y^V`, active period :math:`y`) + satisfies all of the following conditions: + + 1. :math:`y^V, y \in Y`: both vintage and active period are in the ``year`` set + of the Scenario. + 2. :math:`y^V \leq y`: a technology cannot be active before it is constructed. + 3. If `ya_args` (node :math:`n`, technology :math:`t`, and optionally + :math:`y^V`) are given: + + a. :math:`y^V` is in the subset of :math:`Y` for which + :math:`\text{technical_lifetime}_{n,t,y^V}` is defined (or the single, + specified value). + b. :math:`y - y^V + \text{duration_period}_{n,t,y^V} < + \text{technical_lifetime}_{n,t,y^V}`: the active period is partly or fully + within the technical lifetime defined for that technology, node, and + vintage. This is the same condition as :meth:`years_active`. + + 4. If `ya_args` are given and `tl_only` is :obj:`True` (the default): :math:`y` + is in the subset of :math:`Y` for which + :math:`\text{technical_lifetime}_{n,t,y}` is defined.[1]_ + 5. (Deprecated) If `in_horizon` is :obj:`True`: :math:`y \geq y_0`, the + :attr:`.firstmodelyear`. + + .. [1] note this applies to :math:`y`, whereas condition 3(a) applies to + :math:`y^V`. Parameters ---------- - ya_args : tuple of (node, tec, yr_vtg), optional - Arguments to :meth:`years_active`. + ya_args : tuple of (node, technology) or (node, technology, year_vtg), optional + Supplied directly to :meth:`years_active`. If the third element is omitted, + :meth:`years_active` is called repeatedly, once for each vintage for which a + technical lifetime value is set (condition (3)). + tl_only : bool, optional + Condition (4), above. in_horizon : bool, optional - Only return years within the model horizon - (:obj:`firstmodelyear` or later). + Condition (5), above. + + .. deprecated:: 3.6 + + In :mod:`message_ix` 4.0 or later, `in_horizon` will be removed, and the + default behaviour of :func:`vintage_and_active_years` will change to the + equivalent of `in_horizon` = :obj:`False`. Returns ------- pandas.DataFrame - with columns 'year_vtg' and 'year_act', in which each row is a - valid pair. + with columns "year_vtg" and "year_act", in which each row is a valid pair. + + Examples + -------- + :meth:`pandas.DataFrame.query` can be used to further manipulate the data in the + returned data frame. To limit the vintage periods included: + + >>> base = s.vintage_and_active_years(("node", "tech")) + >>> df = base.query("2020 <= year_vtg") + + Limit the active periods included: + + >>> df = base.query("2040 < year_act") + + Limit year_act to the first model year or later (same as deprecated `in_horizon` + argument): + + >>> df = base.query(f"{s.firstmodelyear} <= year_act") + + More complex expressions and a chained pandas call: + + >>> df = s.vintage_and_active_years( + ... ("node", "tech"), tl_only=False + ... ).query("2025 <= year_act or year_vtg < 2010") + + See also + -------- + :doc:`time` + pandas.DataFrame.query + .years_active """ - first = self.firstmodelyear + extra = set(kwargs.keys()) - {"in_horizon"} + if len(extra): + raise TypeError(f"{__name__}() got unexpected keyword argument(s) {extra}") + + ya_max = np.inf # Prepare lists of vintage (yv) and active (ya) years - if ya_args: - if len(ya_args) != 3: - raise ValueError("3 arguments are required if using `ya_args`") - ya = self.years_active(*ya_args) - yv = ya[0:1] # Just the first element, as a list - else: + if ya_args is None: # Product of all years - yv = ya = self.set("year") + years = self.set("year") + values: Iterable = product(years, years) + elif len(ya_args) not in {2, 3}: + raise ValueError( + f"ya_args must be a 2- or 3-tuple; got {ya_args} of length " + f"{len(ya_args)}" + ) + else: + # All possible vintages for the given (node, technology) + vintages = sorted( + self.par( + "technical_lifetime", + filters={"node_loc": ya_args[0], "technology": ya_args[1]}, + )["year_vtg"].unique() + ) + ya_max = max(vintages) if tl_only else np.inf + + if len(ya_args) == 3: + # Specific vintage for `years_active()` + values = map( + lambda y: (int(ya_args[-1]), y), # type: ignore + self.years_active(*ya_args), + ) + else: + # One list of (yv, ya) values for each vintage + # NB this could be made more efficient using a modified version of the + # code in years_active(); however any performance penalty from + # repeated calls is probably mitigated by caching. + iters = [] + for yv in vintages: + iters.append( + [(yv, y) for y in self.years_active(ya_args[0], ya_args[1], yv)] + ) + values = chain(*iters) + + # Minimum value for year_act + if "in_horizon" in kwargs: + warn( + "'in_horizon' argument to .vintage_and_active_years() will be removed " + "in message_ix>=4.0. Use .query(…) instead per documentation examples.", + DeprecationWarning, + ) + ya_min = self.firstmodelyear if kwargs.get("in_horizon", True) else -np.inf # Predicate for filtering years def _valid(elem): yv, ya = elem - return (yv <= ya) and (not in_horizon or (first <= ya)) + return yv <= ya and ya_min <= ya <= ya_max - # - Cartesian product of all yv and ya. - # - Filter only valid years. - # - Convert to data frame. + # Filter values and convert to data frame return pd.DataFrame( - filter(_valid, product(yv, ya)), columns=["year_vtg", "year_act"] + filter(_valid, values), columns=["year_vtg", "year_act"], dtype=np.int64 ) - def years_active(self, node, tec, yr_vtg): - """Return years in which *tec* of *yr_vtg* can be active in *node*. + #: Alias for :meth:`vintage_and_active_years`. + yv_ya = vintage_and_active_years + + def years_active(self, node: str, tec: str, yr_vtg: Union[int, str]) -> List[int]: + """Return periods in which `tec` hnology of `yr_vtg` can be active in `node`. The :ref:`parameters ` ``duration_period`` and - ``technical_lifetime`` are used to determine which periods are partly - or fully within the lifetime of the technology. + ``technical_lifetime`` are used to determine which periods are partly or fully + within the lifetime of the technology. Parameters ---------- @@ -491,7 +592,7 @@ def years_active(self, node, tec, yr_vtg): yv = int(yr_vtg) filters = dict(node_loc=[node], technology=[tec], year_vtg=[yv]) - # Lifetime of the technology at the node + # Lifetime of the technology at the node and year_vtg lt = self.par("technical_lifetime", filters=filters).at[0, "value"] # Duration of periods @@ -500,17 +601,20 @@ def years_active(self, node, tec, yr_vtg): data["age"] = data.where(data.year >= yv, 0)["value"].cumsum() # Return periods: - # - the tec's age at the end of the *prior* period is less than or - # equal to its lifetime, and + # - the tec's age at the end of the *prior* period is less than or equal to its + # lifetime, and # - at or later than the vintage year. return ( data.where(data.age.shift(1, fill_value=0) < lt) .where(data.year >= yv)["year"] .dropna() - .astype(int) + .astype(np.int64) .tolist() ) + #: Alias for :meth:`years_active`. + ya = years_active + @property def firstmodelyear(self): """The first model year of the scenario. @@ -521,6 +625,10 @@ def firstmodelyear(self): """ return int(self.cat("year", "firstmodelyear")[0]) + @property + def y0(self): + """Alias for :attr:`.firstmodelyear`.""" + def clone(self, *args, **kwargs): """Clone the current scenario and return the clone. diff --git a/message_ix/tests/test_core.py b/message_ix/tests/test_core.py index 6b78a5a14..29f843bd0 100644 --- a/message_ix/tests/test_core.py +++ b/message_ix/tests/test_core.py @@ -194,75 +194,6 @@ def test_add_horizon_repeat(test_mp, caplog): scen.add_horizon([2015, 2020, 2025], firstmodelyear=2010) -def test_vintage_and_active_years(test_mp): - scen = Scenario(test_mp, **SCENARIO["dantzig"], version="new") - - years = [2000, 2010, 2020] - scen.add_horizon(year=years, firstmodelyear=2010) - obs = scen.vintage_and_active_years() - exp = pd.DataFrame( - { - "year_vtg": (2000, 2000, 2010, 2010, 2020), - "year_act": (2010, 2020, 2010, 2020, 2020), - } - ) - pdt.assert_frame_equal(exp, obs, check_like=True) # ignore col order - - # Add a technology, its lifetime, and period durations - scen.add_set("node", "foo") - scen.add_set("technology", "bar") - scen.add_par( - "duration_period", pd.DataFrame({"unit": "???", "value": 10, "year": years}) - ) - scen.add_par( - "technical_lifetime", - pd.DataFrame( - { - "node_loc": "foo", - "technology": "bar", - "unit": "???", - "value": 20, - "year_vtg": years, - } - ), - ) - - # part is before horizon - obs = scen.vintage_and_active_years(ya_args=("foo", "bar", "2000")) - exp = pd.DataFrame({"year_vtg": (2000,), "year_act": (2010,)}) - pdt.assert_frame_equal(exp, obs, check_like=True) # ignore col order - - obs = scen.vintage_and_active_years( - ya_args=("foo", "bar", "2000"), in_horizon=False - ) - exp = pd.DataFrame({"year_vtg": (2000, 2000), "year_act": (2000, 2010)}) - pdt.assert_frame_equal(exp, obs, check_like=True) # ignore col order - - # fully in horizon - obs = scen.vintage_and_active_years(ya_args=("foo", "bar", "2010")) - exp = pd.DataFrame({"year_vtg": (2010, 2010), "year_act": (2010, 2020)}) - pdt.assert_frame_equal(exp, obs, check_like=True) # ignore col order - - # part after horizon - obs = scen.vintage_and_active_years(ya_args=("foo", "bar", "2020")) - exp = pd.DataFrame({"year_vtg": (2020,), "year_act": (2020,)}) - pdt.assert_frame_equal(exp, obs, check_like=True) # ignore col order - - # Advance the first model year - scen.add_cat("year", "firstmodelyear", years[-1], is_unique=True) - - # Empty data frame: only 2000 and 2010 valid year_act for this node/tec; - # but both are before the first model year - obs = scen.vintage_and_active_years( - ya_args=("foo", "bar", years[0]), in_horizon=True - ) - pdt.assert_frame_equal(pd.DataFrame(columns=["year_vtg", "year_act"]), obs) - - # Exception is raised for incorrect arguments - with pytest.raises(ValueError, match="3 arguments are required if using `ya_args`"): - scen.vintage_and_active_years(ya_args=("foo", "bar")) - - def test_cat_all(dantzig_message_scenario): scen = dantzig_message_scenario df = scen.cat("technology", "all") diff --git a/message_ix/tests/test_feature_vintage_and_active_years.py b/message_ix/tests/test_feature_vintage_and_active_years.py new file mode 100644 index 000000000..798377e76 --- /dev/null +++ b/message_ix/tests/test_feature_vintage_and_active_years.py @@ -0,0 +1,229 @@ +from functools import lru_cache +from typing import Optional, Sequence, Tuple + +import numpy as np +import pandas as pd +import pytest +from ixmp import Platform +from pandas.testing import assert_frame_equal + +from message_ix import Scenario, make_df +from message_ix.testing import SCENARIO + + +@lru_cache() +def _generate_yv_ya(periods: Tuple[int, ...]) -> pd.DataFrame: + """All meaningful combinations of (vintage year, active year) given `periods`.""" + # commented: currently unused, this does the same as the line below, using (start + # period, final period, uniform ``duration_period). The intermediate periods are + # inferred + # _s = slice(periods_or_start, end + 1, dp) + # data = np.mgrid[_s, _s] + + # Create a mesh grid using numpy built-ins + data = np.meshgrid(periods, periods, indexing="ij") + # Take the upper-triangular portion (setting the rest to 0), reshape + data = np.triu(data).reshape((2, -1)) + # Filter only non-zero pairs + return pd.DataFrame( + filter(sum, zip(data[0, :], data[1, :])), + columns=["year_vtg", "year_act"], + dtype=np.int64, + ) + + +def _setup( + mp: Platform, years: Sequence[int], firstmodelyear: int, tl_years=None +) -> Tuple[Scenario, pd.DataFrame]: + """Common setup for test of :meth:`.vintage_and_active_years`. + + Adds: + + - the model time horizon, using `years` and `firstmodelyear`. + - a node 'foo' + - a technology 'bar', with a ``technical_lifetime`` of 20 years; either for all + `years`, or for a subset of `tl_years`. + + Returns the Scenario and a data frame from :func:`_generate_yv_ya`. + """ + scenario = Scenario(mp, **SCENARIO["dantzig"], version="new") + + scenario.add_horizon(year=years, firstmodelyear=firstmodelyear) + scenario.add_set("node", "foo") + scenario.add_set("technology", "bar") + scenario.add_par( + "technical_lifetime", + make_df( + "technical_lifetime", + node_loc="foo", + technology="bar", + unit="y", + value=20, + year_vtg=tl_years or years, + ), + ) + + return scenario, _generate_yv_ya(years) + + +def _q( + df: pd.DataFrame, query: str, append: Optional[pd.DataFrame] = None +) -> pd.DataFrame: + """Shorthand to query the results of :func:`_generate_yv_ya`. + + 1. :meth:`pandas.DataFrame.query` is called with the `query` argument. + 2. Any additional rows in `append` are appended. + 3. The index is reset. + """ + result = df.query(query).reset_index(drop=True) + + if append is not None: + result = pd.concat([result, append]).sort_values( + ["year_vtg", "year_act"], ignore_index=True + ) + + return result + + +def test_vintage_and_active_years1(test_mp): + """Basic functionality of :meth:`.vintage_and_active_years`.""" + years = (2000, 2010, 2020) + fmy = years[1] + + # Set up scenario, tech, and retrieve valid (yv, ya) pairs + scen, yvya_all = _setup(test_mp, years, fmy) + + # Default / no arguments + assert_frame_equal( + _q(yvya_all, f"year_act >= {fmy}"), + scen.vintage_and_active_years(), + ) + + # part is before horizon + obs = scen.vintage_and_active_years(ya_args=("foo", "bar", "2000")) + exp = pd.DataFrame({"year_vtg": (2000,), "year_act": (2010,)}) + assert_frame_equal(exp, obs) + + with pytest.warns(DeprecationWarning, match="'in_horizon' argument to"): + obs = scen.vintage_and_active_years( + ya_args=("foo", "bar", "2000"), in_horizon=False + ) + exp = pd.DataFrame({"year_vtg": (2000, 2000), "year_act": (2000, 2010)}) + assert_frame_equal(exp, obs) + + # fully in horizon + obs = scen.vintage_and_active_years(ya_args=("foo", "bar", "2010")) + exp = pd.DataFrame({"year_vtg": (2010, 2010), "year_act": (2010, 2020)}) + assert_frame_equal(exp, obs) + + # part after horizon + obs = scen.vintage_and_active_years(ya_args=("foo", "bar", "2020")) + exp = pd.DataFrame({"year_vtg": (2020,), "year_act": (2020,)}) + assert_frame_equal(exp, obs) + + # Advance the first model year + scen.add_cat("year", "firstmodelyear", years[-1], is_unique=True) + + # Empty data frame: only 2000 and 2010 valid year_act for this node/tec; but both + # are before the first model year + with pytest.warns(DeprecationWarning, match="'in_horizon' argument to"): + obs = scen.vintage_and_active_years( + ya_args=("foo", "bar", years[0]), in_horizon=True + ) + assert_frame_equal( + pd.DataFrame(columns=["year_vtg", "year_act"], dtype=np.int64), obs + ) + + # Exception is raised for incorrect arguments + with pytest.raises(ValueError, match=r"got \('foo',\) of length 1"): + scen.vintage_and_active_years(ya_args=("foo",)) + + +def test_vintage_and_active_years2(test_mp): + """:meth:`.vintage_and_active_years` with periods of uneven duration.""" + years = (2000, 2005, 2010, 2015, 2020, 2030) + fmy = years[2] + + scen, yvya_all = _setup(test_mp, years, fmy) + + extra = pd.Series(dict(year_vtg=2010, year_act=2030)).to_frame().T + + # No arguments + obs = scen.vintage_and_active_years() + exp = _q(yvya_all, f"{fmy} <= year_act") + assert_frame_equal(exp, obs) + + # ya_args with 3 elements + obs = scen.vintage_and_active_years(ya_args=("foo", "bar", "2010")) + exp = _q(yvya_all, f"year_vtg == 2010 and {fmy} <= year_act") + assert_frame_equal(exp, obs) + + # ya_args with 2 elements (no year_vtg) + obs = scen.vintage_and_active_years(ya_args=("foo", "bar")) + exp = _q(yvya_all, f"{fmy} <= year_act and year_act - year_vtg < 20", extra) + assert_frame_equal(exp, obs) + + # in_horizon = False + with pytest.warns(DeprecationWarning, match="'in_horizon' argument to"): + obs = scen.vintage_and_active_years(ya_args=("foo", "bar"), in_horizon=False) + exp = _q(yvya_all, "year_act - year_vtg < 20", extra) + assert_frame_equal(exp, obs) + + # Limiting year_vtg + obs = scen.vintage_and_active_years(("foo", "bar")).query("2010 <= year_vtg") + exp = _q(yvya_all, f"{fmy} <= year_vtg") + assert_frame_equal(exp, obs.reset_index(drop=True)) + + # Limiting year_act + obs = scen.vintage_and_active_years(("foo", "bar")).query("2020 <= year_act") + exp = _q(yvya_all, "2020 <= year_act and year_act - year_vtg < 20", extra) + assert_frame_equal(exp, obs.reset_index(drop=True)) + + +def test_vintage_and_active_years3(test_mp): + """Technology with ``technical_lifetime`` not defined to the end of the horizon.""" + years = (2000, 2005, 2010, 2015, 2020, 2030) + fmy = years[2] + + # Last year for which the technical_lifetime of "bar" will be defined + y_max = years[-2] + + scen, yvya_all = _setup(test_mp, years, fmy, filter(lambda y: y <= y_max, years)) + + # With default tl_only=True + obs = scen.vintage_and_active_years(ya_args=("foo", "bar", "2010")) + exp = pd.DataFrame({"year_vtg": (2010, 2010, 2010), "year_act": (2010, 2015, 2020)}) + assert_frame_equal(exp, obs) + + # tl_only=False + obs = scen.vintage_and_active_years(ya_args=("foo", "bar", "2010"), tl_only=False) + exp = _q(yvya_all, "year_vtg == 2010") + assert_frame_equal(exp, obs) + + # ya_args with 2 elements (no year_vtg) + obs = scen.vintage_and_active_years(ya_args=("foo", "bar")) + exp = _q(yvya_all, f"{fmy} <= year_act <= {y_max} and year_act - year_vtg < 20") + assert_frame_equal(exp, obs) + + +def test_vintage_and_active_years4(test_mp): + """Technology with 'gaps'. + + In this test, no ``technical_lifetime`` is designated for the 2020 and 2030 + vintages. The technology thus cannot be vintaged in these periods, or active in the + 2030 period, so these should not appear in the results. + """ + years = (2000, 2010, 2020, 2030, 2040, 2050, 2060) + fmy = years[1] + + # Set up scenario, tech, and retrieve valid (yv, ya) pairs + scen, yvya_all = _setup(test_mp, years, fmy) + + # Change the technical_lifetime of the technology + tl = "technical_lifetime" + data = scen.par(tl) + scen.remove_par(tl, data.query("year_vtg == 2020 or year_vtg == 2030")) + + obs = scen.vintage_and_active_years(("foo", "bar")) + assert 2030 not in obs["year_act"] + assert not any(y in obs["year_vtg"] for y in (2020, 2030))