From 5cb80415d63eca955a70e5b2878027eedaacfa73 Mon Sep 17 00:00:00 2001 From: jvendries Date: Fri, 31 Jul 2026 21:59:48 -0400 Subject: [PATCH 1/3] Add compare_E_and_LCI_result test analogous to the same test in useeior --- bedrock/utils/validation/__init__.py | 20 +- .../__tests__/test_eeio_diagnostics.py | 310 ++++++++--- bedrock/utils/validation/eeio_diagnostics.py | 480 ++++++++++++++++-- 3 files changed, 691 insertions(+), 119 deletions(-) diff --git a/bedrock/utils/validation/__init__.py b/bedrock/utils/validation/__init__.py index b9b550ec..493f8bf3 100644 --- a/bedrock/utils/validation/__init__.py +++ b/bedrock/utils/validation/__init__.py @@ -3,16 +3,26 @@ from bedrock.utils.validation.eeio_diagnostics import ( DiagnosticResult, + assert_eeio_year_alignment_precondition, compare_commodity_output_to_domestics_use_plus_exports, + compare_E_and_LCI_result, compare_output_vs_leontief_x_demand, + eeio_year_alignment_precondition_ok, format_diagnostic_result, + print_validation_results, run_all_diagnostics, + run_model_identity_validations, ) __all__ = [ - "DiagnosticResult", - "compare_commodity_output_to_domestics_use_plus_exports", - "compare_output_vs_leontief_x_demand", - "format_diagnostic_result", - "run_all_diagnostics", + 'DiagnosticResult', + 'assert_eeio_year_alignment_precondition', + 'compare_E_and_LCI_result', + 'compare_commodity_output_to_domestics_use_plus_exports', + 'compare_output_vs_leontief_x_demand', + 'eeio_year_alignment_precondition_ok', + 'format_diagnostic_result', + 'print_validation_results', + 'run_all_diagnostics', + 'run_model_identity_validations', ] diff --git a/bedrock/utils/validation/__tests__/test_eeio_diagnostics.py b/bedrock/utils/validation/__tests__/test_eeio_diagnostics.py index 13bf11ab..8dd02a0a 100644 --- a/bedrock/utils/validation/__tests__/test_eeio_diagnostics.py +++ b/bedrock/utils/validation/__tests__/test_eeio_diagnostics.py @@ -1,6 +1,11 @@ # ruff: noqa: PLC0415 """Unit tests for the EEIO diagnostics module.""" +from __future__ import annotations + +import dataclasses as dc + +import numpy as np import pandas as pd import pytest @@ -23,11 +28,19 @@ derive_cornerstone_y_nab, derive_cornerstone_Ytot_matrix_set, ) +from bedrock.utils.config.usa_config import ( + USAConfig, + reset_usa_config, + set_global_usa_config, +) from bedrock.utils.math.formulas import compute_y_imp from bedrock.utils.validation.eeio_diagnostics import ( DiagnosticResult, + assert_eeio_year_alignment_precondition, compare_commodity_output_to_domestics_use_plus_exports, + compare_E_and_LCI_result, compare_output_vs_leontief_x_demand, + eeio_year_alignment_precondition_ok, format_diagnostic_result, run_all_diagnostics, validate_result, @@ -45,14 +58,14 @@ class TestDiagnosticResult: def test_basic_passing_result(self) -> None: """Test basic instantiation with a passing result.""" result = DiagnosticResult( - name="Row sum check", + name='Row sum check', passed=True, tolerance=0.01, max_rel_diff=0.005, failing_sectors=[], ) - assert result.name == "Row sum check" + assert result.name == 'Row sum check' assert result.passed is True assert result.tolerance == 0.01 assert result.max_rel_diff == 0.005 @@ -62,53 +75,53 @@ def test_basic_passing_result(self) -> None: def test_failed_result_with_failing_sectors(self) -> None: """Test a failed result with sectors that failed the check.""" result = DiagnosticResult( - name="Column sum check", + name='Column sum check', passed=False, tolerance=0.01, max_rel_diff=0.05, - failing_sectors=["11", "21", "31"], + failing_sectors=['11', '21', '31'], ) assert result.passed is False assert len(result.failing_sectors) == 3 - assert "11" in result.failing_sectors - assert "21" in result.failing_sectors - assert "31" in result.failing_sectors + assert '11' in result.failing_sectors + assert '21' in result.failing_sectors + assert '31' in result.failing_sectors assert result.max_rel_diff == 0.05 def test_result_with_details_dataframe(self) -> None: """Test a result with a details DataFrame.""" details_df = pd.DataFrame( { - "sector": ["11", "21"], - "expected": [100.0, 200.0], - "actual": [105.0, 195.0], - "rel_diff": [0.05, 0.025], + 'sector': ['11', '21'], + 'expected': [100.0, 200.0], + 'actual': [105.0, 195.0], + 'rel_diff': [0.05, 0.025], } ) result = DiagnosticResult( - name="Detailed check", + name='Detailed check', passed=False, tolerance=0.01, max_rel_diff=0.05, - failing_sectors=["11"], + failing_sectors=['11'], details=details_df, ) assert result.details is not None assert isinstance(result.details, pd.DataFrame) assert len(result.details) == 2 - assert "sector" in result.details.columns - assert "expected" in result.details.columns - assert "actual" in result.details.columns - assert "rel_diff" in result.details.columns + assert 'sector' in result.details.columns + assert 'expected' in result.details.columns + assert 'actual' in result.details.columns + assert 'rel_diff' in result.details.columns def test_negative_tolerance_raises_error(self) -> None: """Test that negative tolerance raises ValueError.""" - with pytest.raises(ValueError, match="Tolerance must be non-negative"): + with pytest.raises(ValueError, match='Tolerance must be non-negative'): DiagnosticResult( - name="Invalid check", + name='Invalid check', passed=True, tolerance=-0.01, max_rel_diff=0.005, @@ -117,9 +130,9 @@ def test_negative_tolerance_raises_error(self) -> None: def test_negative_max_rel_diff_raises_error(self) -> None: """Test that negative max_rel_diff raises ValueError.""" - with pytest.raises(ValueError, match="max_rel_diff must be non-negative"): + with pytest.raises(ValueError, match='max_rel_diff must be non-negative'): DiagnosticResult( - name="Invalid check", + name='Invalid check', passed=True, tolerance=0.01, max_rel_diff=-0.005, @@ -129,7 +142,7 @@ def test_negative_max_rel_diff_raises_error(self) -> None: def test_zero_tolerance_is_valid(self) -> None: """Test that zero tolerance is accepted (edge case).""" result = DiagnosticResult( - name="Exact match check", + name='Exact match check', passed=True, tolerance=0.0, max_rel_diff=0.0, @@ -145,32 +158,32 @@ class TestValidateResult: def test_zero_value_tiny_residual_passes(self) -> None: """Sectors with q=0 compare absolute residual against atol, not rel_diff.""" - value = pd.Series({"S00402": 0.0, "1111A0": 100.0}) - value_check = pd.Series({"S00402": 7.6e-6, "1111A0": 100.5}) + value = pd.Series({'S00402': 0.0, '1111A0': 100.0}) + value_check = pd.Series({'S00402': 7.6e-6, '1111A0': 100.5}) - result = validate_result("zero q", value, value_check, tolerance=0.01) + result = validate_result('zero q', value, value_check, tolerance=0.01) assert result.passed is True assert result.failing_sectors == [] assert result.max_rel_diff <= 1.0 def test_zero_value_large_residual_fails(self) -> None: - value = pd.Series({"S00402": 0.0}) - value_check = pd.Series({"S00402": 1.0}) + value = pd.Series({'S00402': 0.0}) + value_check = pd.Series({'S00402': 1.0}) - result = validate_result("zero q", value, value_check, tolerance=0.01) + result = validate_result('zero q', value, value_check, tolerance=0.01) assert result.passed is False - assert result.failing_sectors == ["S00402"] + assert result.failing_sectors == ['S00402'] def test_nonzero_value_uses_relative_tolerance(self) -> None: - value = pd.Series({"1111A0": 100.0}) - value_check = pd.Series({"1111A0": 102.0}) + value = pd.Series({'1111A0': 100.0}) + value_check = pd.Series({'1111A0': 102.0}) - result = validate_result("rel", value, value_check, tolerance=0.01) + result = validate_result('rel', value, value_check, tolerance=0.01) assert result.passed is False - assert result.failing_sectors == ["1111A0"] + assert result.failing_sectors == ['1111A0'] class TestFormatDiagnosticResult: @@ -179,7 +192,7 @@ class TestFormatDiagnosticResult: def test_format_passing_result(self) -> None: """Test formatting a passing diagnostic result.""" result = DiagnosticResult( - name="Row sum check", + name='Row sum check', passed=True, tolerance=0.01, max_rel_diff=0.005, @@ -188,33 +201,33 @@ def test_format_passing_result(self) -> None: formatted = format_diagnostic_result(result) - assert "Diagnostic: Row sum check" in formatted - assert "Status: PASSED" in formatted - assert "Tolerance (rtol): 0.0100" in formatted - assert "Max normalized residual: 0.0050 (pass if <= 1.0)" in formatted - assert "Failing sectors: None" in formatted + assert 'Diagnostic: Row sum check' in formatted + assert 'Status: PASSED' in formatted + assert 'Tolerance (rtol): 0.0100' in formatted + assert 'Max normalized residual: 0.0050 (pass if <= 1.0)' in formatted + assert 'Failing sectors: None' in formatted def test_format_failed_result_with_sectors(self) -> None: """Test formatting a failed result with failing sectors.""" result = DiagnosticResult( - name="Column sum check", + name='Column sum check', passed=False, tolerance=0.01, max_rel_diff=0.05, - failing_sectors=["11", "21"], + failing_sectors=['11', '21'], ) formatted = format_diagnostic_result(result) - assert "Diagnostic: Column sum check" in formatted - assert "Status: FAILED" in formatted - assert "Failing sectors (2): 11, 21" in formatted + assert 'Diagnostic: Column sum check' in formatted + assert 'Status: FAILED' in formatted + assert 'Failing sectors (2): 11, 21' in formatted def test_format_result_with_many_failing_sectors(self) -> None: """Test that formatting truncates when many sectors fail.""" many_sectors = [str(i) for i in range(15)] result = DiagnosticResult( - name="Many failures", + name='Many failures', passed=False, tolerance=0.01, max_rel_diff=0.05, @@ -223,8 +236,8 @@ def test_format_result_with_many_failing_sectors(self) -> None: formatted = format_diagnostic_result(result) - assert "Failing sectors (15):" in formatted - assert "+5 more" in formatted + assert 'Failing sectors (15):' in formatted + assert '+5 more' in formatted class TestRunAllDiagnostics: @@ -235,7 +248,7 @@ def test_run_single_passing_diagnostic(self) -> None: def passing_check() -> DiagnosticResult: return DiagnosticResult( - name="Passing check", + name='Passing check', passed=True, tolerance=0.01, max_rel_diff=0.005, @@ -252,7 +265,7 @@ def test_run_multiple_diagnostics(self) -> None: def check_a() -> DiagnosticResult: return DiagnosticResult( - name="Check A", + name='Check A', passed=True, tolerance=0.01, max_rel_diff=0.005, @@ -261,11 +274,11 @@ def check_a() -> DiagnosticResult: def check_b() -> DiagnosticResult: return DiagnosticResult( - name="Check B", + name='Check B', passed=False, tolerance=0.01, max_rel_diff=0.05, - failing_sectors=["11"], + failing_sectors=['11'], ) results = run_all_diagnostics([check_a, check_b], log_results=False) @@ -279,11 +292,11 @@ def test_stop_on_failure(self) -> None: def failing_check() -> DiagnosticResult: return DiagnosticResult( - name="Failing check", + name='Failing check', passed=False, tolerance=0.01, max_rel_diff=0.05, - failing_sectors=["11"], + failing_sectors=['11'], ) with pytest.raises(RuntimeError, match="Diagnostic 'Failing check' failed"): @@ -298,19 +311,19 @@ def test_continues_after_failure_by_default(self) -> None: call_order: list[str] = [] def check_a() -> DiagnosticResult: - call_order.append("a") + call_order.append('a') return DiagnosticResult( - name="Check A", + name='Check A', passed=False, tolerance=0.01, max_rel_diff=0.05, - failing_sectors=["11"], + failing_sectors=['11'], ) def check_b() -> DiagnosticResult: - call_order.append("b") + call_order.append('b') return DiagnosticResult( - name="Check B", + name='Check B', passed=True, tolerance=0.01, max_rel_diff=0.005, @@ -324,23 +337,23 @@ def check_b() -> DiagnosticResult: ) assert len(results) == 2 - assert call_order == ["a", "b"] + assert call_order == ['a', 'b'] @pytest.mark.eeio_integration @pytest.mark.parametrize( - "pipeline", + 'pipeline', [ pytest.param( - "ceda", + 'ceda', marks=pytest.mark.xfail( - reason="CEDA: q≠U_dom+y_d for 13 sectors after schema-alignment changes to 2017 detail trade/U.", + reason='CEDA: q≠U_dom+y_d for 13 sectors after schema-alignment changes to 2017 detail trade/U.', ), ), pytest.param( - "cornerstone", + 'cornerstone', marks=pytest.mark.xfail( - reason="Cornerstone: q≠U_dom+y_d for 13 sectors; BEA→CS remap and waste disagg break NAB identity.", + reason='Cornerstone: q≠U_dom+y_d for 13 sectors; BEA→CS remap and waste disagg break NAB identity.', ), ), ], @@ -348,8 +361,7 @@ def check_b() -> DiagnosticResult: def test_compare_Uset_y_dom_and_q_usa( pipeline: str, ) -> None: - - if pipeline != "cornerstone": + if pipeline != 'cornerstone': U_set = derive_2017_U_with_negatives() y_set = derive_2017_Ytot_usa_matrix_set() # CEDA has derive_detail_y_imp_usa(); it uses derive_2017_U_set_usa().Uimp @@ -393,23 +405,23 @@ def test_compare_Uset_y_dom_and_q_usa( @pytest.mark.eeio_integration @pytest.mark.parametrize( - "modelType, use_domestic, pipeline", + 'modelType, use_domestic, pipeline', [ - ("Commodity", True, "cornerstone"), + ('Commodity', True, 'cornerstone'), pytest.param( - "Commodity", + 'Commodity', False, - "cornerstone", + 'cornerstone', marks=pytest.mark.xfail( - reason="Cornerstone total L·y still uses ytot/trade, not y_nab.", + reason='Cornerstone total L·y still uses ytot/trade, not y_nab.', ), ), pytest.param( - "Commodity", + 'Commodity', False, - "ceda", + 'ceda', marks=pytest.mark.xfail( - reason="CEDA: scaled q≠L_total·y_total for ~298 commodity sectors (total Leontief identity).", + reason='CEDA: scaled q≠L_total·y_total for ~298 commodity sectors (total Leontief identity).', ), ), ], @@ -419,14 +431,13 @@ def test_compare_output_and_L_y( use_domestic: bool, pipeline: str, ) -> None: - - if pipeline != "cornerstone": + if pipeline != 'cornerstone': # CEDA: unscaled 2017-detail A and q; y built from 2017 Ytot/trade in IO year. Aq = derive_2017_Aq_usa() y_set = derive_2017_Ytot_usa_matrix_set() y_imp = derive_detail_y_imp_usa() output = ( - derive_2017_q_usa() if modelType == "Commodity" else derive_2017_x_usa() + derive_2017_q_usa() if modelType == 'Commodity' else derive_2017_x_usa() ) if use_domestic: y = y_set.ytot - y_imp + y_set.exports @@ -438,7 +449,7 @@ def test_compare_output_and_L_y( # Cornerstone scales A and q to model year; CEDA branch stays in 2017 detail. Aq = derive_cornerstone_Aq_scaled() # Output must match Aq scaling (scaled_q), not derive_cornerstone_q() from V. - output = Aq.scaled_q if modelType == "Commodity" else derive_cornerstone_x() + output = Aq.scaled_q if modelType == 'Commodity' else derive_cornerstone_x() if use_domestic: # y_nab from backcompute_y_from_A_and_q(Adom, scaled_q); unclipped. y = derive_cornerstone_y_nab() @@ -454,3 +465,146 @@ def test_compare_output_and_L_y( output=output, L=L, y=y, tolerance=0.01, include_details=True ) assert len(r_output_L_y_validation.failing_sectors) == 0 + + +# --------------------------------------------------------------------------- +# useeior-parity LCI ≈ E + year-alignment precondition +# --------------------------------------------------------------------------- + + +@dc.dataclass(frozen=True) +class _ToyLciMatrices: + B: pd.DataFrame + L: pd.DataFrame + y: pd.Series[float] + E_ind: pd.DataFrame + V: pd.DataFrame + x: pd.Series[float] + q: pd.Series[float] + + +def _toy_lci_matrices() -> _ToyLciMatrices: + """Minimal commodity LCI≈E system with C_m = I and L = I.""" + industries = ['i1', 'i2'] + commodities = ['c1', 'c2'] + flows = ['CO2'] + + V = pd.DataFrame( + [[10.0, 0.0], [0.0, 20.0]], + index=industries, + columns=commodities, + ) + x = V.sum(axis=1) + q = pd.Series([10.0, 20.0], index=commodities) + E_ind = pd.DataFrame([[100.0, 200.0]], index=flows, columns=industries) + # With C_m = I, E_c = E_ind; B = E_c / q + B = pd.DataFrame([[10.0, 10.0]], index=flows, columns=commodities) + L = pd.DataFrame( + np.eye(2), + index=commodities, + columns=commodities, + ) + y = q.copy() + return _ToyLciMatrices(B=B, L=L, y=y, E_ind=E_ind, V=V, x=x, q=q) + + +def test_eeio_year_alignment_precondition_ok() -> None: + cfg = USAConfig.model_validate( + { + 'model_base_year': 2024, + 'usa_ghg_data_year': 2024, + 'use_E_data_year_for_x_in_B': True, + }, + strict=True, + ) + assert eeio_year_alignment_precondition_ok(cfg) + assert_eeio_year_alignment_precondition(cfg) + + +def test_eeio_year_alignment_precondition_fails_loud() -> None: + cfg = USAConfig.model_validate( + { + 'model_base_year': 2024, + 'usa_ghg_data_year': 2023, + 'use_E_data_year_for_x_in_B': True, + }, + strict=True, + ) + assert not eeio_year_alignment_precondition_ok(cfg) + with pytest.raises(ValueError, match='precondition failed'): + assert_eeio_year_alignment_precondition(cfg) + + +def test_toy_lci_passes_when_LCI_equals_E_c() -> None: + m = _toy_lci_matrices() + result = compare_E_and_LCI_result( + B=m.B, + L=m.L, + y=m.y, + E_ind=m.E_ind, + V=m.V, + x=m.x, + tolerance=0.01, + check_precondition=False, + ) + assert result.passed + assert result.failing_sectors == [] + + +def test_toy_lci_fails_when_E_perturbed() -> None: + m = _toy_lci_matrices() + E_ind = m.E_ind.copy() + E_ind.iloc[0, 0] = 1000.0 + result = compare_E_and_LCI_result( + B=m.B, + L=m.L, + y=m.y, + E_ind=E_ind, + V=m.V, + x=m.x, + tolerance=0.01, + check_precondition=False, + ) + assert not result.passed + assert len(result.failing_sectors) > 0 + + +@pytest.mark.eeio_integration +def test_v0_3_domestic_lci_equals_e() -> None: + """Live domestic LCI≈E under aligned v0.3 (Vnorm path, χ=1 precondition).""" + reset_usa_config(should_reset_env_var=True) + set_global_usa_config('2025_usa_cornerstone_v0_3.yaml') + try: + from bedrock.transform.allocation.derived import derive_E_usa + from bedrock.transform.eeio.derived_cornerstone import ( + derive_cornerstone_Aq_scaled, + derive_cornerstone_B_non_finetuned, + derive_cornerstone_Vnorm_scrap_corrected, + derive_cornerstone_x_after_redefinition, + derive_cornerstone_y_nab, + ) + from bedrock.utils.math.formulas import compute_L_matrix + + assert_eeio_year_alignment_precondition() + + Aq = derive_cornerstone_Aq_scaled() + L_d = compute_L_matrix(A=Aq.Adom) + y_d = derive_cornerstone_y_nab() + result = compare_E_and_LCI_result( + B=derive_cornerstone_B_non_finetuned(), + L=L_d, + y=y_d, + E_ind=derive_E_usa(), + x=derive_cornerstone_x_after_redefinition(), + Vnorm=derive_cornerstone_Vnorm_scrap_corrected(), + q=Aq.scaled_q, + tolerance=0.01, + include_details=True, + check_precondition=False, + ) + assert result.passed, ( + f'domestic LCI≈E failed ({len(result.failing_sectors)} cells): ' + f'{result.failing_sectors[:20]}' + ) + finally: + reset_usa_config(should_reset_env_var=True) diff --git a/bedrock/utils/validation/eeio_diagnostics.py b/bedrock/utils/validation/eeio_diagnostics.py index a8dfe94d..d16a5067 100644 --- a/bedrock/utils/validation/eeio_diagnostics.py +++ b/bedrock/utils/validation/eeio_diagnostics.py @@ -14,9 +14,11 @@ import numpy as np import pandas as pd +from bedrock.utils.config.usa_config import USAConfig, get_usa_config from bedrock.utils.math.formulas import ( backcompute_q_from_L_and_y, compute_commodity_mix_matrix, + compute_E_from_BLy, ) from bedrock.utils.schemas.single_region_types import SingleRegionYtotAndTradeVectorSet @@ -55,9 +57,9 @@ class DiagnosticResult: def __post_init__(self) -> None: """Validate the diagnostic result after initialization.""" if self.tolerance < 0: - raise ValueError("Tolerance must be non-negative") + raise ValueError('Tolerance must be non-negative') if self.max_rel_diff < 0: - raise ValueError("max_rel_diff must be non-negative") + raise ValueError('max_rel_diff must be non-negative') def format_diagnostic_result(result: DiagnosticResult) -> str: @@ -88,31 +90,31 @@ def format_diagnostic_result(result: DiagnosticResult) -> str: Max normalized residual: 1.5000 (pass if <= 1.0) Failing sectors (2): 11, 21 """ - status = "PASSED" if result.passed else "FAILED" + status = 'PASSED' if result.passed else 'FAILED' lines = [ - f"Diagnostic: {result.name}", - f"Status: {status}", - f"Tolerance (rtol): {result.tolerance:.4f}", - f"Max normalized residual: {result.max_rel_diff:.4f} (pass if <= 1.0)", + f'Diagnostic: {result.name}', + f'Status: {status}', + f'Tolerance (rtol): {result.tolerance:.4f}', + f'Max normalized residual: {result.max_rel_diff:.4f} (pass if <= 1.0)', ] if result.failing_sectors: sector_count = len(result.failing_sectors) # Limit display to first 10 sectors if many are failing if sector_count > 10: - displayed_sectors = ", ".join(result.failing_sectors[:10]) + displayed_sectors = ', '.join(result.failing_sectors[:10]) lines.append( - f"Failing sectors ({sector_count}): {displayed_sectors}, ... " - f"(+{sector_count - 10} more)" + f'Failing sectors ({sector_count}): {displayed_sectors}, ... ' + f'(+{sector_count - 10} more)' ) else: - displayed_sectors = ", ".join(result.failing_sectors) - lines.append(f"Failing sectors ({sector_count}): {displayed_sectors}") + displayed_sectors = ', '.join(result.failing_sectors) + lines.append(f'Failing sectors ({sector_count}): {displayed_sectors}') else: - lines.append("Failing sectors: None") + lines.append('Failing sectors: None') - return "\n".join(lines) + return '\n'.join(lines) DiagnosticCallable = ta.Callable[[], DiagnosticResult] @@ -170,21 +172,21 @@ def run_all_diagnostics( if stop_on_failure and not result.passed: raise RuntimeError( f"Diagnostic '{result.name}' failed. " - f"Max normalized residual: {result.max_rel_diff:.4f} " - f"(pass if <= 1.0; rtol: {result.tolerance:.4f})" + f'Max normalized residual: {result.max_rel_diff:.4f} ' + f'(pass if <= 1.0; rtol: {result.tolerance:.4f})' ) except Exception as e: if isinstance(e, RuntimeError) and stop_on_failure: raise # Log unexpected errors but continue with other diagnostics - logger.error(f"Error running diagnostic: {e}") + logger.error(f'Error running diagnostic: {e}') # Create a failed result for the error case error_result = DiagnosticResult( - name=f"Error in {diagnostic.__name__ if hasattr(diagnostic, '__name__') else 'unknown'}", + name=f'Error in {diagnostic.__name__ if hasattr(diagnostic, "__name__") else "unknown"}', passed=False, tolerance=0.0, - max_rel_diff=float("inf"), + max_rel_diff=float('inf'), failing_sectors=[], details=None, ) @@ -194,7 +196,7 @@ def run_all_diagnostics( if log_results and results: passed_count = sum(1 for r in results if r.passed) total_count = len(results) - summary = f"Diagnostics complete: {passed_count}/{total_count} passed" + summary = f'Diagnostics complete: {passed_count}/{total_count} passed' if passed_count == total_count: logger.info(summary) else: @@ -247,7 +249,7 @@ def validate_result( value_abs = value.abs() allowed = tolerance * value_abs + atol - with np.errstate(divide="ignore", invalid="ignore"): + with np.errstate(divide='ignore', invalid='ignore'): normalized = abs_diff / allowed normalized = normalized.replace([np.inf, -np.inf], np.nan).fillna(0.0) @@ -258,10 +260,10 @@ def validate_result( details = None if include_details: data = { - "failing sectors": list(getattr(failing_sectors, "index", failing_sectors)), - "passing sectors": list(getattr(passing_sectors, "index", passing_sectors)), - "failing values": normalized.loc[failing_sectors].tolist(), - "max_rel_diff": max_rd, + 'failing sectors': list(getattr(failing_sectors, 'index', failing_sectors)), + 'passing sectors': list(getattr(passing_sectors, 'index', passing_sectors)), + 'failing values': normalized.loc[failing_sectors].tolist(), + 'max_rel_diff': max_rd, } details = pd.DataFrame({key: pd.Series(value) for key, value in data.items()}) @@ -315,16 +317,16 @@ def compare_commodity_output_to_domestics_use_plus_exports( sectors = q.index.intersection(U_d.index).intersection(y_d.index) if len(sectors) != len(q.index): return DiagnosticResult( - name="Unequal number of sectors in arguments of compare_commodity_output_to_domestics_use_plus_exports", + name='Unequal number of sectors in arguments of compare_commodity_output_to_domestics_use_plus_exports', passed=False, tolerance=tolerance, - max_rel_diff=float("inf"), + max_rel_diff=float('inf'), failing_sectors=[], details=None, ) q_check = U_d.sum(axis=1) + y_d - name = "commodity output and domestics use plus exports" + name = 'commodity output and domestics use plus exports' d_result = validate_result( name, q, q_check, tolerance=tolerance, include_details=include_details @@ -373,17 +375,17 @@ def compare_output_vs_leontief_x_demand( sectors = output.index.intersection(L.index).intersection(y.index) if len(sectors) != len(output.index): return DiagnosticResult( - name="Unequal number of sectors in arguments of compare_commodity_output_to_domestics_use_plus_exports", + name='Unequal number of sectors in arguments of compare_commodity_output_to_domestics_use_plus_exports', passed=False, tolerance=tolerance, - max_rel_diff=float("inf"), + max_rel_diff=float('inf'), failing_sectors=[], details=None, ) # calculate scaling factor output_check = backcompute_q_from_L_and_y(L=L, y=y) - name = "compare output and L * y" + name = 'compare output and L * y' d_result = validate_result( name, output, output_check, tolerance=tolerance, include_details=include_details @@ -413,7 +415,7 @@ def commodity_industry_output_cpi_consistency( q_check = q * commodity_CPI_ratio x_check = C_m @ (x * industry_CPI_ratio) - name = "commodity_industry_output_cpi_consistency" + name = 'commodity_industry_output_cpi_consistency' d_result = validate_result( name, q_check, x_check, tolerance=tolerance, include_details=include_details @@ -434,13 +436,16 @@ def compare_output_from_make_and_use( """Check that Make-table and Use-table output agree for industry or commodity. Pass/fail and ``max_rel_diff`` are computed by ``validate_result``. + + Note: not part of the default useeior ``printValidationResults`` suite; + Cornerstone Make/Use mismatches are tracked in GitHub issue #436. """ - if output == "Industry": + if output == 'Industry': x_make = V.sum(axis=1) x_use = U.sum(axis=0) + VA.sum(axis=0) - name = "compare_industry_output_from_make_and_use" + name = 'compare_industry_output_from_make_and_use' d_result = validate_result( name, x_make, @@ -448,11 +453,11 @@ def compare_output_from_make_and_use( tolerance=tolerance, include_details=include_details, ) - elif output == "Commodity": + elif output == 'Commodity': q_make = V.sum(axis=0) q_use = U.sum(axis=1) + (y_set.ytot + y_set.exports - y_set.imports) - name = "compare_commodity_output_from_make_and_use" + name = 'compare_commodity_output_from_make_and_use' d_result = validate_result( name, q_make, @@ -465,3 +470,406 @@ def compare_output_from_make_and_use( 'invalid output parameter requested for comparison between make and use, select commodity or industry' ) return d_result + + +def eeio_year_alignment_precondition_ok(cfg: USAConfig) -> bool: + """Return True when χ=1 is a valid surrogate for useeior Chi year alignment. + + Requires matching model/GHG years, ``use_E_data_year_for_x_in_B``, and no + scaled/deflated B paths that introduce intermediate dollar years. + """ + if cfg.model_base_year != cfg.usa_ghg_data_year: + return False + if not cfg.use_E_data_year_for_x_in_B: + return False + if cfg.use_scaled_x_and_scaled_Vnorm_for_B: + return False + if cfg.deflate_x_to_detail_io_year_for_B: + return False + return True + + +def assert_eeio_year_alignment_precondition( + cfg: ta.Optional[USAConfig] = None, +) -> None: + """Raise if the active (or given) config is not aligned for χ=1 LCI≈E checks. + + Replaces useeior ``generateChiMatrix``: when this passes, ``compare_E_and_LCI_result`` + may use χ=1. Failures are loud (``ValueError``), never silent skips. + + Parameters + ---------- + cfg + Config to check; defaults to ``get_usa_config()``. + """ + if cfg is None: + cfg = get_usa_config() + if eeio_year_alignment_precondition_ok(cfg): + return + + reasons: list[str] = [] + if cfg.model_base_year != cfg.usa_ghg_data_year: + reasons.append( + f'model_base_year ({cfg.model_base_year}) != ' + f'usa_ghg_data_year ({cfg.usa_ghg_data_year})' + ) + if not cfg.use_E_data_year_for_x_in_B: + reasons.append('use_E_data_year_for_x_in_B is False') + if cfg.use_scaled_x_and_scaled_Vnorm_for_B: + reasons.append( + 'use_scaled_x_and_scaled_Vnorm_for_B is True ' + '(intermediate dollar years break χ=1)' + ) + if cfg.deflate_x_to_detail_io_year_for_B: + reasons.append( + 'deflate_x_to_detail_io_year_for_B is True ' + '(intermediate dollar years break χ=1)' + ) + raise ValueError( + 'EEIO year-alignment precondition failed for χ=1 LCI≈E validation: ' + + '; '.join(reasons) + ) + + +def _flatten_matrix_for_validate(df: pd.DataFrame) -> pd.Series[float]: + """Stack a flow×sector matrix to a Series with MultiIndex labels ``flow|sector``.""" + stacked = df.stack() + stacked.index = stacked.index.map( + lambda idx: f'{idx[0]}|{idx[1]}' if isinstance(idx, tuple) else str(idx) + ) + return ta.cast('pd.Series[float]', stacked.astype(float)) + + +def compare_E_and_LCI_result( + *, + B: pd.DataFrame, + L: pd.DataFrame, + y: pd.Series[float], + E_ind: pd.DataFrame, + V: ta.Optional[pd.DataFrame] = None, + x: ta.Optional[pd.Series[float]] = None, + Vnorm: ta.Optional[pd.DataFrame] = None, + q: ta.Optional[pd.Series[float]] = None, + tolerance: float = 0.01, + include_details: bool = False, + check_precondition: bool = True, + cfg: ta.Optional[USAConfig] = None, +) -> DiagnosticResult: + """Compare direct-perspective LCI to commodity-transformed satellite totals. + + Port of useeior ``compareEandLCIResult`` with χ=1 (no Chi matrix). Requires + :func:`assert_eeio_year_alignment_precondition` unless + ``check_precondition=False`` (unit tests with synthetic matrices). + + Commodity ``E_c`` (pick one path):: + + # A) Vnorm path (Cornerstone B = (E/x) @ Vnorm_scrap): preferred when + # scrap-corrected Vnorm is used — C_m(V, x_Make) will not match. + E_c = (E_ind / x @ Vnorm) · diag(q) + + # B) useeior C_m path (no scrap adjustment on market shares): + E_c = (C_m @ E_ind.T).T # C_m from Make V, x + + c = L @ y + LCI = B · diag(c) # B already commodity — do not @ V_n again + compare LCI to E_c cell-wise + + Provide either (``Vnorm``, ``x``, ``q``) or (``V``, ``x``). Distinct from + NAB national ``sum(diag(D) @ L @ y) ≈ sum(E)``. + """ + if check_precondition: + assert_eeio_year_alignment_precondition(cfg) + + use_vnorm_path = Vnorm is not None and q is not None and x is not None + use_cm_path = V is not None and x is not None and not use_vnorm_path + if not use_vnorm_path and not use_cm_path: + raise ValueError( + 'compare_E_and_LCI_result requires either (Vnorm, x, q) or (V, x)' + ) + + flows = B.index.intersection(E_ind.index) + if len(flows) == 0: + return DiagnosticResult( + name='compare_E_and_LCI_result: empty flow intersection', + passed=False, + tolerance=tolerance, + max_rel_diff=float('inf'), + failing_sectors=[], + details=None, + ) + + if use_vnorm_path: + assert Vnorm is not None and q is not None and x is not None + industries = Vnorm.index.intersection(E_ind.columns).intersection(x.index) + commodities = ( + B.columns.intersection(Vnorm.columns) + .intersection(q.index) + .intersection(L.index) + .intersection(y.index) + ) + if len(industries) == 0 or len(commodities) == 0: + return DiagnosticResult( + name='compare_E_and_LCI_result: empty industry/commodity intersection', + passed=False, + tolerance=tolerance, + max_rel_diff=float('inf'), + failing_sectors=[], + details=None, + ) + Bi = ( + E_ind.loc[flows, industries] + .divide(x.reindex(industries).fillna(0.0), axis=1) + .fillna(0.0) + ) + E_c = Bi @ Vnorm.loc[industries, commodities] + E_c = E_c.multiply(q.reindex(commodities).fillna(0.0), axis=1) + else: + assert V is not None and x is not None + C_m = compute_commodity_mix_matrix(V=V, x=x) + industries = C_m.columns.intersection(E_ind.columns) + if len(industries) == 0: + return DiagnosticResult( + name='compare_E_and_LCI_result: empty industry intersection', + passed=False, + tolerance=tolerance, + max_rel_diff=float('inf'), + failing_sectors=[], + details=None, + ) + E_aligned = E_ind.loc[flows, industries] + E_c = (C_m.loc[:, industries] @ E_aligned.T).T + commodities = ( + B.columns.intersection(E_c.columns) + .intersection(L.index) + .intersection(y.index) + ) + if len(commodities) == 0: + return DiagnosticResult( + name='compare_E_and_LCI_result: empty commodity intersection', + passed=False, + tolerance=tolerance, + max_rel_diff=float('inf'), + failing_sectors=[], + details=None, + ) + E_c = E_c.loc[flows, commodities] + + B_c = B.loc[flows, commodities] + L_a = L.loc[commodities, commodities] + y_a = y.reindex(commodities).fillna(0.0) + + LCI = compute_E_from_BLy(B=B_c, L=L_a, y=y_a) + LCI = LCI.reindex(index=E_c.index, columns=E_c.columns).fillna(0.0) + + # useeior: (LCI - E) / E → value=E_c, value_check=LCI + return validate_result( + 'compare_E_and_LCI_result', + _flatten_matrix_for_validate(E_c), + _flatten_matrix_for_validate(LCI), + tolerance=tolerance, + include_details=include_details, + ) + + +def run_model_identity_validations( + *, + cfg: ta.Optional[USAConfig] = None, + output_domestic: ta.Optional[pd.Series[float]] = None, + L_domestic: ta.Optional[pd.DataFrame] = None, + y_domestic: ta.Optional[pd.Series[float]] = None, + output_total: ta.Optional[pd.Series[float]] = None, + L_total: ta.Optional[pd.DataFrame] = None, + y_total: ta.Optional[pd.Series[float]] = None, + B: ta.Optional[pd.DataFrame] = None, + E_ind: ta.Optional[pd.DataFrame] = None, + V: ta.Optional[pd.DataFrame] = None, + x: ta.Optional[pd.Series[float]] = None, + Vnorm: ta.Optional[pd.DataFrame] = None, + q: ta.Optional[pd.Series[float]] = None, + industry_CPI_ratio: ta.Optional[pd.Series[float]] = None, + commodity_CPI_ratio: ta.Optional[pd.Series[float]] = None, + U_d: ta.Optional[pd.DataFrame] = None, + y_d: ta.Optional[pd.Series[float]] = None, + tolerance: float = 0.01, + include_details: bool = False, + log_results: bool = True, + check_precondition: bool = True, +) -> ta.List[DiagnosticResult]: + """Report-style orchestrator mirroring useeior ``printValidationResults``. + + Always asserts the year-alignment precondition first (unless + ``check_precondition=False``). Collects ``DiagnosticResult``s with + ``stop_on_failure=False`` — callers must not ``assert all(r.passed)`` over + the full suite when known xfails (total Ly, ``q≈U_d+y_d``) are included. + + CPI runs only when both CPI ratio series are provided; otherwise CPI is + skipped with a log note. Make/Use is not included (see issue #436). + """ + if check_precondition: + assert_eeio_year_alignment_precondition(cfg) + + diagnostics: ta.List[DiagnosticCallable] = [] + + if ( + output_domestic is not None + and L_domestic is not None + and y_domestic is not None + ): + out_d = output_domestic + L_d_m = L_domestic + y_d_m = y_domestic + + def _ly_dom() -> DiagnosticResult: + r = compare_output_vs_leontief_x_demand( + out_d, + L_d_m, + y_d_m, + tolerance=tolerance, + include_details=include_details, + ) + return dc.replace(r, name='Ly ≈ q (domestic)') + + diagnostics.append(_ly_dom) + + if output_total is not None and L_total is not None and y_total is not None: + out_t = output_total + L_t_m = L_total + y_t_m = y_total + + def _ly_tot() -> DiagnosticResult: + r = compare_output_vs_leontief_x_demand( + out_t, + L_t_m, + y_t_m, + tolerance=tolerance, + include_details=include_details, + ) + return dc.replace(r, name='Ly ≈ q (total)') + + diagnostics.append(_ly_tot) + + lci_inputs_ok = ( + B is not None + and E_ind is not None + and ( + (Vnorm is not None and q is not None and x is not None) + or (V is not None and x is not None) + ) + ) + if lci_inputs_ok and L_domestic is not None and y_domestic is not None: + B_lci = ta.cast(pd.DataFrame, B) + E_lci = ta.cast(pd.DataFrame, E_ind) + V_lci = V + x_lci = x + Vnorm_lci = Vnorm + q_lci = q + L_dom = L_domestic + y_dom = y_domestic + + def _lci_dom() -> DiagnosticResult: + r = compare_E_and_LCI_result( + B=B_lci, + L=L_dom, + y=y_dom, + E_ind=E_lci, + V=V_lci, + x=x_lci, + Vnorm=Vnorm_lci, + q=q_lci, + tolerance=tolerance, + include_details=include_details, + check_precondition=False, + cfg=cfg, + ) + return dc.replace(r, name='LCI ≈ E (domestic)') + + diagnostics.append(_lci_dom) + + if lci_inputs_ok and L_total is not None and y_total is not None: + B_lci_t = ta.cast(pd.DataFrame, B) + E_lci_t = ta.cast(pd.DataFrame, E_ind) + V_lci_t = V + x_lci_t = x + Vnorm_lci_t = Vnorm + q_lci_t = q + L_tot = L_total + y_tot = y_total + + def _lci_tot() -> DiagnosticResult: + r = compare_E_and_LCI_result( + B=B_lci_t, + L=L_tot, + y=y_tot, + E_ind=E_lci_t, + V=V_lci_t, + x=x_lci_t, + Vnorm=Vnorm_lci_t, + q=q_lci_t, + tolerance=tolerance, + include_details=include_details, + check_precondition=False, + cfg=cfg, + ) + return dc.replace(r, name='LCI ≈ E (total)') + + diagnostics.append(_lci_tot) + + if ( + V is not None + and q is not None + and x is not None + and industry_CPI_ratio is not None + and commodity_CPI_ratio is not None + ): + V_cpi = V + q_cpi = q + x_cpi = x + ind_cpi = industry_CPI_ratio + com_cpi = commodity_CPI_ratio + + def _cpi() -> DiagnosticResult: + return commodity_industry_output_cpi_consistency( + V=V_cpi, + q=q_cpi, + x=x_cpi, + industry_CPI_ratio=ind_cpi, + commodity_CPI_ratio=com_cpi, + tolerance=tolerance, + include_details=include_details, + ) + + diagnostics.append(_cpi) + elif industry_CPI_ratio is None or commodity_CPI_ratio is None: + logger.info( + 'Skipping CPI market-share check: industry/commodity CPI ratios not provided' + ) + + if q is not None and U_d is not None and y_d is not None: + q_u = q + U_du = U_d + y_du = y_d + + def _qu() -> DiagnosticResult: + r = compare_commodity_output_to_domestics_use_plus_exports( + q=q_u, + U_d=U_du, + y_d=y_du, + tolerance=tolerance, + include_details=include_details, + ) + return dc.replace(r, name='q ≈ U_d + y_d') + + diagnostics.append(_qu) + + return run_all_diagnostics( + diagnostics, log_results=log_results, stop_on_failure=False + ) + + +def print_validation_results( + *, + cfg: ta.Optional[USAConfig] = None, + **kwargs: ta.Any, +) -> ta.List[DiagnosticResult]: + """Alias for :func:`run_model_identity_validations` (useeior naming).""" + return run_model_identity_validations(cfg=cfg, **kwargs) From 04c795ecbcb6882474f14e26df6826512d64d32c Mon Sep 17 00:00:00 2001 From: jvendries Date: Mon, 3 Aug 2026 16:29:39 -0400 Subject: [PATCH 2/3] Remove unnecessary tests --- bedrock/utils/validation/__init__.py | 20 +- .../__tests__/test_eeio_diagnostics.py | 154 +++++----- bedrock/utils/validation/eeio_diagnostics.py | 279 +++--------------- 3 files changed, 122 insertions(+), 331 deletions(-) diff --git a/bedrock/utils/validation/__init__.py b/bedrock/utils/validation/__init__.py index 493f8bf3..eb17aec8 100644 --- a/bedrock/utils/validation/__init__.py +++ b/bedrock/utils/validation/__init__.py @@ -9,20 +9,16 @@ compare_output_vs_leontief_x_demand, eeio_year_alignment_precondition_ok, format_diagnostic_result, - print_validation_results, run_all_diagnostics, - run_model_identity_validations, ) __all__ = [ - 'DiagnosticResult', - 'assert_eeio_year_alignment_precondition', - 'compare_E_and_LCI_result', - 'compare_commodity_output_to_domestics_use_plus_exports', - 'compare_output_vs_leontief_x_demand', - 'eeio_year_alignment_precondition_ok', - 'format_diagnostic_result', - 'print_validation_results', - 'run_all_diagnostics', - 'run_model_identity_validations', + "DiagnosticResult", + "assert_eeio_year_alignment_precondition", + "compare_E_and_LCI_result", + "compare_commodity_output_to_domestics_use_plus_exports", + "compare_output_vs_leontief_x_demand", + "eeio_year_alignment_precondition_ok", + "format_diagnostic_result", + "run_all_diagnostics", ] diff --git a/bedrock/utils/validation/__tests__/test_eeio_diagnostics.py b/bedrock/utils/validation/__tests__/test_eeio_diagnostics.py index 8dd02a0a..0b6c1cc8 100644 --- a/bedrock/utils/validation/__tests__/test_eeio_diagnostics.py +++ b/bedrock/utils/validation/__tests__/test_eeio_diagnostics.py @@ -58,14 +58,14 @@ class TestDiagnosticResult: def test_basic_passing_result(self) -> None: """Test basic instantiation with a passing result.""" result = DiagnosticResult( - name='Row sum check', + name="Row sum check", passed=True, tolerance=0.01, max_rel_diff=0.005, failing_sectors=[], ) - assert result.name == 'Row sum check' + assert result.name == "Row sum check" assert result.passed is True assert result.tolerance == 0.01 assert result.max_rel_diff == 0.005 @@ -75,53 +75,53 @@ def test_basic_passing_result(self) -> None: def test_failed_result_with_failing_sectors(self) -> None: """Test a failed result with sectors that failed the check.""" result = DiagnosticResult( - name='Column sum check', + name="Column sum check", passed=False, tolerance=0.01, max_rel_diff=0.05, - failing_sectors=['11', '21', '31'], + failing_sectors=["11", "21", "31"], ) assert result.passed is False assert len(result.failing_sectors) == 3 - assert '11' in result.failing_sectors - assert '21' in result.failing_sectors - assert '31' in result.failing_sectors + assert "11" in result.failing_sectors + assert "21" in result.failing_sectors + assert "31" in result.failing_sectors assert result.max_rel_diff == 0.05 def test_result_with_details_dataframe(self) -> None: """Test a result with a details DataFrame.""" details_df = pd.DataFrame( { - 'sector': ['11', '21'], - 'expected': [100.0, 200.0], - 'actual': [105.0, 195.0], - 'rel_diff': [0.05, 0.025], + "sector": ["11", "21"], + "expected": [100.0, 200.0], + "actual": [105.0, 195.0], + "rel_diff": [0.05, 0.025], } ) result = DiagnosticResult( - name='Detailed check', + name="Detailed check", passed=False, tolerance=0.01, max_rel_diff=0.05, - failing_sectors=['11'], + failing_sectors=["11"], details=details_df, ) assert result.details is not None assert isinstance(result.details, pd.DataFrame) assert len(result.details) == 2 - assert 'sector' in result.details.columns - assert 'expected' in result.details.columns - assert 'actual' in result.details.columns - assert 'rel_diff' in result.details.columns + assert "sector" in result.details.columns + assert "expected" in result.details.columns + assert "actual" in result.details.columns + assert "rel_diff" in result.details.columns def test_negative_tolerance_raises_error(self) -> None: """Test that negative tolerance raises ValueError.""" - with pytest.raises(ValueError, match='Tolerance must be non-negative'): + with pytest.raises(ValueError, match="Tolerance must be non-negative"): DiagnosticResult( - name='Invalid check', + name="Invalid check", passed=True, tolerance=-0.01, max_rel_diff=0.005, @@ -130,9 +130,9 @@ def test_negative_tolerance_raises_error(self) -> None: def test_negative_max_rel_diff_raises_error(self) -> None: """Test that negative max_rel_diff raises ValueError.""" - with pytest.raises(ValueError, match='max_rel_diff must be non-negative'): + with pytest.raises(ValueError, match="max_rel_diff must be non-negative"): DiagnosticResult( - name='Invalid check', + name="Invalid check", passed=True, tolerance=0.01, max_rel_diff=-0.005, @@ -142,7 +142,7 @@ def test_negative_max_rel_diff_raises_error(self) -> None: def test_zero_tolerance_is_valid(self) -> None: """Test that zero tolerance is accepted (edge case).""" result = DiagnosticResult( - name='Exact match check', + name="Exact match check", passed=True, tolerance=0.0, max_rel_diff=0.0, @@ -158,32 +158,32 @@ class TestValidateResult: def test_zero_value_tiny_residual_passes(self) -> None: """Sectors with q=0 compare absolute residual against atol, not rel_diff.""" - value = pd.Series({'S00402': 0.0, '1111A0': 100.0}) - value_check = pd.Series({'S00402': 7.6e-6, '1111A0': 100.5}) + value = pd.Series({"S00402": 0.0, "1111A0": 100.0}) + value_check = pd.Series({"S00402": 7.6e-6, "1111A0": 100.5}) - result = validate_result('zero q', value, value_check, tolerance=0.01) + result = validate_result("zero q", value, value_check, tolerance=0.01) assert result.passed is True assert result.failing_sectors == [] assert result.max_rel_diff <= 1.0 def test_zero_value_large_residual_fails(self) -> None: - value = pd.Series({'S00402': 0.0}) - value_check = pd.Series({'S00402': 1.0}) + value = pd.Series({"S00402": 0.0}) + value_check = pd.Series({"S00402": 1.0}) - result = validate_result('zero q', value, value_check, tolerance=0.01) + result = validate_result("zero q", value, value_check, tolerance=0.01) assert result.passed is False - assert result.failing_sectors == ['S00402'] + assert result.failing_sectors == ["S00402"] def test_nonzero_value_uses_relative_tolerance(self) -> None: - value = pd.Series({'1111A0': 100.0}) - value_check = pd.Series({'1111A0': 102.0}) + value = pd.Series({"1111A0": 100.0}) + value_check = pd.Series({"1111A0": 102.0}) - result = validate_result('rel', value, value_check, tolerance=0.01) + result = validate_result("rel", value, value_check, tolerance=0.01) assert result.passed is False - assert result.failing_sectors == ['1111A0'] + assert result.failing_sectors == ["1111A0"] class TestFormatDiagnosticResult: @@ -192,7 +192,7 @@ class TestFormatDiagnosticResult: def test_format_passing_result(self) -> None: """Test formatting a passing diagnostic result.""" result = DiagnosticResult( - name='Row sum check', + name="Row sum check", passed=True, tolerance=0.01, max_rel_diff=0.005, @@ -201,33 +201,33 @@ def test_format_passing_result(self) -> None: formatted = format_diagnostic_result(result) - assert 'Diagnostic: Row sum check' in formatted - assert 'Status: PASSED' in formatted - assert 'Tolerance (rtol): 0.0100' in formatted - assert 'Max normalized residual: 0.0050 (pass if <= 1.0)' in formatted - assert 'Failing sectors: None' in formatted + assert "Diagnostic: Row sum check" in formatted + assert "Status: PASSED" in formatted + assert "Tolerance (rtol): 0.0100" in formatted + assert "Max normalized residual: 0.0050 (pass if <= 1.0)" in formatted + assert "Failing sectors: None" in formatted def test_format_failed_result_with_sectors(self) -> None: """Test formatting a failed result with failing sectors.""" result = DiagnosticResult( - name='Column sum check', + name="Column sum check", passed=False, tolerance=0.01, max_rel_diff=0.05, - failing_sectors=['11', '21'], + failing_sectors=["11", "21"], ) formatted = format_diagnostic_result(result) - assert 'Diagnostic: Column sum check' in formatted - assert 'Status: FAILED' in formatted - assert 'Failing sectors (2): 11, 21' in formatted + assert "Diagnostic: Column sum check" in formatted + assert "Status: FAILED" in formatted + assert "Failing sectors (2): 11, 21" in formatted def test_format_result_with_many_failing_sectors(self) -> None: """Test that formatting truncates when many sectors fail.""" many_sectors = [str(i) for i in range(15)] result = DiagnosticResult( - name='Many failures', + name="Many failures", passed=False, tolerance=0.01, max_rel_diff=0.05, @@ -236,8 +236,8 @@ def test_format_result_with_many_failing_sectors(self) -> None: formatted = format_diagnostic_result(result) - assert 'Failing sectors (15):' in formatted - assert '+5 more' in formatted + assert "Failing sectors (15):" in formatted + assert "+5 more" in formatted class TestRunAllDiagnostics: @@ -248,7 +248,7 @@ def test_run_single_passing_diagnostic(self) -> None: def passing_check() -> DiagnosticResult: return DiagnosticResult( - name='Passing check', + name="Passing check", passed=True, tolerance=0.01, max_rel_diff=0.005, @@ -265,7 +265,7 @@ def test_run_multiple_diagnostics(self) -> None: def check_a() -> DiagnosticResult: return DiagnosticResult( - name='Check A', + name="Check A", passed=True, tolerance=0.01, max_rel_diff=0.005, @@ -274,11 +274,11 @@ def check_a() -> DiagnosticResult: def check_b() -> DiagnosticResult: return DiagnosticResult( - name='Check B', + name="Check B", passed=False, tolerance=0.01, max_rel_diff=0.05, - failing_sectors=['11'], + failing_sectors=["11"], ) results = run_all_diagnostics([check_a, check_b], log_results=False) @@ -292,11 +292,11 @@ def test_stop_on_failure(self) -> None: def failing_check() -> DiagnosticResult: return DiagnosticResult( - name='Failing check', + name="Failing check", passed=False, tolerance=0.01, max_rel_diff=0.05, - failing_sectors=['11'], + failing_sectors=["11"], ) with pytest.raises(RuntimeError, match="Diagnostic 'Failing check' failed"): @@ -311,19 +311,19 @@ def test_continues_after_failure_by_default(self) -> None: call_order: list[str] = [] def check_a() -> DiagnosticResult: - call_order.append('a') + call_order.append("a") return DiagnosticResult( - name='Check A', + name="Check A", passed=False, tolerance=0.01, max_rel_diff=0.05, - failing_sectors=['11'], + failing_sectors=["11"], ) def check_b() -> DiagnosticResult: - call_order.append('b') + call_order.append("b") return DiagnosticResult( - name='Check B', + name="Check B", passed=True, tolerance=0.01, max_rel_diff=0.005, @@ -337,23 +337,23 @@ def check_b() -> DiagnosticResult: ) assert len(results) == 2 - assert call_order == ['a', 'b'] + assert call_order == ["a", "b"] @pytest.mark.eeio_integration @pytest.mark.parametrize( - 'pipeline', + "pipeline", [ pytest.param( - 'ceda', + "ceda", marks=pytest.mark.xfail( - reason='CEDA: q≠U_dom+y_d for 13 sectors after schema-alignment changes to 2017 detail trade/U.', + reason="CEDA: q≠U_dom+y_d for 13 sectors after schema-alignment changes to 2017 detail trade/U.", ), ), pytest.param( - 'cornerstone', + "cornerstone", marks=pytest.mark.xfail( - reason='Cornerstone: q≠U_dom+y_d for 13 sectors; BEA→CS remap and waste disagg break NAB identity.', + reason="Cornerstone: q≠U_dom+y_d for 13 sectors; BEA→CS remap and waste disagg break NAB identity.", ), ), ], @@ -361,7 +361,8 @@ def check_b() -> DiagnosticResult: def test_compare_Uset_y_dom_and_q_usa( pipeline: str, ) -> None: - if pipeline != 'cornerstone': + + if pipeline != "cornerstone": U_set = derive_2017_U_with_negatives() y_set = derive_2017_Ytot_usa_matrix_set() # CEDA has derive_detail_y_imp_usa(); it uses derive_2017_U_set_usa().Uimp @@ -405,23 +406,23 @@ def test_compare_Uset_y_dom_and_q_usa( @pytest.mark.eeio_integration @pytest.mark.parametrize( - 'modelType, use_domestic, pipeline', + "modelType, use_domestic, pipeline", [ - ('Commodity', True, 'cornerstone'), + ("Commodity", True, "cornerstone"), pytest.param( - 'Commodity', + "Commodity", False, - 'cornerstone', + "cornerstone", marks=pytest.mark.xfail( - reason='Cornerstone total L·y still uses ytot/trade, not y_nab.', + reason="Cornerstone total L·y still uses ytot/trade, not y_nab.", ), ), pytest.param( - 'Commodity', + "Commodity", False, - 'ceda', + "ceda", marks=pytest.mark.xfail( - reason='CEDA: scaled q≠L_total·y_total for ~298 commodity sectors (total Leontief identity).', + reason="CEDA: scaled q≠L_total·y_total for ~298 commodity sectors (total Leontief identity).", ), ), ], @@ -431,13 +432,14 @@ def test_compare_output_and_L_y( use_domestic: bool, pipeline: str, ) -> None: - if pipeline != 'cornerstone': + + if pipeline != "cornerstone": # CEDA: unscaled 2017-detail A and q; y built from 2017 Ytot/trade in IO year. Aq = derive_2017_Aq_usa() y_set = derive_2017_Ytot_usa_matrix_set() y_imp = derive_detail_y_imp_usa() output = ( - derive_2017_q_usa() if modelType == 'Commodity' else derive_2017_x_usa() + derive_2017_q_usa() if modelType == "Commodity" else derive_2017_x_usa() ) if use_domestic: y = y_set.ytot - y_imp + y_set.exports @@ -449,7 +451,7 @@ def test_compare_output_and_L_y( # Cornerstone scales A and q to model year; CEDA branch stays in 2017 detail. Aq = derive_cornerstone_Aq_scaled() # Output must match Aq scaling (scaled_q), not derive_cornerstone_q() from V. - output = Aq.scaled_q if modelType == 'Commodity' else derive_cornerstone_x() + output = Aq.scaled_q if modelType == "Commodity" else derive_cornerstone_x() if use_domestic: # y_nab from backcompute_y_from_A_and_q(Adom, scaled_q); unclipped. y = derive_cornerstone_y_nab() diff --git a/bedrock/utils/validation/eeio_diagnostics.py b/bedrock/utils/validation/eeio_diagnostics.py index d16a5067..a5a2a081 100644 --- a/bedrock/utils/validation/eeio_diagnostics.py +++ b/bedrock/utils/validation/eeio_diagnostics.py @@ -57,9 +57,9 @@ class DiagnosticResult: def __post_init__(self) -> None: """Validate the diagnostic result after initialization.""" if self.tolerance < 0: - raise ValueError('Tolerance must be non-negative') + raise ValueError("Tolerance must be non-negative") if self.max_rel_diff < 0: - raise ValueError('max_rel_diff must be non-negative') + raise ValueError("max_rel_diff must be non-negative") def format_diagnostic_result(result: DiagnosticResult) -> str: @@ -90,31 +90,31 @@ def format_diagnostic_result(result: DiagnosticResult) -> str: Max normalized residual: 1.5000 (pass if <= 1.0) Failing sectors (2): 11, 21 """ - status = 'PASSED' if result.passed else 'FAILED' + status = "PASSED" if result.passed else "FAILED" lines = [ - f'Diagnostic: {result.name}', - f'Status: {status}', - f'Tolerance (rtol): {result.tolerance:.4f}', - f'Max normalized residual: {result.max_rel_diff:.4f} (pass if <= 1.0)', + f"Diagnostic: {result.name}", + f"Status: {status}", + f"Tolerance (rtol): {result.tolerance:.4f}", + f"Max normalized residual: {result.max_rel_diff:.4f} (pass if <= 1.0)", ] if result.failing_sectors: sector_count = len(result.failing_sectors) # Limit display to first 10 sectors if many are failing if sector_count > 10: - displayed_sectors = ', '.join(result.failing_sectors[:10]) + displayed_sectors = ", ".join(result.failing_sectors[:10]) lines.append( - f'Failing sectors ({sector_count}): {displayed_sectors}, ... ' - f'(+{sector_count - 10} more)' + f"Failing sectors ({sector_count}): {displayed_sectors}, ... " + f"(+{sector_count - 10} more)" ) else: - displayed_sectors = ', '.join(result.failing_sectors) - lines.append(f'Failing sectors ({sector_count}): {displayed_sectors}') + displayed_sectors = ", ".join(result.failing_sectors) + lines.append(f"Failing sectors ({sector_count}): {displayed_sectors}") else: - lines.append('Failing sectors: None') + lines.append("Failing sectors: None") - return '\n'.join(lines) + return "\n".join(lines) DiagnosticCallable = ta.Callable[[], DiagnosticResult] @@ -172,21 +172,21 @@ def run_all_diagnostics( if stop_on_failure and not result.passed: raise RuntimeError( f"Diagnostic '{result.name}' failed. " - f'Max normalized residual: {result.max_rel_diff:.4f} ' - f'(pass if <= 1.0; rtol: {result.tolerance:.4f})' + f"Max normalized residual: {result.max_rel_diff:.4f} " + f"(pass if <= 1.0; rtol: {result.tolerance:.4f})" ) except Exception as e: if isinstance(e, RuntimeError) and stop_on_failure: raise # Log unexpected errors but continue with other diagnostics - logger.error(f'Error running diagnostic: {e}') + logger.error(f"Error running diagnostic: {e}") # Create a failed result for the error case error_result = DiagnosticResult( - name=f'Error in {diagnostic.__name__ if hasattr(diagnostic, "__name__") else "unknown"}', + name=f"Error in {diagnostic.__name__ if hasattr(diagnostic, '__name__') else 'unknown'}", passed=False, tolerance=0.0, - max_rel_diff=float('inf'), + max_rel_diff=float("inf"), failing_sectors=[], details=None, ) @@ -196,7 +196,7 @@ def run_all_diagnostics( if log_results and results: passed_count = sum(1 for r in results if r.passed) total_count = len(results) - summary = f'Diagnostics complete: {passed_count}/{total_count} passed' + summary = f"Diagnostics complete: {passed_count}/{total_count} passed" if passed_count == total_count: logger.info(summary) else: @@ -249,7 +249,7 @@ def validate_result( value_abs = value.abs() allowed = tolerance * value_abs + atol - with np.errstate(divide='ignore', invalid='ignore'): + with np.errstate(divide="ignore", invalid="ignore"): normalized = abs_diff / allowed normalized = normalized.replace([np.inf, -np.inf], np.nan).fillna(0.0) @@ -260,10 +260,10 @@ def validate_result( details = None if include_details: data = { - 'failing sectors': list(getattr(failing_sectors, 'index', failing_sectors)), - 'passing sectors': list(getattr(passing_sectors, 'index', passing_sectors)), - 'failing values': normalized.loc[failing_sectors].tolist(), - 'max_rel_diff': max_rd, + "failing sectors": list(getattr(failing_sectors, "index", failing_sectors)), + "passing sectors": list(getattr(passing_sectors, "index", passing_sectors)), + "failing values": normalized.loc[failing_sectors].tolist(), + "max_rel_diff": max_rd, } details = pd.DataFrame({key: pd.Series(value) for key, value in data.items()}) @@ -317,16 +317,16 @@ def compare_commodity_output_to_domestics_use_plus_exports( sectors = q.index.intersection(U_d.index).intersection(y_d.index) if len(sectors) != len(q.index): return DiagnosticResult( - name='Unequal number of sectors in arguments of compare_commodity_output_to_domestics_use_plus_exports', + name="Unequal number of sectors in arguments of compare_commodity_output_to_domestics_use_plus_exports", passed=False, tolerance=tolerance, - max_rel_diff=float('inf'), + max_rel_diff=float("inf"), failing_sectors=[], details=None, ) q_check = U_d.sum(axis=1) + y_d - name = 'commodity output and domestics use plus exports' + name = "commodity output and domestics use plus exports" d_result = validate_result( name, q, q_check, tolerance=tolerance, include_details=include_details @@ -375,17 +375,17 @@ def compare_output_vs_leontief_x_demand( sectors = output.index.intersection(L.index).intersection(y.index) if len(sectors) != len(output.index): return DiagnosticResult( - name='Unequal number of sectors in arguments of compare_commodity_output_to_domestics_use_plus_exports', + name="Unequal number of sectors in arguments of compare_commodity_output_to_domestics_use_plus_exports", passed=False, tolerance=tolerance, - max_rel_diff=float('inf'), + max_rel_diff=float("inf"), failing_sectors=[], details=None, ) # calculate scaling factor output_check = backcompute_q_from_L_and_y(L=L, y=y) - name = 'compare output and L * y' + name = "compare output and L * y" d_result = validate_result( name, output, output_check, tolerance=tolerance, include_details=include_details @@ -415,7 +415,7 @@ def commodity_industry_output_cpi_consistency( q_check = q * commodity_CPI_ratio x_check = C_m @ (x * industry_CPI_ratio) - name = 'commodity_industry_output_cpi_consistency' + name = "commodity_industry_output_cpi_consistency" d_result = validate_result( name, q_check, x_check, tolerance=tolerance, include_details=include_details @@ -436,16 +436,13 @@ def compare_output_from_make_and_use( """Check that Make-table and Use-table output agree for industry or commodity. Pass/fail and ``max_rel_diff`` are computed by ``validate_result``. - - Note: not part of the default useeior ``printValidationResults`` suite; - Cornerstone Make/Use mismatches are tracked in GitHub issue #436. """ - if output == 'Industry': + if output == "Industry": x_make = V.sum(axis=1) x_use = U.sum(axis=0) + VA.sum(axis=0) - name = 'compare_industry_output_from_make_and_use' + name = "compare_industry_output_from_make_and_use" d_result = validate_result( name, x_make, @@ -453,11 +450,11 @@ def compare_output_from_make_and_use( tolerance=tolerance, include_details=include_details, ) - elif output == 'Commodity': + elif output == "Commodity": q_make = V.sum(axis=0) q_use = U.sum(axis=1) + (y_set.ytot + y_set.exports - y_set.imports) - name = 'compare_commodity_output_from_make_and_use' + name = "compare_commodity_output_from_make_and_use" d_result = validate_result( name, q_make, @@ -669,207 +666,3 @@ def compare_E_and_LCI_result( tolerance=tolerance, include_details=include_details, ) - - -def run_model_identity_validations( - *, - cfg: ta.Optional[USAConfig] = None, - output_domestic: ta.Optional[pd.Series[float]] = None, - L_domestic: ta.Optional[pd.DataFrame] = None, - y_domestic: ta.Optional[pd.Series[float]] = None, - output_total: ta.Optional[pd.Series[float]] = None, - L_total: ta.Optional[pd.DataFrame] = None, - y_total: ta.Optional[pd.Series[float]] = None, - B: ta.Optional[pd.DataFrame] = None, - E_ind: ta.Optional[pd.DataFrame] = None, - V: ta.Optional[pd.DataFrame] = None, - x: ta.Optional[pd.Series[float]] = None, - Vnorm: ta.Optional[pd.DataFrame] = None, - q: ta.Optional[pd.Series[float]] = None, - industry_CPI_ratio: ta.Optional[pd.Series[float]] = None, - commodity_CPI_ratio: ta.Optional[pd.Series[float]] = None, - U_d: ta.Optional[pd.DataFrame] = None, - y_d: ta.Optional[pd.Series[float]] = None, - tolerance: float = 0.01, - include_details: bool = False, - log_results: bool = True, - check_precondition: bool = True, -) -> ta.List[DiagnosticResult]: - """Report-style orchestrator mirroring useeior ``printValidationResults``. - - Always asserts the year-alignment precondition first (unless - ``check_precondition=False``). Collects ``DiagnosticResult``s with - ``stop_on_failure=False`` — callers must not ``assert all(r.passed)`` over - the full suite when known xfails (total Ly, ``q≈U_d+y_d``) are included. - - CPI runs only when both CPI ratio series are provided; otherwise CPI is - skipped with a log note. Make/Use is not included (see issue #436). - """ - if check_precondition: - assert_eeio_year_alignment_precondition(cfg) - - diagnostics: ta.List[DiagnosticCallable] = [] - - if ( - output_domestic is not None - and L_domestic is not None - and y_domestic is not None - ): - out_d = output_domestic - L_d_m = L_domestic - y_d_m = y_domestic - - def _ly_dom() -> DiagnosticResult: - r = compare_output_vs_leontief_x_demand( - out_d, - L_d_m, - y_d_m, - tolerance=tolerance, - include_details=include_details, - ) - return dc.replace(r, name='Ly ≈ q (domestic)') - - diagnostics.append(_ly_dom) - - if output_total is not None and L_total is not None and y_total is not None: - out_t = output_total - L_t_m = L_total - y_t_m = y_total - - def _ly_tot() -> DiagnosticResult: - r = compare_output_vs_leontief_x_demand( - out_t, - L_t_m, - y_t_m, - tolerance=tolerance, - include_details=include_details, - ) - return dc.replace(r, name='Ly ≈ q (total)') - - diagnostics.append(_ly_tot) - - lci_inputs_ok = ( - B is not None - and E_ind is not None - and ( - (Vnorm is not None and q is not None and x is not None) - or (V is not None and x is not None) - ) - ) - if lci_inputs_ok and L_domestic is not None and y_domestic is not None: - B_lci = ta.cast(pd.DataFrame, B) - E_lci = ta.cast(pd.DataFrame, E_ind) - V_lci = V - x_lci = x - Vnorm_lci = Vnorm - q_lci = q - L_dom = L_domestic - y_dom = y_domestic - - def _lci_dom() -> DiagnosticResult: - r = compare_E_and_LCI_result( - B=B_lci, - L=L_dom, - y=y_dom, - E_ind=E_lci, - V=V_lci, - x=x_lci, - Vnorm=Vnorm_lci, - q=q_lci, - tolerance=tolerance, - include_details=include_details, - check_precondition=False, - cfg=cfg, - ) - return dc.replace(r, name='LCI ≈ E (domestic)') - - diagnostics.append(_lci_dom) - - if lci_inputs_ok and L_total is not None and y_total is not None: - B_lci_t = ta.cast(pd.DataFrame, B) - E_lci_t = ta.cast(pd.DataFrame, E_ind) - V_lci_t = V - x_lci_t = x - Vnorm_lci_t = Vnorm - q_lci_t = q - L_tot = L_total - y_tot = y_total - - def _lci_tot() -> DiagnosticResult: - r = compare_E_and_LCI_result( - B=B_lci_t, - L=L_tot, - y=y_tot, - E_ind=E_lci_t, - V=V_lci_t, - x=x_lci_t, - Vnorm=Vnorm_lci_t, - q=q_lci_t, - tolerance=tolerance, - include_details=include_details, - check_precondition=False, - cfg=cfg, - ) - return dc.replace(r, name='LCI ≈ E (total)') - - diagnostics.append(_lci_tot) - - if ( - V is not None - and q is not None - and x is not None - and industry_CPI_ratio is not None - and commodity_CPI_ratio is not None - ): - V_cpi = V - q_cpi = q - x_cpi = x - ind_cpi = industry_CPI_ratio - com_cpi = commodity_CPI_ratio - - def _cpi() -> DiagnosticResult: - return commodity_industry_output_cpi_consistency( - V=V_cpi, - q=q_cpi, - x=x_cpi, - industry_CPI_ratio=ind_cpi, - commodity_CPI_ratio=com_cpi, - tolerance=tolerance, - include_details=include_details, - ) - - diagnostics.append(_cpi) - elif industry_CPI_ratio is None or commodity_CPI_ratio is None: - logger.info( - 'Skipping CPI market-share check: industry/commodity CPI ratios not provided' - ) - - if q is not None and U_d is not None and y_d is not None: - q_u = q - U_du = U_d - y_du = y_d - - def _qu() -> DiagnosticResult: - r = compare_commodity_output_to_domestics_use_plus_exports( - q=q_u, - U_d=U_du, - y_d=y_du, - tolerance=tolerance, - include_details=include_details, - ) - return dc.replace(r, name='q ≈ U_d + y_d') - - diagnostics.append(_qu) - - return run_all_diagnostics( - diagnostics, log_results=log_results, stop_on_failure=False - ) - - -def print_validation_results( - *, - cfg: ta.Optional[USAConfig] = None, - **kwargs: ta.Any, -) -> ta.List[DiagnosticResult]: - """Alias for :func:`run_model_identity_validations` (useeior naming).""" - return run_model_identity_validations(cfg=cfg, **kwargs) From a3d4b826faf8cd454fa226decf1ee6e43a54b4c2 Mon Sep 17 00:00:00 2001 From: jvendries Date: Mon, 3 Aug 2026 16:47:04 -0400 Subject: [PATCH 3/3] Add LCI = E (total) test --- .../__tests__/test_eeio_diagnostics.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/bedrock/utils/validation/__tests__/test_eeio_diagnostics.py b/bedrock/utils/validation/__tests__/test_eeio_diagnostics.py index 0b6c1cc8..11143e4f 100644 --- a/bedrock/utils/validation/__tests__/test_eeio_diagnostics.py +++ b/bedrock/utils/validation/__tests__/test_eeio_diagnostics.py @@ -610,3 +610,51 @@ def test_v0_3_domestic_lci_equals_e() -> None: ) finally: reset_usa_config(should_reset_env_var=True) + + +@pytest.mark.eeio_integration +@pytest.mark.xfail( + reason=( + 'Cornerstone total LCI≈E uses y_trade (ytot/trade), not IO-balanced ' + 'throughput; same imbalance as total Ly≈q (~1749 cells fail).' + ), +) +def test_v0_3_total_lci_equals_e() -> None: + """Live total LCI≈E under aligned v0.3 (Vnorm path; L_tot / y_trade pairing).""" + reset_usa_config(should_reset_env_var=True) + set_global_usa_config('2025_usa_cornerstone_v0_3.yaml') + try: + from bedrock.transform.allocation.derived import derive_E_usa + from bedrock.transform.eeio.derived_cornerstone import ( + derive_cornerstone_Aq_scaled, + derive_cornerstone_B_non_finetuned, + derive_cornerstone_Vnorm_scrap_corrected, + derive_cornerstone_x_after_redefinition, + derive_cornerstone_Y_and_trade_scaled, + ) + from bedrock.utils.math.formulas import compute_L_matrix + + assert_eeio_year_alignment_precondition() + + Aq = derive_cornerstone_Aq_scaled() + L_tot = compute_L_matrix(A=Aq.Adom + Aq.Aimp) + y_trade = derive_cornerstone_Y_and_trade_scaled() + y_tot = y_trade.ytot + y_trade.exports - y_trade.imports + result = compare_E_and_LCI_result( + B=derive_cornerstone_B_non_finetuned(), + L=L_tot, + y=y_tot, + E_ind=derive_E_usa(), + x=derive_cornerstone_x_after_redefinition(), + Vnorm=derive_cornerstone_Vnorm_scrap_corrected(), + q=Aq.scaled_q, + tolerance=0.01, + include_details=True, + check_precondition=False, + ) + assert result.passed, ( + f'total LCI≈E failed ({len(result.failing_sectors)} cells): ' + f'{result.failing_sectors[:20]}' + ) + finally: + reset_usa_config(should_reset_env_var=True)