From dd1cdf166d13f3781f3ea4a3a69ca4c671ce1f54 Mon Sep 17 00:00:00 2001 From: SeaCelo Date: Tue, 14 Apr 2026 16:55:15 -0400 Subject: [PATCH 1/5] Port OG-ZAF PR #106 and #111: offline-safe Calibration and IMF SDMX refresh --- README.md | 4 +- docs/book/content/calibration/macro.md | 8 +- ogeth/calibrate.py | 150 ++++++----- ogeth/input_output.py | 38 ++- ogeth/macro_params.py | 222 +++++++++++++--- ogeth/ogeth_default_parameters.json | 4 +- tests/test_calibrate.py | 149 +++++++++++ tests/test_input_output.py | 74 ++++++ tests/test_macro_params.py | 350 +++++++++++++++++++++++-- 9 files changed, 855 insertions(+), 144 deletions(-) create mode 100644 tests/test_calibrate.py create mode 100644 tests/test_input_output.py diff --git a/README.md b/README.md index 0ce1553..d00ad0c 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,9 @@ Once the package is installed, one can adjust parameters in the OG-Core `Specifi from ogcore.parameters import Specifications from ogeth.calibrate import Calibration p = Specifications() -c = Calibration(p) +c = Calibration(p, update_from_api=True) updated_params = c.get_dict() -p.update_specifications({'initial_debt_ratio': updated_params['initial_debt_ratio']}) +p.update_specifications(updated_params) ``` ## Disclaimer diff --git a/docs/book/content/calibration/macro.md b/docs/book/content/calibration/macro.md index f84c2a8..1588c27 100644 --- a/docs/book/content/calibration/macro.md +++ b/docs/book/content/calibration/macro.md @@ -39,13 +39,17 @@ where $\tau_d$ is the scale parameter and $\mu_d$ is the level shift parameter. ### Aggregate transfers -Aggregate (non-Social Security) transfers to households are set as a share of GDP with the parameter $\alpha_T$. We exclude Social Security from transfers since it is modeled specifically. With this definition, the share of transfers to GDP in 2015 is 0.034 according to [IMF data](https://data.imf.org/en/Data-Explorer?datasetUrn=IMF.STA:GFS_SOO(12.0.0)&INDICATOR=G271_T). +Aggregate (non-Social Security) transfers to households are set as a share of GDP with the parameter $\alpha_T$. We exclude Social Security from transfers since it is modeled specifically. We compute this from the IMF GFS Statement of Operations data as +
$\alpha_T = (\texttt{G27\_T} - \texttt{G271\_T}) / 100$
+For Ethiopia, the available IMF GFS percent-of-GDP series for this calibration are published under the `Budgetary central government` sector (`S1311B`), so the OG-ETH calibration uses that sector rather than mechanically reusing another country repo's sector code. Using the 2024 IMF values for `G27_T` and `G271_T`, the default calibration sets $\alpha_T = 0.0$. ### Government expenditures Government spending on goods and services are also set as a share of GDP with the parameter $\alpha_G$. We define government spending as:
Government Spending = Total Outlays - Transfers - Net Interest on Debt - Social Security
-With this definition, the share of government expenditure to GDP is 0.095 based on [data from the IMF](https://data.imf.org/en/Data-Explorer?datasetUrn=IMF.RES:WEO(9.0.0)&INDICATOR=GGX). +In IMF GFS Statement of Operations terms, we compute +
$\alpha_G = (\texttt{G2\_T} - \texttt{G24\_T} - \texttt{G27\_T}) / 100$
+Using Ethiopia's 2024 `S1311B` IMF GFS percent-of-GDP series, the default calibration sets $\alpha_G = 0.0453$. (SecLWI_footnotes)= diff --git a/ogeth/calibrate.py b/ogeth/calibrate.py index 03ad3dd..75cad28 100644 --- a/ogeth/calibrate.py +++ b/ogeth/calibrate.py @@ -1,6 +1,7 @@ from ogeth import macro_params, income from ogeth import input_output as io import os +import warnings import numpy as np import datetime from ogcore import demographics @@ -16,7 +17,7 @@ def __init__( macro_data_end_year=datetime.datetime(2024, 12, 31), demographic_data_path=None, output_path=None, - update_from_api=True, # Set True to update from World Bank and UN APIs + update_from_api=False, # Set True to update from World Bank and UN APIs ): """ Constructor for the Calibration class. @@ -37,75 +38,98 @@ def __init__( if not os.path.exists(output_path): os.makedirs(output_path) - # Macro estimation - self.macro_params = macro_params.get_macro_params( - macro_data_start_year, - macro_data_end_year, - update_from_api=update_from_api, - ) - print("Calibrated macro parameters.") - print(self.macro_params) + # Only return successfully refreshed values; packaged defaults on p + # remain the baseline when update_from_api is False or a source fails. + self.macro_params = {} + self.demographic_params = {} + self.e = None + self.alpha_c = np.array([1.0]) if p.I == 1 else None + self.io_matrix = np.array([[1.0]]) if p.M == 1 else None + + if not update_from_api: + return + + try: + self.macro_params = macro_params.get_macro_params( + macro_data_start_year, + macro_data_end_year, + update_from_api=update_from_api, + ) + print("Calibrated macro parameters.") + print(self.macro_params) + except Exception as exc: + warnings.warn(f"Macro params update failed: {exc}", stacklevel=2) - # io matrix and alpha_c if p.I > 1: # no need if just one consumption good - alpha_c_dict = io.get_alpha_c() - # check that model dimensions are consistent with alpha_c - assert p.I == len(list(alpha_c_dict.keys())) - self.alpha_c = np.array(list(alpha_c_dict.values())) - else: - self.alpha_c = np.array([1.0]) + try: + alpha_c_dict = io.get_alpha_c() + # check that model dimensions are consistent with alpha_c + assert p.I == len(list(alpha_c_dict.keys())) + self.alpha_c = np.array(list(alpha_c_dict.values())) + except Exception as exc: + warnings.warn(f"alpha_c update failed: {exc}", stacklevel=2) if p.M > 1: # no need if just one production good - io_df = io.get_io_matrix() - # check that model dimensions are consistent with io_matrix - assert p.M == len(list(io_df.keys())) - self.io_matrix = io_df.values - else: - self.io_matrix = np.array([[1.0]]) + try: + io_df = io.get_io_matrix() + # check that model dimensions are consistent with io_matrix + assert p.M == len(list(io_df.keys())) + self.io_matrix = io_df.values + except Exception as exc: + warnings.warn(f"io_matrix update failed: {exc}", stacklevel=2) - # demographics - self.demographic_params = demographics.get_pop_objs( - p.E, - p.S, - p.T, - 0, - 99, - country_id="231", - initial_data_year=p.start_year - 1, - final_data_year=p.start_year + 1, - GraphDiag=False, - download_path=demographic_data_path, - ) + try: + self.demographic_params = demographics.get_pop_objs( + p.E, + p.S, + p.T, + 0, + 99, + country_id="231", + initial_data_year=p.start_year - 1, + final_data_year=p.start_year + 1, + GraphDiag=False, + download_path=demographic_data_path, + ) - # demographics for 80 period lives (needed for getting e below) - demog80 = demographics.get_pop_objs( - 20, - 80, - p.T, - 0, - 99, - country_id="231", - initial_data_year=p.start_year - 1, - final_data_year=p.start_year + 1, - GraphDiag=False, - ) + # demographics for 80 period lives (needed for getting e below) + demog80 = demographics.get_pop_objs( + 20, + 80, + p.T, + 0, + 99, + country_id="231", + initial_data_year=p.start_year - 1, + final_data_year=p.start_year + 1, + GraphDiag=False, + ) - # earnings profiles - self.e = income.get_e_interp( - p.E, - p.S, - p.J, - p.lambdas, - demog80["omega_SS"], - plot_path=output_path, - ) + # earnings profiles + self.e = income.get_e_interp( + p.E, + p.S, + p.J, + p.lambdas, + demog80["omega_SS"], + plot_path=output_path, + ) + except Exception as exc: + warnings.warn( + f"Demographics/income update failed: {exc}", stacklevel=2 + ) + self.demographic_params = {} + self.e = None # method to return all newly calibrated parameters in a dictionary def get_dict(self): - dict = {} - dict.update(self.macro_params) - dict["e"] = self.e - dict["alpha_c"] = self.alpha_c - dict["io_matrix"] = self.io_matrix - dict.update(self.demographic_params) + d = {} + d.update(self.macro_params) + d.update(self.demographic_params) + if self.e is not None: + d["e"] = self.e + if self.alpha_c is not None: + d["alpha_c"] = self.alpha_c + if self.io_matrix is not None: + d["io_matrix"] = self.io_matrix - return dict + return d diff --git a/ogeth/input_output.py b/ogeth/input_output.py index 9b603dd..6b4b94b 100644 --- a/ogeth/input_output.py +++ b/ogeth/input_output.py @@ -5,18 +5,26 @@ CUR_DIR = os.path.dirname(os.path.realpath(__file__)) -""" -Read in Social Accounting Matrix (SAM) file -""" -# Read in SAM file -# SAM file: sam_path = os.path.join(CUR_DIR, "data", "IFPRI_SAM_ETH_2022_SAM.csv") -SAM = pd.read_csv(sam_path, index_col=1, thousands=",") -# replace NaN with 0 -SAM.fillna(0, inplace=True) -def get_alpha_c(sam=SAM, cons_dict=CONS_DICT): +def read_SAM(): + """ + Read the packaged Ethiopia SAM file. + + Returns: + pd.DataFrame | None: parsed SAM table, or None if unavailable + """ + try: + sam = pd.read_csv(sam_path, index_col=1, thousands=",") + sam.fillna(0, inplace=True) + return sam + except Exception as exc: + print(f"Failed to read packaged SAM file: {exc}") + return None + + +def get_alpha_c(sam=None, cons_dict=CONS_DICT): """ Calibrate the alpha_c vector, showing the shares of household expenditures for each consumption category @@ -28,6 +36,11 @@ def get_alpha_c(sam=SAM, cons_dict=CONS_DICT): Returns: alpha_c (dict): Dictionary of shares of household expenditures """ + if sam is None: + sam = read_SAM() + if sam is None: + raise RuntimeError("SAM data is unavailable. Cannot compute alpha_c.") + hh_cols = [ "hhd-r1", "hhd-r2", @@ -55,7 +68,7 @@ def get_alpha_c(sam=SAM, cons_dict=CONS_DICT): return alpha_c -def get_io_matrix(sam=SAM, cons_dict=CONS_DICT, prod_dict=PROD_DICT): +def get_io_matrix(sam=None, cons_dict=CONS_DICT, prod_dict=PROD_DICT): """ Calibrate the io_matrix array. This array relates the share of each production category in each consumption category @@ -68,6 +81,11 @@ def get_io_matrix(sam=SAM, cons_dict=CONS_DICT, prod_dict=PROD_DICT): Returns: io_df (pd.DataFrame): Dataframe of io_matrix """ + if sam is None: + sam = read_SAM() + if sam is None: + raise RuntimeError("SAM data is unavailable. Cannot compute io_matrix.") + # Create initial matrix as dataframe of 0's to fill in io_dict = {} for key in prod_dict.keys(): diff --git a/ogeth/macro_params.py b/ogeth/macro_params.py index c9ec9e9..6a638ec 100644 --- a/ogeth/macro_params.py +++ b/ogeth/macro_params.py @@ -10,8 +10,139 @@ import numpy as np import requests import datetime -import statsmodels.api as sm from io import StringIO +from pathlib import Path + + +# IMF GFS coverage differs by country. For Ethiopia, the percent-of-GDP +# Statement of Operations series used for alpha_T and alpha_G are published +# under budgetary central government (S1311B), not the broader S1311 sector. +IMF_GFS_SECTOR_BY_COUNTRY = {"ETH": "S1311B"} + + +def _get_imf_gfs_sector(country_iso): + """ + Return the IMF GFS sector code to use for a country's alpha queries. + """ + return IMF_GFS_SECTOR_BY_COUNTRY.get(country_iso.upper(), "S1311") + + +def _get_imf_macro_params(country_iso, target_year, data_path=None): + """ + Fetch IMF GFS data and compute alpha_T and alpha_G. + + Args: + country_iso (str): ISO alpha-3 country code + target_year (int): preferred calibration year + data_path (str | Path | None): optional path to save IMF CSV data + + Returns: + dict: IMF-derived macro parameters + """ + sector = _get_imf_gfs_sector(country_iso) + required_indicators = {"G2_T", "G24_T", "G27_T", "G271_T"} + data_path = Path(data_path) if data_path is not None else None + response = requests.get( + ( + "https://api.imf.org/external/sdmx/3.0/data/dataflow/" + f"IMF.STA/GFS_SOO/12.0.0/{country_iso}.{sector}.G2M.*.POGDP_PT.A" + ), + timeout=30, + ) + response.raise_for_status() + try: + payload = response.json() + data = payload["data"] + structure = data["structures"][0] + data_set = data["dataSets"][0] + series_dimensions = structure["dimensions"]["series"] + observation_years = [ + value.get("id", value.get("value")) + for value in structure["dimensions"]["observation"][0]["values"] + ] + except (ValueError, KeyError, IndexError, TypeError) as exc: + raise ValueError( + "Empty or malformed IMF response for GFS_SOO" + ) from exc + + records = [] + for series_key, series in data_set["series"].items(): + dimension_indexes = [int(idx) for idx in series_key.split(":")] + labels = { + dim["id"]: dim["values"][idx].get( + "id", dim["values"][idx].get("value") + ) + for dim, idx in zip(series_dimensions, dimension_indexes) + } + indicator = labels.get("INDICATOR") + if indicator not in required_indicators: + continue + for observation_key, observation in series.get( + "observations", {} + ).items(): + value = observation[0] + records.append( + { + "year": observation_years[int(observation_key)], + "indicator": indicator, + "value": value, + "country_iso": country_iso, + "sector": sector, + "dataset": "IMF.STA:GFS_SOO(12.0.0)", + } + ) + + imf_data = pd.DataFrame(records) + if imf_data.empty: + raise ValueError("Empty or malformed IMF response for GFS_SOO") + + imf_data["year"] = pd.to_numeric(imf_data["year"], errors="coerce") + imf_data["value"] = pd.to_numeric(imf_data["value"], errors="coerce") + imf_data = imf_data.dropna(subset=["year", "value"]) + + if data_path is not None: + data_path.parent.mkdir(parents=True, exist_ok=True) + imf_data.sort_values(["indicator", "year"]).to_csv( + data_path, index=False + ) + print(f"IMF data saved to {data_path}") + + available = ( + imf_data.pivot_table( + index="year", + columns="indicator", + values="value", + aggfunc="first", + ) + .sort_index() + .dropna(subset=sorted(required_indicators)) + ) + available = available.loc[available.index <= int(target_year)] + + if available.empty: + raise ValueError( + "No complete IMF data available for " + f"{country_iso} sector {sector} up to {target_year}" + ) + + selected_year = ( + int(target_year) + if int(target_year) in available.index + else int(available.index.max()) + ) + if selected_year != int(target_year): + print( + f"Warning: No IMF data for {target_year}. " + f"Using last available year: {selected_year}" + ) + + values = available.loc[selected_year] + return { + "alpha_T": [(values["G27_T"] - values["G271_T"]) / 100], + "alpha_G": [ + (values["G2_T"] - values["G24_T"] - values["G27_T"]) / 100 + ], + } def get_macro_params( @@ -19,6 +150,8 @@ def get_macro_params( data_end_date=datetime.datetime(2024, 12, 31), country_iso="ETH", update_from_api=False, + imf_data_year=None, + imf_data_path=None, ): """ Compute values of parameters that are derived from macro data @@ -27,6 +160,9 @@ def get_macro_params( data_start_date (datetime): start date for data data_end_date (datetime): end date for data country_iso (str): ISO code for country + imf_data_year (int | None): IMF target year override. Defaults to + data_end_date.year when None. + imf_data_path (str | Path | None): optional path to save IMF CSV data Returns: macro_parameters (dict): dictionary of parameter values @@ -79,7 +215,7 @@ def get_macro_params( print( f"g_y_annual updated from World Bank API: {macro_parameters['g_y_annual']}" ) - except: + except Exception: print("Failed to retrieve data from World Bank") print("Will not update the following parameters:") print( @@ -137,7 +273,7 @@ def get_macro_params( print( f"gamma updated from ILOSTAT API: {macro_parameters['gamma']}" ) - except: + except Exception: print("Failed to retrieve data from ILOSTAT") print("Will not update gamma") else: @@ -148,19 +284,26 @@ def get_macro_params( """ if update_from_api: - # alpha_T, non-social security transfers (grants, subsidies, and other transfers) as a fraction of GDP - # source: IMF GFS (12.0.0), indicator G271_T, Budgetary central government - # source link: https://data.imf.org/en/Data-Explorer?datasetUrn=IMF.STA:GFS_SOO(12.0.0)&INDICATOR=G271_T - # 2023 = 3.38% of GDP - macro_parameters["alpha_T"] = [ - 0.034 + 0.016 - ] # including social benefits of 1.6% of GDP - - # alpha_G, total government expenditure as a fraction of GDP - # source: IMF WEO (9.0.0), indicator GGX, General government expenditure (% of GDP) - # source link: https://data.imf.org/en/Data-Explorer?datasetUrn=IMF.RES:WEO(9.0.0)&INDICATOR=GGX - # 2024 = 9.538% of GDP - macro_parameters["alpha_G"] = [0.095] + try: + imf_year = ( + data_end_date.year if imf_data_year is None else imf_data_year + ) + macro_parameters.update( + _get_imf_macro_params( + country_iso, + imf_year, + data_path=imf_data_path, + ) + ) + print( + f"alpha_T updated from IMF data: {macro_parameters['alpha_T']}" + ) + print( + f"alpha_G updated from IMF data: {macro_parameters['alpha_G']}" + ) + except Exception: + print("Failed to retrieve data from IMF") + print("Will not update alpha_T, alpha_G") # initial_debt_ratio, gross general government debt as a fraction of GDP # source: from the IMF WEO, Series ETH.GGXWDG_NGDP.A — Gross general government debt (% of GDP). @@ -195,28 +338,31 @@ def get_macro_params( as the dependent variable """ - # # estimate r_gov_shift and r_gov_scale - sov_y = np.arange(20, 120) / 10 - corp_yhat = 8.199 - (2.975 * sov_y) + (0.478 * sov_y**2) - corp_yhat = sm.add_constant(corp_yhat) - mod = sm.OLS( - sov_y, - corp_yhat, - ) - res = mod.fit() - # First term is the constant and needs to be divided by 100 to have - # the correct unit. Second term is the coefficient - macro_parameters["r_gov_shift"] = [-res.params[0] / 100] - macro_parameters["r_gov_scale"] = [res.params[1]] - # Report new values - print(f"alpha_T updated from IMF data: {macro_parameters['alpha_T']}") - print(f"alpha_G updated from IMF data: {macro_parameters['alpha_G']}") - print( - f"r_gov_shift updated from IMF data: {macro_parameters['r_gov_shift']}" - ) - print( - f"r_gov_scale updated from IMF data: {macro_parameters['r_gov_scale']}" - ) + try: + import statsmodels.api as sm + + # # estimate r_gov_shift and r_gov_scale + sov_y = np.arange(20, 120) / 10 + corp_yhat = 8.199 - (2.975 * sov_y) + (0.478 * sov_y**2) + corp_yhat = sm.add_constant(corp_yhat) + mod = sm.OLS( + sov_y, + corp_yhat, + ) + res = mod.fit() + # First term is the constant and needs to be divided by 100 to have + # the correct unit. Second term is the coefficient + macro_parameters["r_gov_shift"] = [-res.params[0] / 100] + macro_parameters["r_gov_scale"] = [res.params[1]] + print( + f"r_gov_shift updated from IMF data: {macro_parameters['r_gov_shift']}" + ) + print( + f"r_gov_scale updated from IMF data: {macro_parameters['r_gov_scale']}" + ) + except Exception: + print("Failed to compute r_gov_shift, r_gov_scale") + print("Will not update r_gov_shift, r_gov_scale") else: print("Not updating alpha_T, alpha_G, r_gov_shift, r_gov_scale") diff --git a/ogeth/ogeth_default_parameters.json b/ogeth/ogeth_default_parameters.json index 3036e19..5ad048e 100644 --- a/ogeth/ogeth_default_parameters.json +++ b/ogeth/ogeth_default_parameters.json @@ -1187,10 +1187,10 @@ "tG1": 20, "tG2": 256, "alpha_T": [ - 0.05 + 0.0 ], "alpha_G": [ - 0.095 + 0.04525629819754754 ], "alpha_I": [ 0.0 diff --git a/tests/test_calibrate.py b/tests/test_calibrate.py new file mode 100644 index 0000000..fa634ad --- /dev/null +++ b/tests/test_calibrate.py @@ -0,0 +1,149 @@ +""" +Tests of calibrate.py module +""" + +import warnings +from unittest.mock import MagicMock, patch + +import numpy as np + +from ogeth.calibrate import Calibration + + +def _make_mock_p(I=1, M=1): + """ + Create a minimal mock Specifications object. + """ + p = MagicMock() + p.I = I + p.M = M + p.E = 20 + p.S = 80 + p.J = 7 + p.T = 160 + p.start_year = 2025 + p.lambdas = np.array([0.25, 0.25, 0.2, 0.1, 0.1, 0.09, 0.01]) + return p + + +class TestOfflineMode: + """ + Tests for update_from_api=False. + """ + + def test_single_sector_returns_identity_values(self): + p = _make_mock_p(I=1, M=1) + c = Calibration(p, update_from_api=False) + + d = c.get_dict() + assert "alpha_c" in d + assert "io_matrix" in d + np.testing.assert_array_equal(d["alpha_c"], np.array([1.0])) + np.testing.assert_array_equal(d["io_matrix"], np.array([[1.0]])) + + def test_single_sector_omits_macro_and_demographics(self): + p = _make_mock_p(I=1, M=1) + c = Calibration(p, update_from_api=False) + + d = c.get_dict() + assert "g_y_annual" not in d + assert "initial_debt_ratio" not in d + assert "e" not in d + assert "omega_SS" not in d + + def test_multisector_offline_returns_empty_dict(self): + p = _make_mock_p(I=5, M=4) + c = Calibration(p, update_from_api=False) + + assert c.alpha_c is None + assert c.io_matrix is None + assert c.get_dict() == {} + + @patch("ogeth.calibrate.macro_params") + @patch("ogeth.calibrate.io") + @patch("ogeth.calibrate.demographics") + @patch("ogeth.calibrate.income") + def test_offline_mode_makes_no_refresh_calls( + self, mock_income, mock_demog, mock_io, mock_macro + ): + p = _make_mock_p(I=5, M=4) + Calibration(p, update_from_api=False) + + mock_macro.get_macro_params.assert_not_called() + mock_io.get_alpha_c.assert_not_called() + mock_io.get_io_matrix.assert_not_called() + mock_demog.get_pop_objs.assert_not_called() + mock_income.get_e_interp.assert_not_called() + + +class TestOnlinePartialFailure: + """ + Tests for update_from_api=True with partial failures. + """ + + @patch("ogeth.calibrate.macro_params") + @patch("ogeth.calibrate.demographics") + def test_macro_failure_warns_and_omits(self, mock_demog, mock_macro): + mock_macro.get_macro_params.side_effect = RuntimeError("API down") + mock_demog.get_pop_objs.side_effect = RuntimeError("skip") + + p = _make_mock_p(I=1, M=1) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + c = Calibration(p, update_from_api=True) + + assert any("Macro params update failed" in str(w.message) for w in caught) + d = c.get_dict() + assert "g_y_annual" not in d + assert "initial_debt_ratio" not in d + assert "alpha_c" in d + assert "io_matrix" in d + + @patch("ogeth.calibrate.io") + @patch("ogeth.calibrate.macro_params") + @patch("ogeth.calibrate.demographics") + def test_sam_failure_warns_and_omits( + self, mock_demog, mock_macro, mock_io + ): + mock_macro.get_macro_params.return_value = {"g_y_annual": 0.01} + mock_io.get_alpha_c.side_effect = RuntimeError("SAM unavailable") + mock_io.get_io_matrix.side_effect = RuntimeError("SAM unavailable") + mock_demog.get_pop_objs.side_effect = RuntimeError("skip") + + p = _make_mock_p(I=5, M=4) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + c = Calibration(p, update_from_api=True) + + assert any("alpha_c update failed" in str(w.message) for w in caught) + assert any("io_matrix update failed" in str(w.message) for w in caught) + d = c.get_dict() + assert d["g_y_annual"] == 0.01 + assert "alpha_c" not in d + assert "io_matrix" not in d + + @patch("ogeth.calibrate.income") + @patch("ogeth.calibrate.macro_params") + @patch("ogeth.calibrate.demographics") + def test_demographics_failure_warns_and_omits( + self, mock_demog, mock_macro, mock_income + ): + mock_macro.get_macro_params.return_value = {"g_y_annual": 0.01} + mock_demog.get_pop_objs.side_effect = RuntimeError("UN API down") + mock_income.get_e_interp.return_value = np.ones((80, 7)) + + p = _make_mock_p(I=1, M=1) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + c = Calibration(p, update_from_api=True) + + assert any( + "Demographics/income update failed" in str(w.message) + for w in caught + ) + d = c.get_dict() + assert d["g_y_annual"] == 0.01 + assert "e" not in d + assert "omega_SS" not in d + assert "alpha_c" in d + assert "io_matrix" in d diff --git a/tests/test_input_output.py b/tests/test_input_output.py new file mode 100644 index 0000000..eac3782 --- /dev/null +++ b/tests/test_input_output.py @@ -0,0 +1,74 @@ +""" +Tests of input_output.py module +""" + +from unittest.mock import patch + +import pandas as pd +import pytest + +from ogeth import input_output as io + + +HH_COLS = [ + "hhd-r1", + "hhd-r2", + "hhd-r3", + "hhd-r4", + "hhd-r5", + "hhd-u1", + "hhd-u2", + "hhd-u3", + "hhd-u4", + "hhd-u5", +] + + +@pytest.fixture +def sam_df(): + data = {col: [0.0, 0.0] for col in HH_COLS} + data["hhd-r1"] = [10.0, 30.0] + data["hhd-u1"] = [20.0, 40.0] + data["A"] = [2.0, 1.0] + data["B"] = [1.0, 3.0] + return pd.DataFrame(data, index=["beer", "car"]) + + +def test_get_alpha_c(sam_df): + cons_dict = {"Food": ["beer"], "Non-food": ["car"]} + + test_dict = io.get_alpha_c(sam=sam_df, cons_dict=cons_dict) + + assert isinstance(test_dict, dict) + assert sorted(test_dict.keys()) == sorted(["Food", "Non-food"]) + assert test_dict["Food"] == pytest.approx(0.3) + assert test_dict["Non-food"] == pytest.approx(0.7) + + +def test_get_io_matrix(sam_df): + cons_dict = {"Food": ["beer"], "Non-food": ["car"]} + prod_dict = {"Primary": ["A"], "Secondary": ["B"]} + + test_df = io.get_io_matrix( + sam=sam_df, cons_dict=cons_dict, prod_dict=prod_dict + ) + + assert isinstance(test_df, pd.DataFrame) + assert sorted(test_df.columns) == sorted(["Primary", "Secondary"]) + assert sorted(test_df.index) == sorted(["Food", "Non-food"]) + assert test_df.loc["Food", "Primary"] == pytest.approx(2 / 3) + assert test_df.loc["Food", "Secondary"] == pytest.approx(1 / 3) + assert test_df.loc["Non-food", "Primary"] == pytest.approx(1 / 4) + assert test_df.loc["Non-food", "Secondary"] == pytest.approx(3 / 4) + + +@patch("ogeth.input_output.read_SAM", return_value=None) +def test_get_alpha_c_raises_on_none_sam(mock_read_sam): + with pytest.raises(RuntimeError, match="Cannot compute alpha_c"): + io.get_alpha_c() + + +@patch("ogeth.input_output.read_SAM", return_value=None) +def test_get_io_matrix_raises_on_none_sam(mock_read_sam): + with pytest.raises(RuntimeError, match="Cannot compute io_matrix"): + io.get_io_matrix() diff --git a/tests/test_macro_params.py b/tests/test_macro_params.py index 1bab6dd..1d8689f 100644 --- a/tests/test_macro_params.py +++ b/tests/test_macro_params.py @@ -2,36 +2,332 @@ Tests of macro_params.py module """ +import datetime +import sys +import types + +import pandas as pd import pytest +import requests + from ogeth import macro_params -@pytest.mark.parametrize( - "update_from_api", - [True, False], - ids=["update_from_api=True", "update_from_api=False"], -) -def test_get_macro_params(update_from_api): - test_dict = macro_params.get_macro_params(update_from_api=update_from_api) +class MockResponse: + """ + Minimal mock response for requests.get(). + """ - assert isinstance(test_dict, dict) - if update_from_api: - assert ( - list(test_dict.keys()).sort() - == [ - "r_gov_shift", - "r_gov_scale", - "alpha_T", - "alpha_G", - "initial_debt_ratio", - "g_y_annual", - "gamma", - "zeta_D", - "initial_foreign_debt_ratio", - ].sort() - ) - else: - assert ( - list(test_dict.keys()).sort() - == ["r_gov_shift", "r_gov_scale"].sort() + def __init__(self, *, json_data=None, text="", status_code=200): + self._json_data = json_data + self.text = text + self.status_code = status_code + + def json(self): + if isinstance(self._json_data, Exception): + raise self._json_data + return self._json_data + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.HTTPError( + f"HTTP {self.status_code} returned from mocked request" + ) + + +def _imf_payload(indicator_year_values, country="ETH", sector="S1311B"): + years = sorted( + { + int(year) + for observations in indicator_year_values.values() + for year in observations.keys() + } + ) + indicators = list(indicator_year_values.keys()) + return { + "meta": {}, + "data": { + "dataSets": [ + { + "structure": 0, + "action": "Replace", + "series": { + f"0:0:0:{indicator_idx}:0:0": { + "attributes": [0, None, 0], + "observations": { + str(years.index(int(year))): [value] + for year, value in observations.items() + }, + } + for indicator_idx, observations in enumerate( + indicator_year_values.values() + ) + }, + } + ], + "structures": [ + { + "dimensions": { + "series": [ + {"id": "COUNTRY", "values": [{"id": country}]}, + {"id": "SECTOR", "values": [{"id": sector}]}, + {"id": "GFS_GRP", "values": [{"id": "G2M"}]}, + { + "id": "INDICATOR", + "values": [ + {"id": indicator} + for indicator in indicators + ], + }, + { + "id": "TYPE_OF_TRANSFORMATION", + "values": [{"id": "POGDP_PT"}], + }, + {"id": "FREQUENCY", "values": [{"id": "A"}]}, + ], + "observation": [ + { + "id": "TIME_PERIOD", + "values": [ + {"value": str(year)} for year in years + ], + } + ], + }, + "attributes": { + "series": [ + { + "id": "SCALE", + "values": [{"id": "0"}], + }, + {"id": "DECIMALS_DISPLAYED", "values": []}, + {"id": "OVERLAP", "values": [{"id": "OL"}]}, + ], + "observation": [ + {"id": "PRECISION", "values": []}, + { + "id": "DERIVATION_TYPE", + "values": [{"id": "O"}], + }, + {"id": "STATUS", "values": []}, + {"id": "NATURE_OF_DATA", "values": []}, + { + "id": "BASES_OF_RECORDING_CASH_NON_CASH", + "values": [], + }, + { + "id": "BASES_OF_RECORDING_GROSS_NET", + "values": [{"id": "NP"}], + }, + {"id": "VALUATION", "values": [{"id": "_Z"}]}, + ], + }, + } + ], + }, + } + + +def _mock_wb_download(monkeypatch): + def fake_download(indicator, country, start, end): + indicator_codes = list(indicator) + assert indicator_codes == ["NY.GDP.PCAP.KD"] + return pd.DataFrame( + {"NY.GDP.PCAP.KD": [100.0, 80.0, 64.0]}, + index=["2024", "2023", "2022"], ) + + monkeypatch.setattr(macro_params.wb, "download", fake_download) + + +def _mock_requests_get( + monkeypatch, + requested_urls, + *, + ilo_text=None, + imf_json=None, +): + def fake_get(url, params=None, headers=None, timeout=None): + requested_urls.append(url) + if "rplumber.ilo.org" in url: + return MockResponse( + text=ilo_text or "time,obs_value\n2024,38.209\n2023,38.0\n" + ) + if "api.imf.org" in url: + return MockResponse( + json_data=imf_json + or _imf_payload( + { + "G2_T": {2023: 6.121621434173129, 2024: 5.117707506327274}, + "G24_T": { + 2023: 0.5526694185117545, + 2024: 0.5920776865725198, + }, + "G27_T": {2023: 0.0, 2024: 0.0}, + "G271_T": {2023: 0.0, 2024: 0.0}, + } + ) + ) + raise AssertionError(f"Unexpected URL requested in test: {url}") + + monkeypatch.setattr(macro_params.requests, "get", fake_get) + + +def _mock_statsmodels(monkeypatch, params=None): + fake_statsmodels = types.ModuleType("statsmodels") + fake_api = types.ModuleType("statsmodels.api") + + def add_constant(values): + return values + + class OLS: + def __init__(self, endog, exog): + self.endog = endog + self.exog = exog + + def fit(self): + result = types.SimpleNamespace() + result.params = params or [3.376625043803517, 0.24484763593657818] + return result + + fake_api.add_constant = add_constant + fake_api.OLS = OLS + fake_statsmodels.api = fake_api + monkeypatch.setitem(sys.modules, "statsmodels", fake_statsmodels) + monkeypatch.setitem(sys.modules, "statsmodels.api", fake_api) + + +def test_get_macro_params_update_from_api_false_returns_empty_dict(): + test_dict = macro_params.get_macro_params(update_from_api=False) + + assert isinstance(test_dict, dict) + assert test_dict == {} + + +def test_get_macro_params_update_from_api_true(monkeypatch): + requested_urls = [] + _mock_statsmodels(monkeypatch) + _mock_wb_download(monkeypatch) + _mock_requests_get(monkeypatch, requested_urls) + + test_dict = macro_params.get_macro_params(update_from_api=True) + + assert isinstance(test_dict, dict) + assert sorted(test_dict.keys()) == sorted( + [ + "r_gov_shift", + "r_gov_scale", + "alpha_T", + "alpha_G", + "initial_debt_ratio", + "g_y_annual", + "gamma", + "zeta_D", + "initial_foreign_debt_ratio", + ] + ) + assert test_dict["initial_debt_ratio"] == 0.327 + assert test_dict["initial_foreign_debt_ratio"] == 0.42 + assert test_dict["zeta_D"] == [0.12] + assert test_dict["g_y_annual"] == pytest.approx(0.25) + assert test_dict["gamma"] == [pytest.approx(0.61791)] + assert test_dict["alpha_T"] == [pytest.approx(0.0)] + assert test_dict["alpha_G"] == [pytest.approx(0.04525629819754754)] + assert test_dict["r_gov_shift"] == [pytest.approx(-0.03376625043803517)] + assert test_dict["r_gov_scale"] == [pytest.approx(0.24484763593657818)] + assert any(".ETH.S1311B.G2M." in url or "/ETH.S1311B.G2M." in url for url in requested_urls) + + +def test_get_imf_macro_params_uses_eth_budgetary_sector(monkeypatch): + requested_urls = [] + _mock_requests_get(monkeypatch, requested_urls) + + result = macro_params._get_imf_macro_params("ETH", 2024) + + assert result["alpha_T"] == [pytest.approx(0.0)] + assert result["alpha_G"] == [pytest.approx(0.04525629819754754)] + assert any("/ETH.S1311B.G2M.*.POGDP_PT.A" in url for url in requested_urls) + + +def test_get_imf_macro_params_overwrites_saved_file(monkeypatch, tmp_path): + requested_urls = [] + _mock_requests_get(monkeypatch, requested_urls) + + data_file = tmp_path / "imf_gfs_soo_eth_s1311b_g2m_pogdp_pt_a.csv" + result = macro_params._get_imf_macro_params( + "ETH", 2024, data_path=data_file + ) + + assert result["alpha_G"] == [pytest.approx(0.04525629819754754)] + assert data_file.exists() + + requested_urls.clear() + _mock_requests_get( + monkeypatch, + requested_urls, + imf_json=_imf_payload( + { + "G2_T": {2024: 5.0}, + "G24_T": {2024: 0.5}, + "G27_T": {2024: 0.0}, + "G271_T": {2024: 0.0}, + } + ), + ) + + refreshed = macro_params._get_imf_macro_params( + "ETH", 2024, data_path=data_file + ) + + assert refreshed != result + saved_data = pd.read_csv(data_file) + saved_2024 = saved_data[saved_data["year"] == 2024].set_index("indicator") + assert saved_2024.loc["G2_T", "value"] == pytest.approx(5.0) + assert saved_2024.loc["G24_T", "value"] == pytest.approx(0.5) + + +def test_get_imf_macro_params_falls_back_to_last_available_year(monkeypatch): + requested_urls = [] + _mock_requests_get( + monkeypatch, + requested_urls, + imf_json=_imf_payload( + { + "G2_T": {2024: 5.117707506327274}, + "G24_T": {2024: 0.5920776865725198}, + "G27_T": {2024: 0.0}, + "G271_T": {2024: 0.0}, + } + ), + ) + + result = macro_params._get_imf_macro_params("ETH", 2025) + + assert result["alpha_T"] == [pytest.approx(0.0)] + assert result["alpha_G"] == [pytest.approx(0.04525629819754754)] + + +def test_get_macro_params_passes_imf_year_override(monkeypatch): + requested_urls = [] + _mock_statsmodels(monkeypatch) + _mock_wb_download(monkeypatch) + _mock_requests_get( + monkeypatch, + requested_urls, + imf_json=_imf_payload( + { + "G2_T": {2023: 6.121621434173129}, + "G24_T": {2023: 0.5526694185117545}, + "G27_T": {2023: 0.0}, + "G271_T": {2023: 0.0}, + } + ), + ) + + test_dict = macro_params.get_macro_params( + update_from_api=True, + imf_data_year=2023, + data_end_date=datetime.datetime(2024, 12, 31), + ) + + assert test_dict["alpha_G"] == [pytest.approx(0.05568952015661374)] From 98f3071397b960ac347fd69dfa7125722da7b393 Mon Sep 17 00:00:00 2001 From: SeaCelo Date: Sat, 18 Apr 2026 20:45:12 -0400 Subject: [PATCH 2/5] Port OG-ZAF #103: remove pandas-datareader, direct World Bank requests --- .github/workflows/build_and_test.yml | 29 +++++++++ environment.yml | 1 - ogeth/input_output.py | 4 +- ogeth/macro_params.py | 96 ++++++++++++++++++++++++---- setup.py | 1 - tests/test_calibrate.py | 4 +- tests/test_import_smoke.py | 13 ++++ tests/test_macro_params.py | 50 +++++++++++---- 8 files changed, 169 insertions(+), 29 deletions(-) create mode 100644 tests/test_import_smoke.py diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 073c201..d13ce88 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -61,3 +61,32 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true verbose: true + pip-import-smoke: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package and pytest + run: | + python -m pip install --upgrade pip + python -m pip install . pytest + + - name: Run import smoke tests outside checkout + shell: bash + run: | + tmpdir="$(mktemp -d)" + cp "${{ github.workspace }}/tests/test_import_smoke.py" "$tmpdir/test_import_smoke.py" + cd "$tmpdir" + python -m pytest "$tmpdir/test_import_smoke.py" diff --git a/environment.yml b/environment.yml index c10d0d0..12dfdf5 100644 --- a/environment.yml +++ b/environment.yml @@ -36,7 +36,6 @@ dependencies: - pip - pip: - openpyxl>=3.1.2 - - pandas-datareader - linecheck - ogcore>=0.14.11 - sphinx-exercise diff --git a/ogeth/input_output.py b/ogeth/input_output.py index 6b4b94b..d30b78f 100644 --- a/ogeth/input_output.py +++ b/ogeth/input_output.py @@ -84,7 +84,9 @@ def get_io_matrix(sam=None, cons_dict=CONS_DICT, prod_dict=PROD_DICT): if sam is None: sam = read_SAM() if sam is None: - raise RuntimeError("SAM data is unavailable. Cannot compute io_matrix.") + raise RuntimeError( + "SAM data is unavailable. Cannot compute io_matrix." + ) # Create initial matrix as dataframe of 0's to fill in io_dict = {} diff --git a/ogeth/macro_params.py b/ogeth/macro_params.py index 6a638ec..51fb341 100644 --- a/ogeth/macro_params.py +++ b/ogeth/macro_params.py @@ -5,7 +5,6 @@ """ # imports -from pandas_datareader import wb import pandas as pd import numpy as np import requests @@ -14,6 +13,83 @@ from pathlib import Path +def _fetch_wb_data(indicators, country_iso, start_year, end_year, source): + """ + Fetch a set of World Bank indicators and return a single DataFrame. + + Args: + indicators (dict): mapping of human-readable labels to indicator codes + country_iso (str): ISO country code + start_year (int): first year to request + end_year (int): last year to request + source (int): World Bank source ID + + Returns: + pandas.DataFrame: DataFrame indexed by year/quarter label + """ + if source == 2: + date_range = f"{start_year}:{end_year}" + elif source == 20: + date_range = f"{start_year}Q1:{end_year}Q4" + else: + raise ValueError(f"Unsupported World Bank source: {source}") + + data_frames = [] + for label, indicator_code in indicators.items(): + response = requests.get( + ( + "https://api.worldbank.org/v2/country/" + f"{country_iso}/indicator/{indicator_code}" + ), + params={ + "date": date_range, + "source": source, + "format": "json", + "per_page": 10000, + }, + ) + response.raise_for_status() + try: + payload = response.json() + except ValueError as exc: + raise ValueError( + f"Malformed World Bank response for {indicator_code}" + ) from exc + + if ( + not isinstance(payload, list) + or len(payload) < 2 + or not isinstance(payload[1], list) + or not payload[1] + ): + raise ValueError( + f"Empty or malformed World Bank response for {indicator_code}" + ) + + series_data = {} + for row in payload[1]: + date = row.get("date") + if date is None: + continue + series_data[date] = row.get("value") + + if not series_data: + raise ValueError( + f"No dated observations in World Bank response for " + f"{indicator_code}" + ) + + series = pd.Series(series_data, name=label) + series = pd.to_numeric(series, errors="coerce") + data_frames.append(series.to_frame()) + + data = pd.concat(data_frames, axis=1) + data.index.name = "year" + # Preserve descending time order used by the existing pct_change(-1) logic. + data = data.sort_index(ascending=False) + return data + + # IMF GFS coverage differs by country. For Ethiopia, the percent-of-GDP # Statement of Operations series used for alpha_T and alpha_G are published # under budgetary central government (S1311B), not the broader S1311 sector. @@ -184,17 +260,13 @@ def get_macro_params( if update_from_api: try: - # pull series of interest from the WB using pandas_datareader - # Annual data - wb_data_a = wb.download( - indicator=wb_a_variable_dict.values(), - country=country_iso, - start=data_start_date, - end=data_end_date, - ) - wb_data_a.rename( - columns=dict((y, x) for x, y in wb_a_variable_dict.items()), - inplace=True, + # Pull annual series from the World Bank v2 API + wb_data_a = _fetch_wb_data( + wb_a_variable_dict, + country_iso, + data_start_date.year, + data_end_date.year, + source=2, ) # Compute annual GDP growth safely diff --git a/setup.py b/setup.py index 125a44a..7629f31 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,6 @@ "distributed>=2.30.1", "paramtools>=0.20.0", "requests", - "pandas-datareader", "xlwt", "openpyxl>=3.1.2", "statsmodels", diff --git a/tests/test_calibrate.py b/tests/test_calibrate.py index fa634ad..03a0f27 100644 --- a/tests/test_calibrate.py +++ b/tests/test_calibrate.py @@ -92,7 +92,9 @@ def test_macro_failure_warns_and_omits(self, mock_demog, mock_macro): warnings.simplefilter("always") c = Calibration(p, update_from_api=True) - assert any("Macro params update failed" in str(w.message) for w in caught) + assert any( + "Macro params update failed" in str(w.message) for w in caught + ) d = c.get_dict() assert "g_y_annual" not in d assert "initial_debt_ratio" not in d diff --git a/tests/test_import_smoke.py b/tests/test_import_smoke.py new file mode 100644 index 0000000..042206f --- /dev/null +++ b/tests/test_import_smoke.py @@ -0,0 +1,13 @@ +""" +Import smoke tests for installed package usage. +""" + + +def test_import_smoke(): + import ogeth + from ogeth import macro_params + from ogeth.calibrate import Calibration + + assert ogeth is not None + assert macro_params is not None + assert Calibration is not None diff --git a/tests/test_macro_params.py b/tests/test_macro_params.py index 1d8689f..5a14acd 100644 --- a/tests/test_macro_params.py +++ b/tests/test_macro_params.py @@ -128,16 +128,30 @@ def _imf_payload(indicator_year_values, country="ETH", sector="S1311B"): } -def _mock_wb_download(monkeypatch): - def fake_download(indicator, country, start, end): - indicator_codes = list(indicator) - assert indicator_codes == ["NY.GDP.PCAP.KD"] - return pd.DataFrame( - {"NY.GDP.PCAP.KD": [100.0, 80.0, 64.0]}, - index=["2024", "2023", "2022"], - ) +def _wb_payload(observations): + return [ + { + "page": 1, + "pages": 1, + "per_page": "10000", + "total": len(observations), + }, + [ + { + "date": date, + "value": value, + "indicator": {"id": "mock-indicator"}, + } + for date, value in observations + ], + ] - monkeypatch.setattr(macro_params.wb, "download", fake_download) + +_DEFAULT_WB_PAYLOADS = { + "NY.GDP.PCAP.KD": _wb_payload( + [("2024", 100.0), ("2023", 80.0), ("2022", 64.0)] + ), +} def _mock_requests_get( @@ -146,9 +160,15 @@ def _mock_requests_get( *, ilo_text=None, imf_json=None, + wb_payloads=None, ): + payloads = _DEFAULT_WB_PAYLOADS if wb_payloads is None else wb_payloads + def fake_get(url, params=None, headers=None, timeout=None): requested_urls.append(url) + if "worldbank.org" in url: + indicator_code = url.rstrip("/").split("/")[-1] + return MockResponse(json_data=payloads[indicator_code]) if "rplumber.ilo.org" in url: return MockResponse( text=ilo_text or "time,obs_value\n2024,38.209\n2023,38.0\n" @@ -158,7 +178,10 @@ def fake_get(url, params=None, headers=None, timeout=None): json_data=imf_json or _imf_payload( { - "G2_T": {2023: 6.121621434173129, 2024: 5.117707506327274}, + "G2_T": { + 2023: 6.121621434173129, + 2024: 5.117707506327274, + }, "G24_T": { 2023: 0.5526694185117545, 2024: 0.5920776865725198, @@ -207,7 +230,6 @@ def test_get_macro_params_update_from_api_false_returns_empty_dict(): def test_get_macro_params_update_from_api_true(monkeypatch): requested_urls = [] _mock_statsmodels(monkeypatch) - _mock_wb_download(monkeypatch) _mock_requests_get(monkeypatch, requested_urls) test_dict = macro_params.get_macro_params(update_from_api=True) @@ -235,7 +257,10 @@ def test_get_macro_params_update_from_api_true(monkeypatch): assert test_dict["alpha_G"] == [pytest.approx(0.04525629819754754)] assert test_dict["r_gov_shift"] == [pytest.approx(-0.03376625043803517)] assert test_dict["r_gov_scale"] == [pytest.approx(0.24484763593657818)] - assert any(".ETH.S1311B.G2M." in url or "/ETH.S1311B.G2M." in url for url in requested_urls) + assert any( + ".ETH.S1311B.G2M." in url or "/ETH.S1311B.G2M." in url + for url in requested_urls + ) def test_get_imf_macro_params_uses_eth_budgetary_sector(monkeypatch): @@ -310,7 +335,6 @@ def test_get_imf_macro_params_falls_back_to_last_available_year(monkeypatch): def test_get_macro_params_passes_imf_year_override(monkeypatch): requested_urls = [] _mock_statsmodels(monkeypatch) - _mock_wb_download(monkeypatch) _mock_requests_get( monkeypatch, requested_urls, From 9dddb5c280cb2edd8629a1fd8a7ae55deb320a95 Mon Sep 17 00:00:00 2001 From: SeaCelo Date: Sat, 18 Apr 2026 20:54:14 -0400 Subject: [PATCH 3/5] Add AGENTS.md mirroring OG-ZAF structure with ogeth-dev environment --- AGENTS.md | 30 ++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 31 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9b98a5d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +## Workflow + +- Read-only inspection does not require prior approval. This includes actions like checking status, listing branches or worktrees, inspecting tracking refs, reviewing merged status, reading files, and preparing a proposed cleanup list. +- Present a plan and wait for explicit approval before making changes to repo state. This includes file edits, branch deletion, pruning refs or worktrees, rebases, merges, commits, pushes, and similar mutating actions. + +## Project: OG-ETH + +OG-ETH is an Ethiopia country calibration of the OG-Core overlapping-generations model of demographics and fiscal policy. + +## Environment + +- Conda environment: `ogeth-dev` + +## Python formatting + +- Run `black` on all touched `.py` files before staging and pushing. +- Do not run `black` on non-Python files (e.g. README.md will fail to parse). +- Re-run tests after formatting to confirm nothing broke. +- Format command: `conda run -n ogeth-dev python -m black ` + +## Testing + +- Full suite: `conda run -n ogeth-dev python -m pytest tests/ -q` +- Targeted: `conda run -n ogeth-dev python -m pytest tests/test_calibrate.py tests/test_input_output.py tests/test_macro_params.py -q` + +## Repo conventions + +- The packaged JSON default parameters are the standard baseline input for offline/default runs. +- Calibration-related changes can affect macro parameters, demographics, earnings distribution, and industry I/O behavior. +- Changes in calibration or data-source behavior should be validated with targeted tests and, where feasible, the relevant example flows. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From ec3d044ff7d918c70ed77d6d6c2b1c5b5cd04ca6 Mon Sep 17 00:00:00 2001 From: SeaCelo Date: Sat, 18 Apr 2026 21:05:09 -0400 Subject: [PATCH 4/5] Restore explicit README calibration example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d00ad0c..4ada1f3 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ from ogeth.calibrate import Calibration p = Specifications() c = Calibration(p, update_from_api=True) updated_params = c.get_dict() -p.update_specifications(updated_params) +p.update_specifications({'initial_debt_ratio': updated_params['initial_debt_ratio']}) ``` ## Disclaimer From 2977cf9724fda98a93f02e859a21a1929ead17c8 Mon Sep 17 00:00:00 2001 From: SeaCelo Date: Sat, 18 Apr 2026 22:36:19 -0400 Subject: [PATCH 5/5] Format code with black 26.3.0 --- docs/create_doc_figures.py | 1 - ogeth/input_output.py | 1 - tests/test_input_output.py | 1 - 3 files changed, 3 deletions(-) diff --git a/docs/create_doc_figures.py b/docs/create_doc_figures.py index 4195508..42efad0 100644 --- a/docs/create_doc_figures.py +++ b/docs/create_doc_figures.py @@ -11,7 +11,6 @@ from ogcore import parameter_plots as pp from ogcore import demographics as demog - CUR_DIR = os.path.dirname(os.path.realpath(__file__)) UN_COUNTRY_CODE = "231" plot_path = os.path.join(CUR_DIR, "book", "content", "calibration", "images") diff --git a/ogeth/input_output.py b/ogeth/input_output.py index d30b78f..5748c53 100644 --- a/ogeth/input_output.py +++ b/ogeth/input_output.py @@ -3,7 +3,6 @@ import os from ogeth.constants import CONS_DICT, PROD_DICT - CUR_DIR = os.path.dirname(os.path.realpath(__file__)) sam_path = os.path.join(CUR_DIR, "data", "IFPRI_SAM_ETH_2022_SAM.csv") diff --git a/tests/test_input_output.py b/tests/test_input_output.py index eac3782..c180ebd 100644 --- a/tests/test_input_output.py +++ b/tests/test_input_output.py @@ -9,7 +9,6 @@ from ogeth import input_output as io - HH_COLS = [ "hhd-r1", "hhd-r2",