From 4d06c13a7a6b9d468594c3b0d50ce011febf5dbb Mon Sep 17 00:00:00 2001 From: arihantlodha-cmd Date: Sun, 9 Aug 2026 12:00:10 +0900 Subject: [PATCH] Add npv_table for net present value of flow-variable changes Closes #1131. Adds output_tables.npv_table, which builds a table of the net present value of the reform-minus-baseline change in flow variables (e.g. Y) over a horizon, evaluated at a list of discount rates. Values are un-stationarized by default so the NPV is taken over the actual level path, which is the economically meaningful object to discount; pass stationarized=True to use the stationarized model values instead. Adds a DataFrame test on the cached run output and an exactness test that checks the discounted sum against a hand calculation, plus a CHANGELOG entry. --- CHANGELOG.md | 5 +++ ogcore/output_tables.py | 90 +++++++++++++++++++++++++++++++++++++ tests/test_output_tables.py | 40 +++++++++++++++++ 3 files changed, 135 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a1ffa17..e1b117aa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `npv_table` in `output_tables.py` (Issue #1131): builds a table of the + net present value of the reform-minus-baseline change in flow variables + (e.g. `Y`) over a horizon, evaluated at a list of discount rates. Values + are un-stationarized by default so the NPV is taken over the actual + (trend-inclusive) level path. - Stall detection for the TPI outer loop (Issue #1177): when the best distance has not improved over the last `TPI_stall_window` iterations (default 50; 0 disables), `run_TPI` logs a diagnosis distinguishing a diff --git a/ogcore/output_tables.py b/ogcore/output_tables.py index 9f75ee69f..6b19df82b 100644 --- a/ogcore/output_tables.py +++ b/ogcore/output_tables.py @@ -207,6 +207,96 @@ def macro_table_SS( return table +def npv_table( + base_tpi, + base_params, + reform_tpi, + reform_params, + var_list=["Y"], + discount_rates=[0.01, 0.02, 0.03, 0.04, 0.06], + num_years=10, + stationarized=False, + start_year=DEFAULT_START_YEAR, + table_format=None, + path=None, +): + """ + Create a table of the net present value (NPV) of the change + (reform minus baseline) in flow variables over a horizon, computed + at several discount rates. + + For each variable the NPV is + + .. math:: + NPV = \\sum_{t=0}^{num\\_years-1} + \\frac{x^{reform}_{t} - x^{base}_{t}}{(1 + r)^{t}} + + where :math:`x_{t}` is the value of the variable in period `t` and + `r` is the discount rate. Values are un-stationarized by default so + the NPV is taken over the actual (trend-inclusive) level path, which + is the economically meaningful object to discount; pass + `stationarized=True` to discount the stationarized model values + instead. Results are in the same units as the variable (model + units); to express them in dollars, scale by the model's `factor`. + + Args: + base_tpi (dictionary): TPI output from baseline run + base_params (OG-Core Specifications class): baseline parameters + object + reform_tpi (dictionary): TPI output from reform run + reform_params (OG-Core Specifications class): reform parameters + object + var_list (list): names of variables to include in the table + discount_rates (list): annual discount rates to compute the NPV + at, each expressed as a decimal (e.g. 0.03 for 3%) + num_years (integer): number of years to include in the NPV sum + stationarized (bool): whether to use the stationarized model + values; if False (default) the variables are un-stationarized + before discounting + start_year (integer): first year of the NPV window + table_format (string): format to return table in: 'csv', 'tex', + 'excel', 'json', if None, a DataFrame is returned + path (string): path to save table to + + Returns: + table (various): table in DataFrame or string format or `None` + if saved to disk + + """ + assert reform_tpi is not None, ( + "npv_table computes the NPV of the reform-minus-baseline change, " + "so a reform run is required." + ) + assert isinstance(start_year, (int, np.integer)) + assert isinstance(num_years, (int, np.integer)) + assert num_years <= base_params.T + # Make sure both runs cover the same time period + assert base_params.start_year == reform_params.start_year + start_index = start_year - base_params.start_year + periods = np.arange(num_years) + # Difference in each variable over the NPV window, un-stationarized + # unless the caller asks for the stationarized values + diffs = {} + for v in var_list: + if stationarized: + base_v = base_tpi[v] + reform_v = reform_tpi[v] + else: + base_v = unstationarize_vars(v, base_tpi, base_params) + reform_v = unstationarize_vars(v, reform_tpi, reform_params) + diffs[v] = (reform_v - base_v)[start_index : start_index + num_years] + table_dict = {"Variable": [VAR_LABELS[v] for v in var_list]} + for r in discount_rates: + discount = (1 + r) ** periods + table_dict["{:.1%}".format(r)] = [ + (diffs[v] / discount).sum() for v in var_list + ] + table_df = pd.DataFrame.from_dict(table_dict, orient="columns") + table = save_return_table(table_df, table_format, path) + + return table + + def ineq_table( base_ss, base_params, diff --git a/tests/test_output_tables.py b/tests/test_output_tables.py index f342197ba..b535c6731 100644 --- a/tests/test_output_tables.py +++ b/tests/test_output_tables.py @@ -96,6 +96,46 @@ def test_macro_table_SS(): assert isinstance(df, pd.DataFrame) +def test_npv_table(): + df = output_tables.npv_table( + base_tpi, + base_params, + reform_tpi, + reform_params, + var_list=["Y", "C"], + num_years=10, + start_year=int(base_params.start_year), + ) + assert isinstance(df, pd.DataFrame) + # one row per variable, plus a "Variable" column and one column per + # discount rate (5 by default) + assert df.shape == (2, 6) + + +def test_npv_table_values(): + """NPV of the reform-minus-baseline change matches a hand calculation.""" + p = Specifications() + b_tpi = {"Y": np.zeros(p.T)} + r_tpi = {"Y": np.zeros(p.T)} + r_tpi["Y"][:3] = np.array([100.0, 110.0, 120.0]) + df = output_tables.npv_table( + b_tpi, + p, + r_tpi, + p, + var_list=["Y"], + discount_rates=[0.0, 0.1], + num_years=3, + stationarized=True, + start_year=int(p.start_year), + ) + # r = 0 is just the undiscounted sum of the change + assert np.isclose(df["0.0%"][0], 330.0) + # r = 0.1: 100 + 110 / 1.1 + 120 / 1.1**2 + expected = 100.0 + 110.0 / 1.1 + 120.0 / 1.1**2 + assert np.isclose(df["10.0%"][0], expected) + + def test_ineq_table(): df = output_tables.ineq_table(base_ss, base_params) assert isinstance(df, pd.DataFrame)