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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/453.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Open-ended parametric reforms (`end_date=None`, including all flat-dict reforms) now apply from their start date onward in microsimulation runs instead of only within the start year. Open-ended values are serialised with explicit period stops, each clipped at the next-later value of the same parameter.
48 changes: 35 additions & 13 deletions src/policyengine/utils/parametric_reforms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from collections.abc import Callable
from datetime import timedelta
from typing import TYPE_CHECKING, Optional, Union

from policyengine_core.periods import period
Expand All @@ -11,6 +12,22 @@
from policyengine.core.dynamic import Dynamic
from policyengine.core.policy import Policy

# policyengine-core's Reform.from_dict applies a bare "YYYY-MM-DD" period
# key for a single instant only, so open-ended values (end_date=None) need
# an explicit far-future stop to hold beyond their start year (#453).
OPEN_ENDED_REFORM_STOP = "2100-12-31"


def _open_ended_stop_str(pv: ParameterValue, siblings: list[ParameterValue]) -> str:
"""Stop date for an open-ended value: the day before the next-later
value of the same parameter starts, or far-future if none follows."""
later_starts = [
other.start_date for other in siblings if other.start_date > pv.start_date
]
if later_starts:
return (min(later_starts) - timedelta(days=1)).strftime("%Y-%m-%d")
return OPEN_ENDED_REFORM_STOP


def reform_dict_from_parameter_values(
parameter_values: Optional[list[ParameterValue]],
Expand All @@ -21,33 +38,38 @@ def reform_dict_from_parameter_values(
This format is accepted by policyengine_us.Microsimulation(reform=...) and
policyengine_uk.Microsimulation(reform=...) at construction time.

Every period key uses the "start.stop" range format. Values with
end_date=None run from their start date until the next-later value of
the same parameter takes over, or until OPEN_ENDED_REFORM_STOP.

Args:
parameter_values: List of ParameterValue objects to convert.

Returns:
A dict mapping parameter names to period-value dicts, e.g.:
{
"gov.irs.deductions.standard.amount.SINGLE": {
"2024-01-01": 29200
"2024-01-01.2100-12-31": 29200
}
}
"""
if not parameter_values:
return None

reform_dict = {}
by_parameter: dict[str, list[ParameterValue]] = {}
for pv in parameter_values:
param_name = pv.parameter.name
if param_name not in reform_dict:
reform_dict[param_name] = {}

# Format the period string
period_str = pv.start_date.strftime("%Y-%m-%d")
if pv.end_date:
# Use period range format: "start.end"
period_str = f"{period_str}.{pv.end_date.strftime('%Y-%m-%d')}"

reform_dict[param_name][period_str] = pv.value
by_parameter.setdefault(pv.parameter.name, []).append(pv)

reform_dict: dict[str, dict] = {}
for param_name, values in by_parameter.items():
periods = reform_dict.setdefault(param_name, {})
for pv in values:
start_str = pv.start_date.strftime("%Y-%m-%d")
if pv.end_date:
stop_str = pv.end_date.strftime("%Y-%m-%d")
else:
stop_str = _open_ended_stop_str(pv, values)
periods[f"{start_str}.{stop_str}"] = pv.value

return reform_dict

Expand Down
2 changes: 1 addition & 1 deletion tests/test_entity_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ def test__given_parameter_values__then_returns_reform_dict(self):

result = build_reform_dict(obj)

assert result == {"gov.test.param": {"2024-01-01": 1000}}
assert result == {"gov.test.param": {"2024-01-01.2100-12-31": 1000}}


class TestMergeReformDicts:
Expand Down
94 changes: 83 additions & 11 deletions tests/test_parametric_reforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ def test__given_single_parameter_value__then_returns_dict_with_one_entry(
# Then
assert result is not None
assert MOCK_PARAM_SINGLE.name in result
assert "2024-01-01" in result[MOCK_PARAM_SINGLE.name]
assert result[MOCK_PARAM_SINGLE.name]["2024-01-01"] == 29200
assert "2024-01-01.2100-12-31" in result[MOCK_PARAM_SINGLE.name]
assert result[MOCK_PARAM_SINGLE.name]["2024-01-01.2100-12-31"] == 29200

def test__given_parameter_value_with_end_date__then_uses_period_range_format(
self,
Expand All @@ -93,9 +93,9 @@ def test__given_parameter_value_with_end_date__then_uses_period_range_format(
def test__given_multiple_periods_same_parameter__then_includes_all_periods(
self,
):
"""Given: Multiple parameter values for same parameter (different periods)
"""Given: Multiple open-ended values for same parameter (different periods)
When: Calling reform_dict_from_parameter_values
Then: Returns dict with all periods for that parameter
Then: Each value runs until the next-later value starts
"""
# Given
param_values = MULTI_PERIOD_PARAM_VALUES
Expand All @@ -108,8 +108,8 @@ def test__given_multiple_periods_same_parameter__then_includes_all_periods(
param_name = MOCK_PARAM_SINGLE.name
assert param_name in result
assert len(result[param_name]) == 2
assert result[param_name]["2024-01-01"] == 29200
assert result[param_name]["2025-01-01"] == 30000
assert result[param_name]["2024-01-01.2024-12-31"] == 29200
assert result[param_name]["2025-01-01.2100-12-31"] == 30000

def test__given_multiple_different_parameters__then_includes_all_parameters(
self,
Expand All @@ -130,9 +130,9 @@ def test__given_multiple_different_parameters__then_includes_all_parameters(
assert MOCK_PARAM_SINGLE.name in result
assert MOCK_PARAM_JOINT.name in result
assert MOCK_PARAM_TAX_RATE.name in result
assert result[MOCK_PARAM_SINGLE.name]["2024-01-01"] == 29200
assert result[MOCK_PARAM_JOINT.name]["2024-01-01"] == 58400
assert result[MOCK_PARAM_TAX_RATE.name]["2024-01-01"] == 0.10
assert result[MOCK_PARAM_SINGLE.name]["2024-01-01.2100-12-31"] == 29200
assert result[MOCK_PARAM_JOINT.name]["2024-01-01.2100-12-31"] == 58400
assert result[MOCK_PARAM_TAX_RATE.name]["2024-01-01.2100-12-31"] == 0.10

def test__given_parameter_value__then_preserves_value_type(self):
"""Given: Parameter values with different types (int, float)
Expand All @@ -151,8 +151,80 @@ def test__given_parameter_value__then_preserves_value_type(self):
result = reform_dict_from_parameter_values([pv_float])

# Then
assert result["gov.test.rate"]["2024-01-01"] == 0.15
assert isinstance(result["gov.test.rate"]["2024-01-01"], float)
assert result["gov.test.rate"]["2024-01-01.2100-12-31"] == 0.15
assert isinstance(result["gov.test.rate"]["2024-01-01.2100-12-31"], float)

def test__given_open_ended_value__then_period_stops_far_future(self):
"""Given: Parameter value with no end date (open-ended)
When: Calling reform_dict_from_parameter_values
Then: Emits an explicit far-future stop instead of a bare start
date, which policyengine-core Reform.from_dict would apply for a
single instant only (issue #453)
"""
# Given
pv = SINGLE_PARAM_VALUE

# When
result = reform_dict_from_parameter_values([pv])

# Then
(period_key,) = result[MOCK_PARAM_SINGLE.name]
assert period_key == "2024-01-01.2100-12-31"

def test__given_open_ended_values_in_reverse_order__then_clips_at_next_start(
self,
):
"""Given: Two open-ended values for one parameter, listed latest-first
When: Calling reform_dict_from_parameter_values
Then: The earlier value stops the day before the later one starts,
regardless of list order
"""
# Given
param = create_mock_parameter("gov.test.step")
later = create_parameter_value(
parameter=param, value=2_000, start_date=date(2028, 1, 1)
)
earlier = create_parameter_value(
parameter=param, value=1_000, start_date=date(2026, 1, 1)
)

# When
result = reform_dict_from_parameter_values([later, earlier])

# Then
assert result["gov.test.step"] == {
"2028-01-01.2100-12-31": 2_000,
"2026-01-01.2027-12-31": 1_000,
}

def test__given_open_ended_and_explicit_end_values__then_open_ended_clips_at_next_start(
self,
):
"""Given: An open-ended value followed by a later value with an explicit end
When: Calling reform_dict_from_parameter_values
Then: The open-ended value stops the day before the later value
starts; the explicit end is preserved verbatim
"""
# Given
param = create_mock_parameter("gov.test.mixed")
open_ended = create_parameter_value(
parameter=param, value=100, start_date=date(2024, 1, 1)
)
bounded = create_parameter_value(
parameter=param,
value=200,
start_date=date(2026, 1, 1),
end_date=date(2026, 12, 31),
)

# When
result = reform_dict_from_parameter_values([open_ended, bounded])

# Then
assert result["gov.test.mixed"] == {
"2024-01-01.2025-12-31": 100,
"2026-01-01.2026-12-31": 200,
}


class TestSimulationModifierFromParameterValues:
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading