Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

- `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.

## [0.20.0] - 2026-08-13 12:00:00

### Added
Expand Down
90 changes: 90 additions & 0 deletions ogcore/output_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions tests/test_output_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down