From 021031799e232baeba100dee7027747c56f85b8b Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Thu, 8 Oct 2026 12:38:15 +0100 Subject: [PATCH] Re-average fiscal-year-blended parameters after mid-year reforms (#2167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reforms edit the converted parameter tree, where each fiscal_year_blend parameter already holds a day-weighted model-year average. A reform dated part-way through a year applied only from its calendar month, against a baseline average that still included the rates it replaces: 60p a litre from 1 September 2026 scored +£24.19 for 1,200 litres instead of the day-weighted +£38.26. Record each reform update to a blended parameter, replay it on the dated schedule (1 January and 31 December boundaries become 6 April and 5 April) and re-average the model years. Whole-year reforms keep their values exactly. Document the semantics and syntax per API. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2167.fixed.md | 1 + docs/engineering/excise-duties.md | 47 ++++ .../gov/hmrc/fuel_duty/petrol_and_diesel.yaml | 4 +- policyengine_uk/simulation.py | 8 + .../tests/test_mid_year_reforms.py | 264 ++++++++++++++++++ policyengine_uk/utils/parameters.py | 183 +++++++++++- policyengine_uk/utils/scenario.py | 63 +++-- 7 files changed, 528 insertions(+), 42 deletions(-) create mode 100644 changelog.d/2167.fixed.md create mode 100644 policyengine_uk/tests/test_mid_year_reforms.py diff --git a/changelog.d/2167.fixed.md b/changelog.d/2167.fixed.md new file mode 100644 index 0000000000..99180c0c05 --- /dev/null +++ b/changelog.d/2167.fixed.md @@ -0,0 +1 @@ +Re-average fiscal-year-blended parameters (fuel duties, CGT rates) when a reform changes them part-way through a year, so a dated reform scores its day-weighted share of the model year instead of applying only from its calendar month. Whole-year reforms are unchanged. diff --git a/docs/engineering/excise-duties.md b/docs/engineering/excise-duties.md index 2151b427ae..ad4e06f261 100644 --- a/docs/engineering/excise-duties.md +++ b/docs/engineering/excise-duties.md @@ -188,6 +188,53 @@ rate: every later year stays one 1 April step behind. Reform classes and legacy reform dictionaries change the annual values after uprating and are not affected. +## Reforms dated part-way through a year + +This applies to every parameter with `fiscal_year_blend: true`: the petrol and +diesel, road fuel gas and natural gas duty rates, and the CGT rates. Parameters +with `preserve_calendar_dates: true` are never converted, so none of this +applies to them. + +**Fiscal-year semantics.** After conversion, a blended parameter holds one +day-weighted average per model year (6 April to 5 April), written over the +calendar year with the same label. A bare-year override replaces the whole +fiscal year. A change dated part-way through a year is replayed on the dated +schedule and the year is re-averaged: 60p a litre from 1 September 2026 raises +model year 2026 by 217 of its 365 days at 60p in place of the rates then in +force, which for 1,200 litres is +£38.26. Before this was fixed, the change +applied only from the September monthly reading, against a baseline average +that still included the January and March 2027 increases it replaces, and +scored +£24.19. + +**Accepted syntax.** + +- `Reform.from_dict`, and dictionaries passed as `reform=`: a range key + `"2026-09-01.2100-12-31"` is a persistent change from 1 September 2026, and a + range ending before 5 April (`"2026-09-01.2027-01-31"`) reverts to the + schedule afterwards. A single-date key differs between the two: + `Reform.from_dict` reads `"2026-09-01"` as from that date onwards, while a + plain dictionary reads it as that one day. Several ranges on one parameter can fall in the same + fiscal year. On these reforms, 1 January and 31 December are read as + model-year boundaries, because that is what they bound on the converted tree: + `"2026-01-01.2100-12-31"` and the bare key `"2026"` both change model year + 2026 onwards in full, exactly as before. A change that really takes effect + on 1 January cannot be written this way; write the hand-computed annual + values instead. +- `Scenario(parameter_changes=...)`: a bare year names one fiscal year. A + single-date key such as `"2026-09-01"` is a one-day period, not a change from + that date onwards: on fuel duty it changes model year 2026 by one day's + weight (+£0.23 for the example above). `Scenario` does not accept range keys + and raises a `ValueError`; use `Reform.from_dict` for a dated persistent + change. + +**Scoring a mid-year change.** The day weighting assumes equal daily use across +the year. Fuel duty, for example, is charged on litres consumed through the +year, and the model has no seasonal profile, so a change from 1 October to +31 March takes 182/365 of the year's litres, not the share actually bought in +those months. Monthly +`fuel_duty` still reads the model-year rate in each calendar month, so a +reform's effect shows in every month of the model year, not only from its date. + ## Population data follow-ups These formulas do not impute quantities or vehicle characteristics from the diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml index cf7d2a640f..5a2c3691a1 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml @@ -4,7 +4,9 @@ description: The UK levies fuel duty on petrol and diesel at this rate per litre # year (6 April to 5 April) when parameters are converted to annual values, # so a model year carries every rate in force during it in proportion to its # days. Time of day is not modelled: a 6pm change counts from the start of -# its day. +# its day. A reform dated part-way through a year is re-averaged the same +# way; see "Reforms dated part-way through a year" in +# docs/engineering/excise-duties.md. values: 2010-04-01: value: 0.5719 diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 5feee2bc38..1405bd7880 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -18,6 +18,7 @@ from policyengine_uk.utils.parameters import ( check_parameter_not_removed, + reblend_dated_reforms, uk_fiscal_year_period, ) @@ -250,6 +251,13 @@ def clone( clone._user_input_contexts = list(getattr(self, "_user_input_contexts", ())) return clone + def apply_reform(self, reform: Union[tuple, Reform]): + # A reform dated part-way through a year on a fiscal-year-blended + # parameter is re-averaged over the model year (see + # reblend_dated_reforms). + with reblend_dated_reforms(self.tax_benefit_system): + super().apply_reform(reform) + def delete_arrays(self, variable: str, period: Period = None) -> None: super().delete_arrays(variable, period) # policyengine-core's Simulation.delete_arrays and diff --git a/policyengine_uk/tests/test_mid_year_reforms.py b/policyengine_uk/tests/test_mid_year_reforms.py new file mode 100644 index 0000000000..0189a42eff --- /dev/null +++ b/policyengine_uk/tests/test_mid_year_reforms.py @@ -0,0 +1,264 @@ +""" +Reforms dated part-way through a year on fiscal-year-blended parameters. + +Parameters with `fiscal_year_blend: true` hold one day-weighted average per +model year (6 April to 5 April). A reform dated part-way through a year is +replayed on the dated schedule and re-averaged, so model year 2026 changes +by the day-weighted fiscal-year amount. Whole-year reforms (bare years, and +ranges starting 1 January and ending 31 December) keep their values exactly. + +The expected amounts assume equal daily consumption across the year, which +is what the day weighting means. +""" + +import datetime + +import pytest +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation +from policyengine_uk.model_api import Scenario +from policyengine_uk.system import system + +FUEL = "gov.hmrc.fuel_duty.petrol_and_diesel" +CGT = "gov.hmrc.cgt.basic_rate" +LITRES = 1_200 +YEARS = range(2015, 2041) + +SITUATION = { + "people": {"adult": {"age": {2026: 40}}}, + "benunits": {"benunit": {"members": ["adult"]}}, + "households": { + "household": { + "members": ["adult"], + "petrol_litres": {year: LITRES for year in (2026, 2027)}, + "diesel_litres": {year: 0 for year in (2026, 2027)}, + "in_rural_fuel_duty_relief_area": {2026: False, 2027: False}, + } + }, +} + + +def dated(path): + """The dated schedule the model-year values are averaged from.""" + return system.parameters.baseline.get_child(path) + + +def model_year_days(year): + start = datetime.date(year, 4, 6) + end = datetime.date(year + 1, 4, 6) + return [start + datetime.timedelta(days=i) for i in range((end - start).days)] + + +def day_weighted(path, year, overrides): + """Average of the dated schedule over a model year, with dated overrides. + + overrides is a list of (first_day, last_day, value), applied in order. + """ + schedule = dated(path) + total = 0.0 + days = model_year_days(year) + for day in days: + value = schedule(day.isoformat()) + for first, last, override in overrides: + if first <= day <= last: + value = override + total += value + return total / len(days) + + +def fuel_duty(year, reform=None, scenario=None): + simulation = Simulation(situation=SITUATION, reform=reform, scenario=scenario) + return float(simulation.calculate("fuel_duty", year)[0]) + + +def change(year, reform): + return fuel_duty(year, reform) - fuel_duty(year) + + +def converted(path): + return system.parameters.get_child(path) + + +ONWARDS = datetime.date(2100, 12, 31) + + +def test_mid_year_start_is_day_weighted_over_the_model_year(): + """The #2167 example: 60p a litre from 1 September 2026, persistently. + + Before the fix the 2026 change was +£24.19, because months before + September kept a baseline average that includes the January and March + 2027 increases the reform replaces. + """ + reform = Reform.from_dict({FUEL: {"2026-09-01.2100-12-31": 0.60}}, "uk") + expected = LITRES * ( + day_weighted(FUEL, 2026, [(datetime.date(2026, 9, 1), ONWARDS, 0.60)]) + - converted(FUEL)("2026") + ) + assert expected == pytest.approx(38.2636, abs=1e-4) + # fuel_duty is computed in float32. + assert change(2026, reform) == pytest.approx(expected, rel=1e-5) + # From model year 2027 the reform covers the whole year. + assert change(2027, reform) == pytest.approx( + LITRES * (0.60 - converted(FUEL)("2027")), rel=1e-5 + ) + + +def test_change_that_expires_within_the_year_reverts_to_the_schedule(): + reform = Reform.from_dict({FUEL: {"2026-09-01.2027-01-31": 0.60}}, "uk") + window = (datetime.date(2026, 9, 1), datetime.date(2027, 1, 31), 0.60) + expected = LITRES * (day_weighted(FUEL, 2026, [window]) - converted(FUEL)("2026")) + assert change(2026, reform) == pytest.approx(expected, rel=1e-5) + assert change(2027, reform) == pytest.approx(0, abs=1e-6) + + +def test_several_changes_within_one_year(): + reform = Reform.from_dict( + {FUEL: {"2026-07-01.2026-10-31": 0.50, "2026-11-01.2100-12-31": 0.62}}, + "uk", + ) + expected = LITRES * ( + day_weighted( + FUEL, + 2026, + [ + (datetime.date(2026, 7, 1), datetime.date(2026, 10, 31), 0.50), + (datetime.date(2026, 11, 1), ONWARDS, 0.62), + ], + ) + - converted(FUEL)("2026") + ) + assert change(2026, reform) == pytest.approx(expected, rel=1e-5) + + +def test_cgt_rate_change_part_way_through_the_year(): + reform = Reform.from_dict({CGT: {"2026-10-01.2100-12-31": 0.24}}, "uk") + simulation = Simulation(situation=SITUATION, reform=reform) + rate = simulation.tax_benefit_system.parameters.get_child(CGT) + expected = day_weighted(CGT, 2026, [(datetime.date(2026, 10, 1), ONWARDS, 0.24)]) + assert rate("2026") == pytest.approx(expected, abs=1e-12) + assert converted(CGT)("2026") < rate("2026") < 0.24 + assert rate("2027") == pytest.approx(0.24, abs=1e-12) + + +def test_temporary_zero_rate_with_the_electricity_vat_dates(): + """The #2166 dates on a blended parameter: 0% from 1 October 2026 to 31 + March 2027, then 5% from 1 April 2027. + + Replace with the electricity VAT rate once #2166 adds it. + """ + reform = Reform.from_dict( + {FUEL: {"2026-10-01.2027-03-31": 0.0, "2027-04-01.2100-12-31": 0.05}}, + "uk", + ) + simulation = Simulation(situation=SITUATION, reform=reform) + rate = simulation.tax_benefit_system.parameters.get_child(FUEL) + expected = day_weighted( + FUEL, + 2026, + [ + (datetime.date(2026, 10, 1), datetime.date(2027, 3, 31), 0.0), + (datetime.date(2027, 4, 1), ONWARDS, 0.05), + ], + ) + baseline = dated(FUEL) + # 6 April to 30 September at the schedule (178 days at 52.95p), 182 + # zero-rated days, and 1 to 5 April 2027 at 5%. + assert baseline("2026-04-06") == baseline("2026-09-30") == 0.5295 + assert expected == pytest.approx((178 * 0.5295 + 5 * 0.05) / 365, abs=1e-12) + assert rate("2026") == pytest.approx(expected, abs=1e-12) + assert rate("2027") == pytest.approx(0.05, abs=1e-12) + + +@pytest.mark.parametrize( + "period_values", + [ + {"2027": 0.5595}, + {"2026-01-01.2100-12-31": 0.60}, + {"2026-01-01.2027-12-31": 0.60}, + {"year:2026:2": 0.60}, + 0.60, + ], +) +def test_whole_year_reforms_keep_their_values_exactly(period_values): + """1 January and 31 December bound model years on the converted tree. + + These reforms are applied exactly as before: every model-year value is + the one the reform writes to the converted tree. + """ + reform = Reform.from_dict({FUEL: period_values}, "uk") + reformed = Simulation(situation=SITUATION, reform=reform) + expected = converted(FUEL).clone() + if isinstance(period_values, dict): + for key, value in period_values.items(): + if "." in key: + start, stop = key.split(".") + expected.update(start=instant(start), stop=instant(stop), value=value) + elif ":" in key: + expected.update(period=key, value=value) + else: + expected.update(start=instant(key), value=value) + else: + expected.update(period="year:2000:100", value=period_values) + parameter = reformed.tax_benefit_system.parameters.get_child(FUEL) + for year in YEARS: + assert parameter(str(year)) == expected(str(year)), year + + +def test_later_reform_keeps_an_earlier_mid_year_change(): + first = Reform.from_dict({FUEL: {"2026-09-01.2100-12-31": 0.60}}, "uk") + second = Reform.from_dict({FUEL: {"2028": 0.70}}, "uk") + simulation = Simulation(situation=SITUATION, reform=(first, second)) + parameter = simulation.tax_benefit_system.parameters.get_child(FUEL) + assert parameter("2026") == pytest.approx( + day_weighted(FUEL, 2026, [(datetime.date(2026, 9, 1), ONWARDS, 0.60)]), + abs=1e-12, + ) + assert parameter("2027") == pytest.approx(0.60, abs=1e-12) + assert parameter("2028") == pytest.approx(0.70, abs=1e-12) + + +def test_dict_reform_with_a_range_key_is_day_weighted(): + reform = {FUEL: {"2026-09-01.2100-12-31": 0.60}} + expected = LITRES * ( + day_weighted(FUEL, 2026, [(datetime.date(2026, 9, 1), ONWARDS, 0.60)]) + - converted(FUEL)("2026") + ) + assert change(2026, reform) == pytest.approx(expected, rel=1e-5) + + +def test_reform_applied_before_data_load_is_day_weighted(): + reform = Reform.from_dict({FUEL: {"2026-09-01.2100-12-31": 0.60}}, "uk") + scenario = Scenario.from_reform(reform) + scenario.applied_before_data_load = True + expected = LITRES * ( + day_weighted(FUEL, 2026, [(datetime.date(2026, 9, 1), ONWARDS, 0.60)]) + - converted(FUEL)("2026") + ) + assert fuel_duty(2026, scenario=scenario) - fuel_duty(2026) == pytest.approx( + expected, rel=1e-5 + ) + + +def test_reform_leaves_the_baseline_schedule_untouched(): + reform = Reform.from_dict({FUEL: {"2026-09-01.2100-12-31": 0.60}}, "uk") + simulation = Simulation(situation=SITUATION, reform=reform) + schedule = simulation.tax_benefit_system.parameters.baseline.get_child(FUEL) + assert schedule("2026-10-01") == pytest.approx(0.5295) + assert schedule("2027-03-01") == pytest.approx(0.5795) + assert ( + "update" + not in simulation.tax_benefit_system.parameters.get_child(FUEL).__dict__ + ) + + +def test_unblended_parameter_is_not_re_averaged(): + """Blending is opt-in: a mid-year reform to an unblended parameter is + applied to the converted tree as written.""" + path = "gov.hmrc.national_insurance.class_1.rates.employee.main" + reform = Reform.from_dict({path: {"2026-09-01.2100-12-31": 0.10}}, "uk") + simulation = Simulation(situation=SITUATION, reform=reform) + parameter = simulation.tax_benefit_system.parameters.get_child(path) + assert parameter("2026-08-31") == converted(path)("2026") + assert parameter("2026-09-01") == 0.10 diff --git a/policyengine_uk/utils/parameters.py b/policyengine_uk/utils/parameters.py index fdbe9b663a..e8ea4eed9c 100644 --- a/policyengine_uk/utils/parameters.py +++ b/policyengine_uk/utils/parameters.py @@ -1,4 +1,5 @@ import datetime +from contextlib import contextmanager from policyengine_core.model_api import * from policyengine_core import periods @@ -99,25 +100,183 @@ def convert_to_fiscal_year_parameters(parameters): Values are computed for every year before any are written, so that rewriting one year cannot affect the reading of another. """ - # Cover years from 2015 through 2040 for long-term projections - YEARS = list(range(2015, 2041)) for param in parameters.get_descendants(): if isinstance(param, Parameter): if (param.metadata or {}).get("preserve_calendar_dates", False): continue blend = (param.metadata or {}).get("fiscal_year_blend", False) - values = {} - for year in YEARS: - value = fiscal_year_average(param, year) if blend else None - if value is None: - value = param(f"{year}-04-30") - values[year] = value - for year, value in values.items(): - param.update( - period=f"{year}", + _write_model_year_values(param, param, blend) + return parameters + + +# Cover years from 2015 through 2040 for long-term projections +FISCAL_YEARS = list(range(2015, 2041)) + + +def _write_model_year_values(param, schedule, blend: bool) -> None: + """Write one value per model year to param, read from a dated schedule.""" + values = {} + for year in FISCAL_YEARS: + value = fiscal_year_average(schedule, year) if blend else None + if value is None: + value = schedule(f"{year}-04-30") + values[year] = value + for year, value in values.items(): + param.update( + period=f"{year}", + value=value, + ) + + +def _is_blended(param) -> bool: + metadata = param.metadata or {} + return bool(metadata.get("fiscal_year_blend")) and not metadata.get( + "preserve_calendar_dates" + ) + + +class _DetachedParent: + """Parent for a working copy of a dated schedule. + + ``Parameter.update`` clears and marks its parent; a copy hanging off the + baseline tree would otherwise mark the baseline as modified. + """ + + def clear_parent_cache(self): + pass + + def mark_as_modified(self): + pass + + +def _to_instant(value): + if value is None or isinstance(value, periods.Instant): + return value + return periods.instant(value) + + +def _dated_update(value, period, start, stop, remove_after): + """Restate an update to the model-year tree as one to the dated schedule. + + On the converted tree a calendar year holds the model year that starts in + it, so 1 January is a model-year boundary there, and so is the day after + 31 December. Those boundaries move to 6 April on the dated schedule. Any + other date is kept as written. Returns the update and whether every + boundary was a model-year boundary. + """ + if period is not None: + if isinstance(period, str): + period = periods.period(period) + start, stop = period.start, period.stop + start, stop = _to_instant(start), _to_instant(stop) + aligned = True + if start is not None and start.year > 1: + if (start.month, start.day) == (1, 1): + start = periods.Instant((start.year, 4, 6)) + else: + aligned = False + if stop is not None: + following = stop.offset(1, "day") + if (following.month, following.day) == (1, 1): + stop = periods.Instant((following.year, 4, 5)) + else: + aligned = False + return ( + dict(value=value, start=start, stop=stop, remove_after=remove_after), + aligned, + ) + + +def _reblend(param, updates, baseline) -> None: + """Re-run the fiscal-year average after a reform edits a blended parameter. + + The reform's updates are replayed on the dated schedule the model-year + values were averaged from: the uprated baseline, or the schedule left by + an earlier reform. Model years are rewritten only if some update starts + or ends part-way through a year, so whole-year reforms keep their current + values exactly. + """ + source = param.__dict__.get("_fiscal_year_dated_schedule") + if source is None: + source = baseline.get_child(param.name) + schedule = source.clone() + schedule.parent = _DetachedParent() + part_year = False + for update in updates: + kwargs, aligned = _dated_update(**update) + schedule.update(**kwargs) + part_year = part_year or not aligned + param.__dict__["_fiscal_year_dated_schedule"] = schedule + if part_year: + _write_model_year_values(param, schedule, blend=True) + + +@contextmanager +def reblend_dated_reforms(tax_benefit_system): + """Day-weight reforms dated part-way through a year on blended parameters. + + Reforms edit the converted tree, where each blended parameter + (``fiscal_year_blend: true``) already holds a day-weighted average per + model year. A change dated, say, 1 September would otherwise apply only + from the September monthly reading and leave the earlier months on an + average that still includes the rates the reform replaces. Inside this + context each update to a blended parameter is recorded, and on exit it + is replayed on the dated schedule and the affected parameter re-averaged. + """ + parameters = tax_benefit_system.parameters + gov = parameters.children.get("gov") + baseline = parameters.children.get("baseline") + if gov is None or baseline is None: + yield + return + recorded = {} + wrapped = [] + for param in gov.get_descendants(): + if ( + not isinstance(param, Parameter) + or not _is_blended(param) + or "update" in param.__dict__ + ): + continue + + def record( + value=None, + period=None, + start=None, + stop=None, + remove_after=False, + _param=param, + _update=param.update, + ): + result = _update( + value=value, + period=period, + start=start, + stop=stop, + remove_after=remove_after, + ) + recorded.setdefault(id(_param), (_param, []))[1].append( + dict( value=value, + period=period, + start=start, + stop=stop, + remove_after=remove_after, ) - return parameters + ) + return result + + param.update = record + wrapped.append(param) + try: + yield + finally: + for param in wrapped: + del param.__dict__["update"] + for param, updates in recorded.values(): + _reblend(param, updates, baseline) + if recorded: + tax_benefit_system.reset_parameter_caches() _STATE_PENSION_AGE_REPLACEMENT = ( diff --git a/policyengine_uk/utils/scenario.py b/policyengine_uk/utils/scenario.py index 8484578fb8..dbb3b7dd07 100644 --- a/policyengine_uk/utils/scenario.py +++ b/policyengine_uk/utils/scenario.py @@ -5,6 +5,7 @@ from policyengine_core.periods import period, instant from policyengine_uk.utils.parameters import ( check_parameter_not_removed, + reblend_dated_reforms, uk_fiscal_year_period, ) @@ -23,7 +24,8 @@ def _apply_reform_class(reform: Type[Reform], simulation: Simulation) -> None: # ``applied_before_data_load`` is set on the Scenario afterwards, so the # simulation's populations (created by data load) are the phase signal. if getattr(simulation, "populations", None) is None: - reform.apply(simulation.tax_benefit_system) + with reblend_dated_reforms(simulation.tax_benefit_system): + reform.apply(simulation.tax_benefit_system) else: simulation.apply_reform(reform) # Adding or replacing parameter nodes does not clear the per-node @@ -158,40 +160,43 @@ def modifier(simulation: Simulation) -> None: # Make sure to capture YYYY-MM-DD.YYYY-MM-DD. def modifier(sim: Simulation): - for parameter in reform: - check_parameter_not_removed(parameter) - target = sim.tax_benefit_system.parameters.get_child(parameter) - if isinstance(reform[parameter], dict): - for period_str, value in reform[parameter].items(): - if "." in period_str: - start = instant(period_str.split(".")[0]) - stop = instant(period_str.split(".")[1]) - period_ = None - else: - start = None - stop = None - period_ = ( - uk_fiscal_year_period(period_str) - if target.metadata.get("preserve_calendar_dates") - else period(period_str) + with reblend_dated_reforms(sim.tax_benefit_system): + for parameter in reform: + check_parameter_not_removed(parameter) + target = sim.tax_benefit_system.parameters.get_child(parameter) + if isinstance(reform[parameter], dict): + for period_str, value in reform[parameter].items(): + if "." in period_str: + start = instant(period_str.split(".")[0]) + stop = instant(period_str.split(".")[1]) + period_ = None + else: + start = None + stop = None + period_ = ( + uk_fiscal_year_period(period_str) + if target.metadata.get( + "preserve_calendar_dates" + ) + else period(period_str) + ) + target.update( + start=start, + stop=stop, + period=period_, + value=value, ) + else: + start = instant("2023-01-01") + stop = None + period_ = None + target.update( start=start, stop=stop, period=period_, - value=value, + value=reform[parameter], ) - else: - start = instant("2023-01-01") - stop = None - period_ = None - - target.update( - start=start, - stop=stop, - period=period_, - value=reform[parameter], - ) return Scenario( simulation_modifier=modifier,