diff --git a/changelog.d/add-labor-supply-response-output.added.md b/changelog.d/add-labor-supply-response-output.added.md new file mode 100644 index 00000000..16847a0f --- /dev/null +++ b/changelog.d/add-labor-supply-response-output.added.md @@ -0,0 +1 @@ +- Add a legacy-compatible labor-supply response output for US and UK macro analyses. diff --git a/src/policyengine/core/dynamic.py b/src/policyengine/core/dynamic.py index d707b9b2..537ce805 100644 --- a/src/policyengine/core/dynamic.py +++ b/src/policyengine/core/dynamic.py @@ -14,6 +14,14 @@ class Dynamic(BaseModel): description: Optional[str] = None parameter_values: list[ParameterValue] = [] simulation_modifier: Optional[Callable] = None + affects_labor_supply_response: Optional[bool] = Field( + default=None, + description=( + "Whether this dynamic should materialize labor-supply response " + "outputs. None preserves conservative detection for unmarked " + "simulation modifiers." + ), + ) created_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now) @@ -39,9 +47,22 @@ def combined_modifier(sim): elif other.simulation_modifier is not None: combined_modifier = other.simulation_modifier + affects_labor_supply_response = None + if ( + self.affects_labor_supply_response is True + or other.affects_labor_supply_response is True + ): + affects_labor_supply_response = True + elif ( + self.affects_labor_supply_response is False + and other.affects_labor_supply_response is False + ): + affects_labor_supply_response = False + return Dynamic( name=f"{self.name} + {other.name}", description=f"Combined dynamic: {self.name} and {other.name}", parameter_values=self.parameter_values + other.parameter_values, simulation_modifier=combined_modifier, + affects_labor_supply_response=affects_labor_supply_response, ) diff --git a/src/policyengine/core/policy.py b/src/policyengine/core/policy.py index 3860a817..769a860a 100644 --- a/src/policyengine/core/policy.py +++ b/src/policyengine/core/policy.py @@ -14,6 +14,14 @@ class Policy(BaseModel): description: Optional[str] = None parameter_values: list[ParameterValue] = [] simulation_modifier: Optional[Callable] = None + affects_labor_supply_response: Optional[bool] = Field( + default=None, + description=( + "Whether this policy should materialize labor-supply response " + "outputs. None preserves conservative detection for unmarked " + "simulation modifiers." + ), + ) created_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now) @@ -39,9 +47,22 @@ def combined_modifier(sim): elif other.simulation_modifier is not None: combined_modifier = other.simulation_modifier + affects_labor_supply_response = None + if ( + self.affects_labor_supply_response is True + or other.affects_labor_supply_response is True + ): + affects_labor_supply_response = True + elif ( + self.affects_labor_supply_response is False + and other.affects_labor_supply_response is False + ): + affects_labor_supply_response = False + return Policy( name=f"{self.name} + {other.name}", description=f"Combined policy: {self.name} and {other.name}", parameter_values=self.parameter_values + other.parameter_values, simulation_modifier=combined_modifier, + affects_labor_supply_response=affects_labor_supply_response, ) diff --git a/src/policyengine/outputs/__init__.py b/src/policyengine/outputs/__init__.py index 13ff2a26..00936706 100644 --- a/src/policyengine/outputs/__init__.py +++ b/src/policyengine/outputs/__init__.py @@ -28,6 +28,13 @@ IntraDecileImpact, compute_intra_decile_impacts, ) +from policyengine.outputs.labor_supply_response import ( + HoursResponse, + LaborSupplyResponse, + calculate_labor_supply_response, + configure_labor_supply_response_variables, + labor_supply_response_is_active, +) from policyengine.outputs.local_authority_impact import ( LocalAuthorityImpact, compute_uk_local_authority_impacts, @@ -63,6 +70,11 @@ "ProgramStatistics", "IntraDecileImpact", "compute_intra_decile_impacts", + "HoursResponse", + "LaborSupplyResponse", + "calculate_labor_supply_response", + "configure_labor_supply_response_variables", + "labor_supply_response_is_active", "Poverty", "UKPovertyType", "USPovertyType", diff --git a/src/policyengine/outputs/labor_supply_response.py b/src/policyengine/outputs/labor_supply_response.py new file mode 100644 index 00000000..fec2b6f7 --- /dev/null +++ b/src/policyengine/outputs/labor_supply_response.py @@ -0,0 +1,633 @@ +"""Legacy-compatible labor-supply response macro output.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Literal + +from microdf import MicroSeries +from pydantic import BaseModel + +from policyengine.core import Output, Simulation +from policyengine.outputs.aggregate import ( + get_aggregate_variable, + get_output_entity_data, + require_output_column, +) + +CountryCode = Literal["us", "uk"] +DecileValues = dict[int, float] + +US_LSR_PARAMETER_PREFIXES = ( + "gov.simulation.labor_supply_responses.elasticities.income", + "gov.simulation.labor_supply_responses.elasticities.substitution", +) +UK_LSR_PARAMETER_PREFIXES = ( + "gov.simulation.labour_supply_responses.income_elasticity", + "gov.simulation.labour_supply_responses.substitution_elasticity", + "gov.simulation.labor_supply_responses.income_elasticity", + "gov.simulation.labor_supply_responses.substitution_elasticity", +) + +US_ACTIVE_LSR_VARIABLES = { + "person": [ + # Keep LSR-only support columns out of default household outputs. + "self_employment_income", + "weekly_hours_worked", + "income_elasticity_lsr", + "substitution_elasticity_lsr", + "weekly_hours_worked_behavioural_response_income_elasticity", + "weekly_hours_worked_behavioural_response_substitution_elasticity", + ], +} +UK_ACTIVE_LSR_VARIABLES = { + "person": [ + "income_elasticity_lsr", + "substitution_elasticity_lsr", + ], +} + + +class HoursResponse(BaseModel): + baseline: float + reform: float + change: float + income_effect: float + substitution_effect: float + + +class LaborSupplyResponse(Output): + substitution_lsr: float + income_lsr: float + relative_lsr: dict[str, float] + total_change: float + revenue_change: float + decile: dict[str, dict[str, DecileValues]] + hours: HoursResponse + + +def _parameter_name(value: Any) -> str | None: + parameter = getattr(value, "parameter", None) + return getattr(parameter, "name", None) + + +def _iter_reform_parameter_names(source: Any): + if source is None: + return + if isinstance(source, Mapping): + yield from (str(key) for key in source) + return + for parameter_value in getattr(source, "parameter_values", []) or []: + name = _parameter_name(parameter_value) + if name is not None: + yield name + + +def _has_simulation_modifier(source: Any) -> bool: + return getattr(source, "simulation_modifier", None) is not None + + +def _explicit_labor_supply_response_marker(source: Any) -> bool | None: + return getattr(source, "affects_labor_supply_response", None) + + +def _labor_supply_parameter_prefixes(country_code: CountryCode) -> tuple[str, ...]: + if country_code == "us": + return US_LSR_PARAMETER_PREFIXES + if country_code == "uk": + return UK_LSR_PARAMETER_PREFIXES + raise ValueError( + f"Unsupported country_code for labor supply response: {country_code}" + ) + + +def _parameter_matches_labor_supply_response_prefix( + parameter_name: str, + prefixes: tuple[str, ...], +) -> bool: + return any( + parameter_name == prefix or parameter_name.startswith(f"{prefix}.") + for prefix in prefixes + ) + + +def _simulation_may_have_labor_supply_response( + simulation: Simulation, + country_code: CountryCode, +) -> bool: + prefixes = _labor_supply_parameter_prefixes(country_code) + + def source_may_have_labor_supply_response(source: Any) -> bool: + if any( + _parameter_matches_labor_supply_response_prefix(parameter_name, prefixes) + for parameter_name in _iter_reform_parameter_names(source) + ): + return True + + explicit_marker = _explicit_labor_supply_response_marker(source) + if explicit_marker is not None: + return explicit_marker + + return _has_simulation_modifier(source) + + return any( + source_may_have_labor_supply_response(source) + for source in (simulation.policy, simulation.dynamic) + ) + + +def labor_supply_response_is_active( + baseline_simulation: Simulation, + reform_simulation: Simulation, + *, + country_code: CountryCode, +) -> bool: + """Return whether either scenario may produce non-zero LSR output.""" + return _simulation_may_have_labor_supply_response( + baseline_simulation, + country_code, + ) or _simulation_may_have_labor_supply_response( + reform_simulation, + country_code, + ) + + +def _active_lsr_variables(country_code: CountryCode) -> dict[str, list[str]]: + if country_code == "us": + return US_ACTIVE_LSR_VARIABLES + if country_code == "uk": + return UK_ACTIVE_LSR_VARIABLES + raise ValueError( + f"Unsupported country_code for labor supply response: {country_code}" + ) + + +def _add_extra_variables( + simulation: Simulation, + variables_by_entity: dict[str, list[str]], +) -> None: + extra_variables = { + entity: list(variables) + for entity, variables in (simulation.extra_variables or {}).items() + } + for entity, variables in variables_by_entity.items(): + existing = extra_variables.setdefault(entity, []) + for variable in variables: + if variable not in existing: + existing.append(variable) + simulation.extra_variables = extra_variables + + +def configure_labor_supply_response_variables( + baseline_simulation: Simulation, + reform_simulation: Simulation, + *, + country_code: CountryCode, +) -> bool: + """Materialize expensive LSR columns only when a request needs them.""" + if not labor_supply_response_is_active( + baseline_simulation, + reform_simulation, + country_code=country_code, + ): + return False + + active_variables = _active_lsr_variables(country_code) + _add_extra_variables(baseline_simulation, active_variables) + _add_extra_variables(reform_simulation, active_variables) + return True + + +def _require_direct_output_column( + simulation: Simulation, + entity: str, + column: str, + context: str, +) -> None: + data = get_output_entity_data(simulation, entity, context) + if column in data.columns: + return + raise ValueError( + f"{context} requires simulation '{simulation.id}' output data for " + f"entity '{entity}' to include column '{column}'." + ) + + +def _has_direct_output_column( + simulation: Simulation, + entity: str, + column: str, +) -> bool: + data = get_output_entity_data( + simulation, + entity, + f"LaborSupplyResponse.{entity}", + ) + return column in data.columns + + +def _series_for_variable( + simulation: Simulation, + variable_name: str, + target_entity: str, + context: str, +) -> MicroSeries: + variable = get_aggregate_variable(simulation, variable_name, context) + target_data = get_output_entity_data(simulation, target_entity, context) + + if variable.entity != target_entity: + source_data = get_output_entity_data(simulation, variable.entity, context) + require_output_column( + source_data, + variable_name, + variable.entity, + simulation, + context, + ) + mapped = simulation.output_dataset.data.map_to_entity( + variable.entity, + target_entity, + columns=[variable_name], + ) + return mapped[variable_name] + + require_output_column( + target_data, + variable_name, + target_entity, + simulation, + context, + ) + return target_data[variable_name] + + +def _zero_household_series(baseline_simulation: Simulation) -> MicroSeries: + household_data = get_output_entity_data( + baseline_simulation, + "household", + "LaborSupplyResponse.household", + ) + _require_direct_output_column( + baseline_simulation, + "household", + "household_weight", + "LaborSupplyResponse.household_weight", + ) + return MicroSeries( + [0.0] * len(household_data), + weights=household_data["household_weight"].to_numpy(), + ) + + +def _positional_microseries(series: Any, weights: Any) -> MicroSeries: + return MicroSeries(series.to_numpy(), weights=weights) + + +def _decile_values(series: MicroSeries) -> DecileValues: + return {int(key): float(value) for key, value in series.to_dict().items()} + + +def _positive_decile_values(series: MicroSeries) -> DecileValues: + return { + int(key): float(value) + for key, value in series.to_dict().items() + if int(key) > 0 + } + + +def _safe_ratio(numerator: float, denominator: float) -> float: + if denominator == 0: + return 0.0 + return float(numerator / denominator) + + +def _positive_decile_ratios( + numerators: MicroSeries, + denominators: MicroSeries, +) -> DecileValues: + numerator_values = numerators.to_dict() + denominator_values = denominators.to_dict() + return { + int(key): _safe_ratio(float(numerator), float(denominator_values.get(key, 0.0))) + for key, numerator in numerator_values.items() + if int(key) > 0 + } + + +def _sum_variable( + simulation: Simulation, + variable_name: str, + entity: str, + context: str, +) -> float: + return float(_series_for_variable(simulation, variable_name, entity, context).sum()) + + +def _calculate_hours( + baseline_simulation: Simulation, + reform_simulation: Simulation, + *, + country_code: CountryCode, + active: bool, +) -> HoursResponse: + if country_code != "us": + return HoursResponse( + baseline=0.0, + reform=0.0, + change=0.0, + income_effect=0.0, + substitution_effect=0.0, + ) + + if _has_direct_output_column( + baseline_simulation, + "person", + "weekly_hours_worked", + ) and _has_direct_output_column( + reform_simulation, + "person", + "weekly_hours_worked", + ): + baseline_hours = _sum_variable( + baseline_simulation, + "weekly_hours_worked", + "person", + "LaborSupplyResponse.hours", + ) + reform_hours = _sum_variable( + reform_simulation, + "weekly_hours_worked", + "person", + "LaborSupplyResponse.hours", + ) + elif active: + baseline_hours = _sum_variable( + baseline_simulation, + "weekly_hours_worked", + "person", + "LaborSupplyResponse.hours", + ) + reform_hours = _sum_variable( + reform_simulation, + "weekly_hours_worked", + "person", + "LaborSupplyResponse.hours", + ) + else: + baseline_hours = 0.0 + reform_hours = 0.0 + + if active: + baseline_income_effect = _sum_variable( + baseline_simulation, + "weekly_hours_worked_behavioural_response_income_elasticity", + "person", + "LaborSupplyResponse.hours.income_effect", + ) + reform_income_effect = _sum_variable( + reform_simulation, + "weekly_hours_worked_behavioural_response_income_elasticity", + "person", + "LaborSupplyResponse.hours.income_effect", + ) + baseline_substitution_effect = _sum_variable( + baseline_simulation, + "weekly_hours_worked_behavioural_response_substitution_elasticity", + "person", + "LaborSupplyResponse.hours.substitution_effect", + ) + reform_substitution_effect = _sum_variable( + reform_simulation, + "weekly_hours_worked_behavioural_response_substitution_elasticity", + "person", + "LaborSupplyResponse.hours.substitution_effect", + ) + else: + baseline_income_effect = 0.0 + reform_income_effect = 0.0 + baseline_substitution_effect = 0.0 + reform_substitution_effect = 0.0 + + return HoursResponse( + baseline=baseline_hours, + reform=reform_hours, + change=reform_hours - baseline_hours, + income_effect=reform_income_effect - baseline_income_effect, + substitution_effect=reform_substitution_effect - baseline_substitution_effect, + ) + + +def calculate_labor_supply_response( + baseline_simulation: Simulation, + reform_simulation: Simulation, + *, + country_code: CountryCode, +) -> LaborSupplyResponse: + """Calculate legacy macro labor-supply response output.""" + active = labor_supply_response_is_active( + baseline_simulation, + reform_simulation, + country_code=country_code, + ) + + if active: + baseline_income_lsr = _sum_variable( + baseline_simulation, + "income_elasticity_lsr", + "person", + "LaborSupplyResponse.income_lsr", + ) + reform_income_lsr = _sum_variable( + reform_simulation, + "income_elasticity_lsr", + "person", + "LaborSupplyResponse.income_lsr", + ) + baseline_substitution_lsr = _sum_variable( + baseline_simulation, + "substitution_elasticity_lsr", + "person", + "LaborSupplyResponse.substitution_lsr", + ) + reform_substitution_lsr = _sum_variable( + reform_simulation, + "substitution_elasticity_lsr", + "person", + "LaborSupplyResponse.substitution_lsr", + ) + + income_lsr_hh = _series_for_variable( + reform_simulation, + "income_elasticity_lsr", + "household", + "LaborSupplyResponse.income_lsr_hh", + ) - _series_for_variable( + baseline_simulation, + "income_elasticity_lsr", + "household", + "LaborSupplyResponse.income_lsr_hh", + ) + substitution_lsr_hh = _series_for_variable( + reform_simulation, + "substitution_elasticity_lsr", + "household", + "LaborSupplyResponse.substitution_lsr_hh", + ) - _series_for_variable( + baseline_simulation, + "substitution_elasticity_lsr", + "household", + "LaborSupplyResponse.substitution_lsr_hh", + ) + else: + baseline_income_lsr = 0.0 + reform_income_lsr = 0.0 + baseline_substitution_lsr = 0.0 + reform_substitution_lsr = 0.0 + income_lsr_hh = _zero_household_series(baseline_simulation) + substitution_lsr_hh = _zero_household_series(baseline_simulation) + + income_lsr = reform_income_lsr - baseline_income_lsr + substitution_lsr = reform_substitution_lsr - baseline_substitution_lsr + total_change = substitution_lsr + income_lsr + + if not active: + household_data = get_output_entity_data( + baseline_simulation, + "household", + "LaborSupplyResponse.household", + ) + _require_direct_output_column( + baseline_simulation, + "household", + "household_income_decile", + "LaborSupplyResponse.household_income_decile", + ) + decile = household_data["household_income_decile"].to_numpy() + zero_lsr_hh = _zero_household_series(baseline_simulation) + decile_average = _decile_values(zero_lsr_hh.groupby(decile).mean()) + decile_relative = _positive_decile_values(zero_lsr_hh.groupby(decile).sum()) + + return LaborSupplyResponse( + substitution_lsr=0.0, + income_lsr=0.0, + relative_lsr={ + "income": 0.0, + "substitution": 0.0, + }, + total_change=0.0, + # Legacy ``budgetary_impact_lsr`` was initialised to zero and not + # recalculated, so preserve the effective public value. + revenue_change=0.0, + decile={ + "average": { + "income": decile_average, + "substitution": decile_average, + }, + "relative": { + "income": decile_relative, + "substitution": decile_relative, + }, + }, + hours=_calculate_hours( + baseline_simulation, + reform_simulation, + country_code=country_code, + active=False, + ), + ) + + household_data = get_output_entity_data( + baseline_simulation, + "household", + "LaborSupplyResponse.household", + ) + _require_direct_output_column( + baseline_simulation, + "household", + "household_income_decile", + "LaborSupplyResponse.household_income_decile", + ) + _require_direct_output_column( + baseline_simulation, + "household", + "household_weight", + "LaborSupplyResponse.household_weight", + ) + decile = household_data["household_income_decile"].to_numpy() + household_weight = household_data["household_weight"].to_numpy() + + employment_income_hh = _positional_microseries( + _series_for_variable( + baseline_simulation, + "employment_income", + "household", + "LaborSupplyResponse.employment_income_hh", + ), + household_weight, + ) + self_employment_income_hh = _positional_microseries( + _series_for_variable( + baseline_simulation, + "self_employment_income", + "household", + "LaborSupplyResponse.self_employment_income_hh", + ), + household_weight, + ) + + income_lsr_hh = _positional_microseries(income_lsr_hh, household_weight) + substitution_lsr_hh = _positional_microseries( + substitution_lsr_hh, + household_weight, + ) + total_lsr_hh = MicroSeries( + income_lsr_hh.to_numpy() + substitution_lsr_hh.to_numpy(), + weights=household_weight, + ) + original_earnings = MicroSeries( + employment_income_hh.to_numpy() + + self_employment_income_hh.to_numpy() + - total_lsr_hh.to_numpy(), + weights=household_weight, + ) + + decile_average = { + "income": _decile_values(income_lsr_hh.groupby(decile).mean()), + "substitution": _decile_values(substitution_lsr_hh.groupby(decile).mean()), + } + decile_relative = { + "income": _positive_decile_ratios( + income_lsr_hh.groupby(decile).sum(), + original_earnings.groupby(decile).sum(), + ), + "substitution": _positive_decile_ratios( + substitution_lsr_hh.groupby(decile).sum(), + original_earnings.groupby(decile).sum(), + ), + } + + return LaborSupplyResponse( + substitution_lsr=float(substitution_lsr), + income_lsr=float(income_lsr), + relative_lsr={ + "income": _safe_ratio(income_lsr_hh.sum(), original_earnings.sum()), + "substitution": _safe_ratio( + substitution_lsr_hh.sum(), + original_earnings.sum(), + ), + }, + total_change=float(total_change), + # Legacy ``budgetary_impact_lsr`` was initialised to zero and not + # recalculated, so preserve the effective public value. + revenue_change=0.0, + decile={ + "average": decile_average, + "relative": decile_relative, + }, + hours=_calculate_hours( + baseline_simulation, + reform_simulation, + country_code=country_code, + active=active, + ), + ) diff --git a/src/policyengine/tax_benefit_models/uk/__init__.py b/src/policyengine/tax_benefit_models/uk/__init__.py index 3ab098e2..338d1805 100644 --- a/src/policyengine/tax_benefit_models/uk/__init__.py +++ b/src/policyengine/tax_benefit_models/uk/__init__.py @@ -15,7 +15,7 @@ if find_spec("policyengine_uk") is not None: from policyengine.core import Dataset - from policyengine.outputs import ProgramStatistics + from policyengine.outputs import LaborSupplyResponse, ProgramStatistics from .analysis import economic_impact_analysis from .datasets import ( @@ -41,6 +41,7 @@ PolicyEngineUKDataset.model_rebuild() PolicyEngineUKLatest.model_rebuild() ProgramStatistics.model_rebuild() + LaborSupplyResponse.model_rebuild() __all__ = [ "UKYearData", @@ -56,6 +57,7 @@ "calculate_household", "economic_impact_analysis", "ProgramStatistics", + "LaborSupplyResponse", ] else: __all__ = [] diff --git a/src/policyengine/tax_benefit_models/uk/analysis.py b/src/policyengine/tax_benefit_models/uk/analysis.py index f37d18be..130a978b 100644 --- a/src/policyengine/tax_benefit_models/uk/analysis.py +++ b/src/policyengine/tax_benefit_models/uk/analysis.py @@ -10,7 +10,12 @@ from pydantic import BaseModel from policyengine.core import OutputCollection, Simulation -from policyengine.outputs import ProgramStatistics +from policyengine.outputs import ( + LaborSupplyResponse, + ProgramStatistics, + calculate_labor_supply_response, + configure_labor_supply_response_variables, +) from policyengine.outputs.decile_impact import ( DecileImpact, calculate_decile_impacts, @@ -34,6 +39,7 @@ class PolicyReformAnalysis(BaseModel): reform_poverty: OutputCollection[Poverty] baseline_inequality: Inequality reform_inequality: Inequality + labor_supply_response: LaborSupplyResponse def economic_impact_analysis( @@ -41,6 +47,11 @@ def economic_impact_analysis( reform_simulation: Simulation, ) -> PolicyReformAnalysis: """Perform comprehensive analysis of a UK policy reform.""" + configure_labor_supply_response_variables( + baseline_simulation, + reform_simulation, + country_code="uk", + ) baseline_simulation.ensure() reform_simulation.ensure() @@ -111,6 +122,11 @@ def economic_impact_analysis( reform_poverty = calculate_uk_poverty_rates(reform_simulation) baseline_inequality = calculate_uk_inequality(baseline_simulation) reform_inequality = calculate_uk_inequality(reform_simulation) + labor_supply_response = calculate_labor_supply_response( + baseline_simulation, + reform_simulation, + country_code="uk", + ) return PolicyReformAnalysis( decile_impacts=decile_impacts, @@ -119,4 +135,5 @@ def economic_impact_analysis( reform_poverty=reform_poverty, baseline_inequality=baseline_inequality, reform_inequality=reform_inequality, + labor_supply_response=labor_supply_response, ) diff --git a/src/policyengine/tax_benefit_models/us/__init__.py b/src/policyengine/tax_benefit_models/us/__init__.py index d49d46d4..623e445b 100644 --- a/src/policyengine/tax_benefit_models/us/__init__.py +++ b/src/policyengine/tax_benefit_models/us/__init__.py @@ -28,7 +28,7 @@ if find_spec("policyengine_us") is not None: from policyengine.core import Dataset - from policyengine.outputs import ProgramStatistics + from policyengine.outputs import LaborSupplyResponse, ProgramStatistics from .analysis import economic_impact_analysis from .datasets import ( @@ -54,6 +54,7 @@ PolicyEngineUSDataset.model_rebuild() PolicyEngineUSLatest.model_rebuild() ProgramStatistics.model_rebuild() + LaborSupplyResponse.model_rebuild() __all__ = [ "USYearData", @@ -69,6 +70,7 @@ "calculate_household", "economic_impact_analysis", "ProgramStatistics", + "LaborSupplyResponse", ] else: __all__ = [] diff --git a/src/policyengine/tax_benefit_models/us/analysis.py b/src/policyengine/tax_benefit_models/us/analysis.py index a285020d..26359480 100644 --- a/src/policyengine/tax_benefit_models/us/analysis.py +++ b/src/policyengine/tax_benefit_models/us/analysis.py @@ -12,7 +12,12 @@ from pydantic import BaseModel from policyengine.core import OutputCollection, Simulation -from policyengine.outputs import ProgramStatistics +from policyengine.outputs import ( + LaborSupplyResponse, + ProgramStatistics, + calculate_labor_supply_response, + configure_labor_supply_response_variables, +) from policyengine.outputs.decile_impact import ( DecileImpact, calculate_decile_impacts, @@ -57,6 +62,7 @@ class PolicyReformAnalysis(BaseModel): reform_poverty: OutputCollection[Poverty] baseline_inequality: Inequality reform_inequality: Inequality + labor_supply_response: LaborSupplyResponse def _format_missing_program_variables(missing_variables: set[str]) -> str | None: @@ -140,6 +146,11 @@ def economic_impact_analysis( ``PolicyReformAnalysis`` with decile impacts, program statistics, baseline and reform poverty, and inequality. """ + configure_labor_supply_response_variables( + baseline_simulation, + reform_simulation, + country_code="us", + ) _validate_program_statistics_config(baseline_simulation, reform_simulation) baseline_simulation.ensure() @@ -202,6 +213,11 @@ def economic_impact_analysis( reform_inequality = calculate_us_inequality( reform_simulation, preset=inequality_preset ) + labor_supply_response = calculate_labor_supply_response( + baseline_simulation, + reform_simulation, + country_code="us", + ) return PolicyReformAnalysis( decile_impacts=decile_impacts, @@ -210,4 +226,5 @@ def economic_impact_analysis( reform_poverty=reform_poverty, baseline_inequality=baseline_inequality, reform_inequality=reform_inequality, + labor_supply_response=labor_supply_response, ) diff --git a/tests/test_labor_supply_response.py b/tests/test_labor_supply_response.py new file mode 100644 index 00000000..518072eb --- /dev/null +++ b/tests/test_labor_supply_response.py @@ -0,0 +1,964 @@ +import datetime +from typing import Optional + +import pandas as pd +import pytest +from microdf import MicroDataFrame + +from policyengine.core import Dynamic, Parameter, ParameterValue, Policy, Simulation +from policyengine.outputs import ( + LaborSupplyResponse, + calculate_labor_supply_response, + configure_labor_supply_response_variables, +) +from policyengine.tax_benefit_models.uk.analysis import ( + PolicyReformAnalysis as UKPolicyReformAnalysis, +) +from policyengine.tax_benefit_models.uk.datasets import ( + PolicyEngineUKDataset, + UKYearData, +) +from policyengine.tax_benefit_models.uk.model import uk_latest +from policyengine.tax_benefit_models.us.analysis import ( + PolicyReformAnalysis as USPolicyReformAnalysis, +) +from policyengine.tax_benefit_models.us.analysis import ( + economic_impact_analysis as us_economic_impact_analysis, +) +from policyengine.tax_benefit_models.us.datasets import ( + PolicyEngineUSDataset, + USYearData, +) +from policyengine.tax_benefit_models.us.model import us_latest + +US_EXPECTED_LSR_EXTRA_VARIABLES = [ + "self_employment_income", + "weekly_hours_worked", + "income_elasticity_lsr", + "substitution_elasticity_lsr", + "weekly_hours_worked_behavioural_response_income_elasticity", + "weekly_hours_worked_behavioural_response_substitution_elasticity", +] + + +def _microdf(data: dict, weights: str) -> MicroDataFrame: + return MicroDataFrame(pd.DataFrame(data), weights=weights) + + +def _replace_entity_frame( + simulation: Simulation, + entity: str, + data: pd.DataFrame, + weights: str, +) -> None: + setattr( + simulation.output_dataset.data, + entity, + MicroDataFrame(data, weights=weights), + ) + + +def _set_us_fixture_weights_and_household_index( + simulation: Simulation, +) -> None: + person = pd.DataFrame(simulation.output_dataset.data.person).copy() + person["person_weight"] = [2.0, 2.0, 3.0] + _replace_entity_frame(simulation, "person", person, "person_weight") + + household = pd.DataFrame(simulation.output_dataset.data.household).copy() + household["household_weight"] = [2.0, 3.0] + household.index = [10, 20] + _replace_entity_frame(simulation, "household", household, "household_weight") + + +def _lsr_dynamic() -> Dynamic: + return Dynamic( + name="Mock labor-supply response", + simulation_modifier=lambda microsim: microsim, + ) + + +def _dynamic_with_parameter( + parameter_name: str, + *, + model_version, +) -> Dynamic: + return Dynamic( + name="Mock parameterized labor-supply response", + parameter_values=[ + ParameterValue( + parameter=Parameter( + name=parameter_name, + tax_benefit_model_version=model_version, + ), + start_date=datetime.datetime(2026, 1, 1), + value=0.1, + ) + ], + ) + + +def _make_us_lsr_simulation( + tmp_path, + simulation_id: str, + *, + include_lsr: bool, + is_reform: bool = False, + dynamic: Optional[Dynamic] = None, +) -> Simulation: + person = { + "person_id": [1, 2, 3], + "household_id": [1, 1, 2], + "marital_unit_id": [1, 1, 2], + "family_id": [1, 1, 2], + "spm_unit_id": [1, 1, 2], + "tax_unit_id": [1, 1, 2], + "person_weight": [1.0, 1.0, 1.0], + "employment_income": [100.0, 100.0, 100.0], + "self_employment_income": [0.0, 0.0, 50.0], + "weekly_hours_worked": [42.0, 22.0, 35.0] if is_reform else [40.0, 20.0, 30.0], + } + if include_lsr: + person.update( + { + "income_elasticity_lsr": [20.0, 30.0, 50.0] + if is_reform + else [10.0, 20.0, 30.0], + "substitution_elasticity_lsr": [10.0, 15.0, 20.0] + if is_reform + else [5.0, 5.0, 10.0], + "weekly_hours_worked_behavioural_response_income_elasticity": [ + 2.0, + 3.0, + 5.0, + ] + if is_reform + else [1.0, 2.0, 3.0], + "weekly_hours_worked_behavioural_response_substitution_elasticity": [ + 1.0, + 2.0, + 3.0, + ] + if is_reform + else [0.5, 1.0, 1.5], + } + ) + + data = USYearData( + person=_microdf(person, "person_weight"), + marital_unit=_microdf( + { + "marital_unit_id": [1, 2], + "marital_unit_weight": [1.0, 1.0], + }, + "marital_unit_weight", + ), + family=_microdf( + { + "family_id": [1, 2], + "family_weight": [1.0, 1.0], + }, + "family_weight", + ), + spm_unit=_microdf( + { + "spm_unit_id": [1, 2], + "spm_unit_weight": [1.0, 1.0], + }, + "spm_unit_weight", + ), + tax_unit=_microdf( + { + "tax_unit_id": [1, 2], + "tax_unit_weight": [1.0, 1.0], + }, + "tax_unit_weight", + ), + household=_microdf( + { + "household_id": [1, 2], + "household_weight": [1.0, 1.0], + "household_income_decile": [1, 2], + }, + "household_weight", + ), + ) + dataset = PolicyEngineUSDataset( + id=simulation_id, + name=f"{simulation_id} output", + description="Mocked US output dataset for labor-supply response", + filepath=str(tmp_path / f"{simulation_id}.h5"), + year=2026, + is_output_dataset=True, + data=data, + ) + return Simulation( + id=simulation_id, + dataset=dataset, + tax_benefit_model_version=us_latest, + output_dataset=dataset, + dynamic=dynamic, + ) + + +def _make_uk_lsr_simulation( + tmp_path, + simulation_id: str, + *, + include_lsr: bool, + is_reform: bool = False, + dynamic: Optional[Dynamic] = None, +) -> Simulation: + person = { + "person_id": [1, 2], + "benunit_id": [1, 2], + "household_id": [1, 2], + "person_weight": [1.0, 1.0], + "employment_income": [100.0, 200.0], + "self_employment_income": [50.0, 0.0], + } + if include_lsr: + person.update( + { + "income_elasticity_lsr": [15.0, 30.0] if is_reform else [10.0, 20.0], + "substitution_elasticity_lsr": [7.0, 14.0] + if is_reform + else [5.0, 10.0], + } + ) + + data = UKYearData( + person=_microdf(person, "person_weight"), + benunit=_microdf( + { + "benunit_id": [1, 2], + "benunit_weight": [1.0, 1.0], + }, + "benunit_weight", + ), + household=_microdf( + { + "household_id": [1, 2], + "household_weight": [1.0, 1.0], + "household_income_decile": [1, 2], + }, + "household_weight", + ), + ) + dataset = PolicyEngineUKDataset( + id=simulation_id, + name=f"{simulation_id} output", + description="Mocked UK output dataset for labor-supply response", + filepath=str(tmp_path / f"{simulation_id}.h5"), + year=2026, + is_output_dataset=True, + data=data, + ) + return Simulation( + id=simulation_id, + dataset=dataset, + tax_benefit_model_version=uk_latest, + output_dataset=dataset, + dynamic=dynamic, + ) + + +def test_policy_reform_analysis_models_include_labor_supply_response(): + assert ( + USPolicyReformAnalysis.model_fields["labor_supply_response"].annotation + is LaborSupplyResponse + ) + assert ( + UKPolicyReformAnalysis.model_fields["labor_supply_response"].annotation + is LaborSupplyResponse + ) + + +def test_us_default_outputs_do_not_include_optional_lsr_support_variables(): + assert "self_employment_income" not in us_latest.entity_variables["person"] + assert "weekly_hours_worked" not in us_latest.entity_variables["person"] + + +def test_configure_labor_supply_response_variables_adds_us_extras(tmp_path): + baseline = _make_us_lsr_simulation( + tmp_path, + "baseline", + include_lsr=False, + ) + reform = _make_us_lsr_simulation( + tmp_path, + "reform", + include_lsr=False, + is_reform=True, + dynamic=_lsr_dynamic(), + ) + + assert configure_labor_supply_response_variables( + baseline, + reform, + country_code="us", + ) + assert baseline.extra_variables["person"] == US_EXPECTED_LSR_EXTRA_VARIABLES + assert reform.extra_variables == baseline.extra_variables + + +def test_configure_labor_supply_response_variables_detects_us_parameter_values( + tmp_path, +): + baseline = _make_us_lsr_simulation( + tmp_path, + "baseline", + include_lsr=False, + ) + reform = _make_us_lsr_simulation( + tmp_path, + "reform", + include_lsr=False, + is_reform=True, + dynamic=_dynamic_with_parameter( + "gov.simulation.labor_supply_responses.elasticities.income", + model_version=us_latest, + ), + ) + + assert configure_labor_supply_response_variables( + baseline, + reform, + country_code="us", + ) + assert baseline.extra_variables["person"] == US_EXPECTED_LSR_EXTRA_VARIABLES + assert reform.extra_variables == baseline.extra_variables + + +def test_configure_labor_supply_response_variables_detects_us_parameter_descendants( + tmp_path, +): + baseline = _make_us_lsr_simulation( + tmp_path, + "baseline", + include_lsr=False, + ) + reform = _make_us_lsr_simulation( + tmp_path, + "reform", + include_lsr=False, + is_reform=True, + dynamic=_dynamic_with_parameter( + "gov.simulation.labor_supply_responses.elasticities.substitution.all", + model_version=us_latest, + ), + ) + + assert configure_labor_supply_response_variables( + baseline, + reform, + country_code="us", + ) + assert baseline.extra_variables["person"] == US_EXPECTED_LSR_EXTRA_VARIABLES + assert reform.extra_variables == baseline.extra_variables + + +def test_configure_labor_supply_response_variables_detects_policy_mapping_parameters( + tmp_path, +): + baseline = _make_us_lsr_simulation( + tmp_path, + "baseline", + include_lsr=False, + ) + reform = _make_us_lsr_simulation( + tmp_path, + "reform", + include_lsr=False, + is_reform=True, + ) + reform.policy = {"gov.simulation.labor_supply_responses.elasticities.income": 0.1} + + assert configure_labor_supply_response_variables( + baseline, + reform, + country_code="us", + ) + assert baseline.extra_variables["person"] == US_EXPECTED_LSR_EXTRA_VARIABLES + assert reform.extra_variables == baseline.extra_variables + + +@pytest.mark.parametrize( + "parameter_name", + [ + "gov.simulation.labour_supply_responses.income_elasticity", + "gov.simulation.labor_supply_responses.income_elasticity", + ], +) +def test_configure_labor_supply_response_variables_detects_uk_parameter_values( + tmp_path, + parameter_name, +): + baseline = _make_uk_lsr_simulation( + tmp_path, + "baseline", + include_lsr=False, + ) + reform = _make_uk_lsr_simulation( + tmp_path, + "reform", + include_lsr=False, + is_reform=True, + dynamic=_dynamic_with_parameter( + parameter_name, + model_version=uk_latest, + ), + ) + + assert configure_labor_supply_response_variables( + baseline, + reform, + country_code="uk", + ) + assert baseline.extra_variables["person"] == [ + "income_elasticity_lsr", + "substitution_elasticity_lsr", + ] + assert reform.extra_variables == baseline.extra_variables + + +@pytest.mark.parametrize( + ("country_code", "parameter_name", "model_version"), + [ + ( + "us", + "gov.simulation.labor_supply_responses.elasticities.income_support", + us_latest, + ), + ( + "uk", + "gov.simulation.labour_supply_responses.income_elasticity_adjustment", + uk_latest, + ), + ], +) +def test_configure_labor_supply_response_variables_ignores_parameter_siblings( + tmp_path, + country_code, + parameter_name, + model_version, +): + make_simulation = ( + _make_us_lsr_simulation if country_code == "us" else _make_uk_lsr_simulation + ) + baseline = make_simulation( + tmp_path, + "baseline", + include_lsr=False, + ) + reform = make_simulation( + tmp_path, + "reform", + include_lsr=False, + is_reform=True, + dynamic=_dynamic_with_parameter( + parameter_name, + model_version=model_version, + ), + ) + + assert not configure_labor_supply_response_variables( + baseline, + reform, + country_code=country_code, + ) + assert baseline.extra_variables == {} + assert reform.extra_variables == {} + + +def test_configure_labor_supply_response_variables_ignores_inactive_runs(tmp_path): + baseline = _make_us_lsr_simulation( + tmp_path, + "baseline", + include_lsr=False, + ) + reform = _make_us_lsr_simulation( + tmp_path, + "reform", + include_lsr=False, + is_reform=True, + ) + + assert not configure_labor_supply_response_variables( + baseline, + reform, + country_code="us", + ) + assert baseline.extra_variables == {} + assert reform.extra_variables == {} + + +def test_configure_labor_supply_response_variables_honors_explicit_false_marker( + tmp_path, +): + baseline = _make_us_lsr_simulation( + tmp_path, + "baseline", + include_lsr=False, + ) + reform = _make_us_lsr_simulation( + tmp_path, + "reform", + include_lsr=False, + is_reform=True, + dynamic=Dynamic( + name="Non-LSR modifier", + simulation_modifier=lambda microsim: microsim, + affects_labor_supply_response=False, + ), + ) + + assert not configure_labor_supply_response_variables( + baseline, + reform, + country_code="us", + ) + assert baseline.extra_variables == {} + assert reform.extra_variables == {} + + +def test_configure_labor_supply_response_variables_honors_explicit_true_marker( + tmp_path, +): + baseline = _make_us_lsr_simulation( + tmp_path, + "baseline", + include_lsr=False, + ) + reform = _make_us_lsr_simulation( + tmp_path, + "reform", + include_lsr=False, + is_reform=True, + dynamic=Dynamic( + name="LSR marker", + affects_labor_supply_response=True, + ), + ) + + assert configure_labor_supply_response_variables( + baseline, + reform, + country_code="us", + ) + assert baseline.extra_variables["person"] == US_EXPECTED_LSR_EXTRA_VARIABLES + assert reform.extra_variables == baseline.extra_variables + + +def test_configure_labor_supply_response_variables_detects_lsr_parameters_before_marker( + tmp_path, +): + baseline = _make_us_lsr_simulation( + tmp_path, + "baseline", + include_lsr=False, + ) + reform = _make_us_lsr_simulation( + tmp_path, + "reform", + include_lsr=False, + is_reform=True, + dynamic=Dynamic( + name="Contradictory LSR parameter marker", + affects_labor_supply_response=False, + parameter_values=[ + ParameterValue( + parameter=Parameter( + name="gov.simulation.labor_supply_responses.elasticities.income", + tax_benefit_model_version=us_latest, + ), + start_date=datetime.datetime(2026, 1, 1), + value=0.1, + ) + ], + ), + ) + + assert configure_labor_supply_response_variables( + baseline, + reform, + country_code="us", + ) + assert baseline.extra_variables["person"] == US_EXPECTED_LSR_EXTRA_VARIABLES + assert reform.extra_variables == baseline.extra_variables + + +def test_combined_policy_and_dynamic_lsr_markers_preserve_unknown_state(): + assert ( + Dynamic(name="yes", affects_labor_supply_response=True) + + Dynamic(name="unknown") + ).affects_labor_supply_response is True + assert ( + Dynamic(name="no", affects_labor_supply_response=False) + + Dynamic(name="unknown") + ).affects_labor_supply_response is None + assert ( + Policy(name="no", affects_labor_supply_response=False) + + Policy(name="no again", affects_labor_supply_response=False) + ).affects_labor_supply_response is False + + +def test_policy_lsr_marker_does_not_suppress_unmarked_dynamic_modifier( + tmp_path, +): + baseline = _make_us_lsr_simulation( + tmp_path, + "baseline", + include_lsr=False, + ) + reform = _make_us_lsr_simulation( + tmp_path, + "reform", + include_lsr=False, + is_reform=True, + dynamic=_lsr_dynamic(), + ) + reform.policy = Policy( + name="Non-LSR policy modifier", + simulation_modifier=lambda microsim: microsim, + affects_labor_supply_response=False, + ) + + assert configure_labor_supply_response_variables( + baseline, + reform, + country_code="us", + ) + assert baseline.extra_variables["person"] == US_EXPECTED_LSR_EXTRA_VARIABLES + assert reform.extra_variables == baseline.extra_variables + + +def test_us_economic_impact_analysis_configures_lsr_extras_before_ensure( + tmp_path, + monkeypatch, +): + baseline = _make_us_lsr_simulation( + tmp_path, + "baseline", + include_lsr=False, + ) + reform = _make_us_lsr_simulation( + tmp_path, + "reform", + include_lsr=False, + is_reform=True, + dynamic=_lsr_dynamic(), + ) + ensure_calls = [] + + class StopAfterEnsure(Exception): + pass + + def fake_ensure(self): + assert self.extra_variables["person"] == US_EXPECTED_LSR_EXTRA_VARIABLES + ensure_calls.append(self.id) + if len(ensure_calls) == 2: + raise StopAfterEnsure + + monkeypatch.setattr(Simulation, "ensure", fake_ensure) + + with pytest.raises(StopAfterEnsure): + us_economic_impact_analysis(baseline, reform) + + assert ensure_calls == ["baseline", "reform"] + + +def test_inactive_labor_supply_response_tolerates_missing_optional_columns( + tmp_path, +): + baseline = _make_us_lsr_simulation( + tmp_path, + "baseline", + include_lsr=False, + ) + reform = _make_us_lsr_simulation( + tmp_path, + "reform", + include_lsr=False, + is_reform=True, + ) + for simulation in (baseline, reform): + simulation.output_dataset.data.person = ( + simulation.output_dataset.data.person.drop( + columns=[ + "self_employment_income", + "weekly_hours_worked", + ] + ) + ) + + result = calculate_labor_supply_response( + baseline, + reform, + country_code="us", + ) + + assert result.income_lsr == pytest.approx(0.0) + assert result.substitution_lsr == pytest.approx(0.0) + assert result.relative_lsr == { + "income": pytest.approx(0.0), + "substitution": pytest.approx(0.0), + } + assert result.hours.baseline == pytest.approx(0.0) + assert result.hours.reform == pytest.approx(0.0) + + +def test_calculate_us_labor_supply_response_uses_legacy_shape(tmp_path): + baseline = _make_us_lsr_simulation( + tmp_path, + "baseline", + include_lsr=True, + ) + reform = _make_us_lsr_simulation( + tmp_path, + "reform", + include_lsr=True, + is_reform=True, + dynamic=_lsr_dynamic(), + ) + + result = calculate_labor_supply_response( + baseline, + reform, + country_code="us", + ) + + assert result.model_dump().keys() == { + "substitution_lsr", + "income_lsr", + "relative_lsr", + "total_change", + "revenue_change", + "decile", + "hours", + } + assert result.income_lsr == pytest.approx(40.0) + assert result.substitution_lsr == pytest.approx(25.0) + assert result.total_change == pytest.approx(65.0) + assert result.revenue_change == pytest.approx(0.0) + assert result.relative_lsr == { + "income": pytest.approx(40.0 / 285.0), + "substitution": pytest.approx(25.0 / 285.0), + } + assert result.decile["average"]["income"] == { + 1: pytest.approx(20.0), + 2: pytest.approx(20.0), + } + assert result.decile["average"]["substitution"] == { + 1: pytest.approx(15.0), + 2: pytest.approx(10.0), + } + assert result.decile["relative"]["income"] == { + 1: pytest.approx(20.0 / 165.0), + 2: pytest.approx(20.0 / 120.0), + } + assert result.decile["relative"]["substitution"] == { + 1: pytest.approx(15.0 / 165.0), + 2: pytest.approx(10.0 / 120.0), + } + assert result.hours.baseline == pytest.approx(90.0) + assert result.hours.reform == pytest.approx(99.0) + assert result.hours.change == pytest.approx(9.0) + assert result.hours.income_effect == pytest.approx(4.0) + assert result.hours.substitution_effect == pytest.approx(3.0) + + +def test_calculate_us_labor_supply_response_uses_positional_household_data( + tmp_path, +): + baseline = _make_us_lsr_simulation( + tmp_path, + "baseline", + include_lsr=True, + ) + reform = _make_us_lsr_simulation( + tmp_path, + "reform", + include_lsr=True, + is_reform=True, + dynamic=_lsr_dynamic(), + ) + _set_us_fixture_weights_and_household_index(baseline) + _set_us_fixture_weights_and_household_index(reform) + + result = calculate_labor_supply_response( + baseline, + reform, + country_code="us", + ) + + assert result.income_lsr == pytest.approx(100.0) + assert result.substitution_lsr == pytest.approx(60.0) + assert result.total_change == pytest.approx(160.0) + assert result.relative_lsr == { + "income": pytest.approx(100.0 / 690.0), + "substitution": pytest.approx(60.0 / 690.0), + } + assert result.decile["average"]["income"] == { + 1: pytest.approx(20.0), + 2: pytest.approx(20.0), + } + assert result.decile["average"]["substitution"] == { + 1: pytest.approx(15.0), + 2: pytest.approx(10.0), + } + assert result.decile["relative"]["income"] == { + 1: pytest.approx(20.0 / 165.0), + 2: pytest.approx(20.0 / 120.0), + } + assert result.decile["relative"]["substitution"] == { + 1: pytest.approx(15.0 / 165.0), + 2: pytest.approx(10.0 / 120.0), + } + assert result.hours.baseline == pytest.approx(210.0) + assert result.hours.reform == pytest.approx(233.0) + assert result.hours.change == pytest.approx(23.0) + + +def test_inactive_us_labor_supply_response_does_not_require_lsr_columns(tmp_path): + baseline = _make_us_lsr_simulation( + tmp_path, + "baseline", + include_lsr=False, + ) + reform = _make_us_lsr_simulation( + tmp_path, + "reform", + include_lsr=False, + is_reform=True, + ) + _set_us_fixture_weights_and_household_index(baseline) + _set_us_fixture_weights_and_household_index(reform) + + result = calculate_labor_supply_response( + baseline, + reform, + country_code="us", + ) + + assert result.income_lsr == pytest.approx(0.0) + assert result.substitution_lsr == pytest.approx(0.0) + assert result.total_change == pytest.approx(0.0) + assert result.relative_lsr == { + "income": pytest.approx(0.0), + "substitution": pytest.approx(0.0), + } + assert result.decile == { + "average": { + "income": {1: pytest.approx(0.0), 2: pytest.approx(0.0)}, + "substitution": {1: pytest.approx(0.0), 2: pytest.approx(0.0)}, + }, + "relative": { + "income": {1: pytest.approx(0.0), 2: pytest.approx(0.0)}, + "substitution": {1: pytest.approx(0.0), 2: pytest.approx(0.0)}, + }, + } + assert result.hours.baseline == pytest.approx(210.0) + assert result.hours.reform == pytest.approx(233.0) + assert result.hours.income_effect == pytest.approx(0.0) + assert result.hours.substitution_effect == pytest.approx(0.0) + + +def test_labor_supply_response_relative_values_guard_zero_earnings(tmp_path): + baseline = _make_us_lsr_simulation( + tmp_path, + "baseline", + include_lsr=True, + ) + reform = _make_us_lsr_simulation( + tmp_path, + "reform", + include_lsr=True, + is_reform=True, + dynamic=_lsr_dynamic(), + ) + baseline.output_dataset.data.person["employment_income"] = [20.0, 15.0, 30.0] + baseline.output_dataset.data.person["self_employment_income"] = [0.0, 0.0, 0.0] + + result = calculate_labor_supply_response( + baseline, + reform, + country_code="us", + ) + + assert result.relative_lsr == { + "income": pytest.approx(0.0), + "substitution": pytest.approx(0.0), + } + assert result.decile["relative"]["income"] == { + 1: pytest.approx(0.0), + 2: pytest.approx(0.0), + } + assert result.decile["relative"]["substitution"] == { + 1: pytest.approx(0.0), + 2: pytest.approx(0.0), + } + + +def test_active_labor_supply_response_requires_lsr_columns(tmp_path): + baseline = _make_us_lsr_simulation( + tmp_path, + "baseline", + include_lsr=False, + ) + reform = _make_us_lsr_simulation( + tmp_path, + "reform", + include_lsr=False, + is_reform=True, + dynamic=_lsr_dynamic(), + ) + + with pytest.raises(ValueError, match="income_elasticity_lsr"): + calculate_labor_supply_response( + baseline, + reform, + country_code="us", + ) + + +def test_calculate_uk_labor_supply_response_returns_zero_hours(tmp_path): + baseline = _make_uk_lsr_simulation( + tmp_path, + "baseline", + include_lsr=True, + ) + reform = _make_uk_lsr_simulation( + tmp_path, + "reform", + include_lsr=True, + is_reform=True, + dynamic=_lsr_dynamic(), + ) + + result = calculate_labor_supply_response( + baseline, + reform, + country_code="uk", + ) + + assert result.income_lsr == pytest.approx(15.0) + assert result.substitution_lsr == pytest.approx(6.0) + assert result.total_change == pytest.approx(21.0) + assert result.relative_lsr == { + "income": pytest.approx(15.0 / 329.0), + "substitution": pytest.approx(6.0 / 329.0), + } + assert result.decile["average"]["income"] == { + 1: pytest.approx(5.0), + 2: pytest.approx(10.0), + } + assert result.decile["average"]["substitution"] == { + 1: pytest.approx(2.0), + 2: pytest.approx(4.0), + } + assert result.hours.baseline == pytest.approx(0.0) + assert result.hours.reform == pytest.approx(0.0) + assert result.hours.change == pytest.approx(0.0) + assert result.hours.income_effect == pytest.approx(0.0) + assert result.hours.substitution_effect == pytest.approx(0.0) diff --git a/uv.lock b/uv.lock index 469bc909..4cbbe368 100644 --- a/uv.lock +++ b/uv.lock @@ -2411,7 +2411,7 @@ wheels = [ [[package]] name = "policyengine" -version = "4.4.3" +version = "4.4.4" source = { editable = "." } dependencies = [ { name = "jsonschema" },