diff --git a/changelog.d/ny-drive-clean-effective-date.fixed.md b/changelog.d/ny-drive-clean-effective-date.fixed.md new file mode 100644 index 00000000000..2c51574c423 --- /dev/null +++ b/changelog.d/ny-drive-clean-effective-date.fixed.md @@ -0,0 +1 @@ +New York Drive Clean rebate amounts and the MSRP cap for the flat rebate now change on July 1, 2021, for vehicles purchased after June 30, 2021, replacing the impossible date 2021-06-31 and a June 30 date one day early; a test now rejects any parameter date that is not a calendar day, other than the 0000-01-01 sentinel. diff --git a/policyengine_us/parameters/gov/states/ny/nyserda/drive_clean/amount.yaml b/policyengine_us/parameters/gov/states/ny/nyserda/drive_clean/amount.yaml index 1d5a253cf08..53eb9a81e95 100644 --- a/policyengine_us/parameters/gov/states/ny/nyserda/drive_clean/amount.yaml +++ b/policyengine_us/parameters/gov/states/ny/nyserda/drive_clean/amount.yaml @@ -1,4 +1,5 @@ description: New York provides the following Drive Clean point-of-sale rebate amount, based on the all-electric range of the vehicle. +# The lower amounts apply to vehicles purchased after June 30, 2021. brackets: # 0 - 19 miles - threshold: @@ -10,19 +11,19 @@ brackets: 2017-03-21: 20 amount: 2017-03-21: 1_100 - 2021-06-30: 500 + 2021-07-01: 500 # 40 - 119 miles - threshold: 2017-03-21: 40 amount: 2017-03-21: 1_700 - 2021-06-31: 1_000 + 2021-07-01: 1_000 # 120 - 199 miles - threshold: 2017-03-21: 119 amount: 2017-03-21: 2_000 - 2021-06-31: 1_000 + 2021-07-01: 1_000 # 200 miles and above - threshold: 2017-03-21: 200 diff --git a/policyengine_us/parameters/gov/states/ny/nyserda/drive_clean/flat_rebate/msrp_threshold.yaml b/policyengine_us/parameters/gov/states/ny/nyserda/drive_clean/flat_rebate/msrp_threshold.yaml index 91080cdd90e..7d2c90c7b99 100644 --- a/policyengine_us/parameters/gov/states/ny/nyserda/drive_clean/flat_rebate/msrp_threshold.yaml +++ b/policyengine_us/parameters/gov/states/ny/nyserda/drive_clean/flat_rebate/msrp_threshold.yaml @@ -9,4 +9,5 @@ metadata: values: 2017-03-21: 60_000 - 2021-06-30: 42_000 + # Vehicles purchased after June 30, 2021. + 2021-07-01: 42_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ny/nyserda/drive_clean_rebate.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/nyserda/drive_clean_rebate.yaml index 828be8b2af2..1c8eb981454 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ny/nyserda/drive_clean_rebate.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/nyserda/drive_clean_rebate.yaml @@ -127,3 +127,75 @@ ny_drive_clean_vehicle_electric_range: 200 output: ny_drive_clean_rebate: 0 + + +# NYSERDA Drive Clean Implementation Manual, section 4, page 8: +# https://www.nyserda.ny.gov/-/media/Project/Nyserda/Files/Programs/Drive-Clean-NY/implementation-manual.pdf#page=8 +# First-of-month rolling years exercise the annual formula on either side +# of the July 1 transition. Core 3.32.15 rejects caching mid-month annual periods; +# exact June 30 parameter lookups are covered in test_ny_drive_clean_rebate_schedule.py. +- name: June 1 rolling year retains the 20-mile rebate + period: year:2021-06-01 + input: + state_code: NY + ny_drive_clean_purchased_qualifying_vehicle: true + ny_drive_clean_vehicle_cost: 30_000 + ny_drive_clean_vehicle_electric_range: 20 + output: + # $30,000 < $60,000 cap; table gives $1,100 (July reduction: 1,100 - 500 = 600). + ny_drive_clean_rebate: 1_100 + +- name: July 1 rolling year applies the reduced 20-mile rebate + period: year:2021-07-01 + input: + state_code: NY + ny_drive_clean_purchased_qualifying_vehicle: true + ny_drive_clean_vehicle_cost: 30_000 + ny_drive_clean_vehicle_electric_range: 20 + output: + # $30,000 < $42,000 cap; table gives $500 (1,100 - 600 = 500). + ny_drive_clean_rebate: 500 + +- name: June 1 rolling year retains the 40-mile rebate + period: year:2021-06-01 + input: + state_code: NY + ny_drive_clean_purchased_qualifying_vehicle: true + ny_drive_clean_vehicle_cost: 30_000 + ny_drive_clean_vehicle_electric_range: 40 + output: + # $30,000 < $60,000 cap; table gives $1,700. + ny_drive_clean_rebate: 1_700 + +- name: July 1 rolling year applies the reduced 40-mile rebate + period: year:2021-07-01 + input: + state_code: NY + ny_drive_clean_purchased_qualifying_vehicle: true + ny_drive_clean_vehicle_cost: 30_000 + ny_drive_clean_vehicle_electric_range: 40 + output: + # $30,000 < $42,000 cap; table gives $1,000 (1,700 - 700 = 1,000). + ny_drive_clean_rebate: 1_000 + +- name: June 1 rolling year retains the higher MSRP cap + period: year:2021-06-01 + input: + state_code: NY + ny_drive_clean_purchased_qualifying_vehicle: true + ny_drive_clean_vehicle_cost: 50_000 + ny_drive_clean_vehicle_electric_range: 200 + output: + # $50,000 < $60,000 cap, so $2,000 by range (July reduction: 2,000 - 500 = 1,500). + ny_drive_clean_rebate: 2_000 + +- name: July 1 rolling year applies the lower MSRP cap + period: year:2021-07-01 + input: + state_code: NY + ny_drive_clean_purchased_qualifying_vehicle: true + ny_drive_clean_vehicle_cost: 50_000 + ny_drive_clean_vehicle_electric_range: 200 + output: + # $50,000 > $42,000 cap, so flat $500 (2,000 - 1,500 = 500). + ny_drive_clean_rebate: 500 diff --git a/policyengine_us/tests/test_ny_drive_clean_rebate_schedule.py b/policyengine_us/tests/test_ny_drive_clean_rebate_schedule.py new file mode 100644 index 00000000000..862276999d7 --- /dev/null +++ b/policyengine_us/tests/test_ny_drive_clean_rebate_schedule.py @@ -0,0 +1,80 @@ +"""The NY Drive Clean rebate schedule changes for vehicles purchased after +June 30, 2021. + +Section 4 of the NYSERDA Drive Clean Implementation Manual (last updated June +30, 2021) has one table for vehicles purchased on or before June 30, 2021 and +another for vehicles purchased after it, with the MSRP cap for the flat $500 +rebate falling from $60,000 to $42,000. The new amounts therefore start on +July 1, 2021. Calendar-year simulations read parameters on January 1; +first-of-month rolling-year YAML tests exercise the formula in June and July. +Core 3.32.15 rejects caching mid-month annual periods, so these parameter tests +cover the exact June 30 boundary and randomized schedule invariants. +Source: https://www.nyserda.ny.gov/-/media/Project/Nyserda/Files/Programs/Drive-Clean-NY/implementation-manual.pdf#page=8 +""" + +from datetime import date + +import numpy as np +import pytest +from hypothesis import example, given +from hypothesis import strategies as st + +from policyengine_us.system import system + +DRIVE_CLEAN = system.parameters.gov.states.ny.nyserda.drive_clean +ALL_ELECTRIC_RANGES = np.array([10, 20, 39, 40, 100, 120, 199, 200]) +# The manual's section 4 tables, at ranges unaffected by the disclosed +# pre-existing 119-mile threshold discrepancy. +OLD_AMOUNTS = [500, 1_100, 1_100, 1_700, 1_700, 2_000, 2_000, 2_000] +NEW_AMOUNTS = [500, 500, 500, 1_000, 1_000, 1_000, 1_000, 2_000] + + +@pytest.mark.parametrize( + "instant, amounts, msrp_threshold", + [ + ( + "2021-06-30", + OLD_AMOUNTS, + 60_000, + ), + ( + "2021-07-01", + NEW_AMOUNTS, + 42_000, + ), + ], +) +def test_drive_clean_schedule_changes_after_june_30_2021( + instant, amounts, msrp_threshold +): + scale = DRIVE_CLEAN.amount(instant) + + assert scale.calc(ALL_ELECTRIC_RANGES).tolist() == amounts + assert DRIVE_CLEAN.flat_rebate.msrp_threshold(instant) == msrp_threshold + + +@given(st.dates(min_value=date(2017, 3, 21), max_value=date(2022, 12, 31))) +@example(date(2021, 6, 30)) +@example(date(2021, 7, 1)) +def test_drive_clean_schedule_is_stable_on_each_side_of_transition(calendar_date): + before_july = calendar_date < date(2021, 7, 1) + amounts = OLD_AMOUNTS if before_july else NEW_AMOUNTS + msrp_threshold = 60_000 if before_july else 42_000 + instant = calendar_date.isoformat() + + assert DRIVE_CLEAN.amount(instant).calc(ALL_ELECTRIC_RANGES).tolist() == amounts + assert DRIVE_CLEAN.flat_rebate.msrp_threshold(instant) == msrp_threshold + + +@given(st.integers(min_value=2018, max_value=2035)) +@example(2021) +@example(2022) +def test_drive_clean_january_first_lookups_retain_calendar_year_schedule(year): + # All corrected dates lie after January 1, 2021 and before January 1, 2022. + # Calendar-year lookups therefore retain the same old/new source values. + instant = f"{year}-01-01" + amounts = OLD_AMOUNTS if year <= 2021 else NEW_AMOUNTS + msrp_threshold = 60_000 if year <= 2021 else 42_000 + + assert DRIVE_CLEAN.amount(instant).calc(ALL_ELECTRIC_RANGES).tolist() == amounts + assert DRIVE_CLEAN.flat_rebate.msrp_threshold(instant) == msrp_threshold diff --git a/policyengine_us/tests/test_parameter_scale_thresholds.py b/policyengine_us/tests/test_parameter_scale_thresholds.py index 0899577cbed..5a4899c5de8 100644 --- a/policyengine_us/tests/test_parameter_scale_thresholds.py +++ b/policyengine_us/tests/test_parameter_scale_thresholds.py @@ -15,12 +15,16 @@ a reachable bottom row, so duplicates there are flagged. """ +import calendar import math +import re from collections import defaultdict from datetime import date import pytest -from policyengine_core.parameters import ParameterNode, ParameterScale +from hypothesis import example, given +from hypothesis import strategies as st +from policyengine_core.parameters import Parameter, ParameterNode, ParameterScale from policyengine_core.parameters.operations.uprate_parameters import ( uprate_parameters, ) @@ -212,10 +216,18 @@ def test_duplicate_threshold_guard_reads_tied_dates_like_core(): assert _duplicate_threshold_errors(distinct) == [] +# Several parameter files key a value at 0000-01-01 to mean "since the +# beginning of time" (see tests/code_health/test_uprating_placement.py). Python +# dates have no year zero, so it is the one instant that is not a calendar date. +YEAR_ZERO_SENTINEL = "0000-01-01" + + def _is_calendar_date(instant_str): - # Core's public lookup parses the instant, which fails for padded - # 0000-01-01 entries and for 2021-06-31 in - # gov.states.ny.nyserda.drive_clean.amount. + # Core compares instants as strings and its INSTANT_PATTERN checks only the + # digit ranges, so an impossible day such as 2021-06-31 loads and sorts + # without error while naming no date. + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", instant_str) is None: + return False try: date.fromisoformat(instant_str) except ValueError: @@ -223,6 +235,110 @@ def _is_calendar_date(instant_str): return True +def _non_calendar_instants(parameter): + return [ + value_at_instant.instant_str + for value_at_instant in parameter.values_list + if value_at_instant.instant_str != YEAR_ZERO_SENTINEL + and not _is_calendar_date(value_at_instant.instant_str) + ] + + +def test_calendar_date_guard_flags_impossible_dates_only(): + parameter = Parameter( + "test_parameter", + { + "values": { + YEAR_ZERO_SENTINEL: 0, + "2021-06-30": 1, + "2021-06-31": 2, + "2024-02-29": 3, + "2025-02-29": 4, + "2025-04-31": 5, + } + }, + ) + + assert sorted(_non_calendar_instants(parameter)) == [ + "2021-06-31", + "2025-02-29", + "2025-04-31", + ] + # Forms core's INSTANT_PATTERN or date.fromisoformat accept that are not + # full YYYY-MM-DD days. + for instant_str in ["2021", "2021-06", "20210630", "2021-W26-3"]: + assert not _is_calendar_date(instant_str) + + +@given(st.dates()) +@example(date(2000, 2, 29)) +@example(date(2024, 2, 29)) +def test_calendar_date_guard_accepts_real_calendar_dates(calendar_date): + assert _is_calendar_date(calendar_date.isoformat()) + + +@given( + year=st.integers(min_value=1, max_value=9999), + month=st.integers(min_value=1, max_value=12), + day=st.integers(min_value=1, max_value=31), +) +@example(year=2021, month=6, day=31) +@example(year=1900, month=2, day=29) +@example(year=2000, month=2, day=29) +def test_calendar_date_guard_matches_gregorian_month_lengths(year, month, day): + instant_str = f"{year:04d}-{month:02d}-{day:02d}" + # The Gregorian month-length oracle independently checks impossible days, + # including century years that are leap years only when divisible by 400. + assert _is_calendar_date(instant_str) == ( + day <= calendar.monthrange(year, month)[1] + ) + + +@given(st.dates()) +@example(date(2021, 6, 30)) +def test_calendar_date_guard_requires_full_calendar_form(calendar_date): + canonical = calendar_date.isoformat() + iso_year, iso_week, iso_day = calendar_date.isocalendar() + alternatives = ( + canonical[:4], + canonical[:7], + canonical.replace("-", ""), + f"{iso_year:04d}-W{iso_week:02d}-{iso_day}", + ) + for instant_str in alternatives: + assert not _is_calendar_date(instant_str) + + +@given( + month=st.integers(min_value=1, max_value=12), + day=st.integers(min_value=1, max_value=31), +) +@example(month=1, day=1) +@example(month=1, day=2) +@example(month=2, day=29) +def test_calendar_date_guard_exempts_only_beginning_of_time(month, day): + instant_str = f"0000-{month:02d}-{day:02d}" + parameter = Parameter( + "test_parameter", + {"values": {YEAR_ZERO_SENTINEL: 0, instant_str: 1}}, + ) + # Year zero is not a calendar year; only the exact documented sentinel is + # exempt when validating the parameter's loaded instants. + expected = [] if instant_str == YEAR_ZERO_SENTINEL else [instant_str] + assert _non_calendar_instants(parameter) == expected + + +def test_parameter_instants_are_calendar_dates(): + errors = [ + f"{parameter.name}: {instant_str}" + for parameter in system.parameters.get_descendants() + if isinstance(parameter, Parameter) + for instant_str in _non_calendar_instants(parameter) + ] + + assert not errors, "\n".join(errors) + + def test_add_bracket_thresholds_match_core_for_every_scale_and_instant(monkeypatch): # Record every threshold core passes to add_bracket, repeats included, and # compare that multiset with the guard's; comparing the built scale's @@ -240,7 +356,8 @@ def recording_add_bracket(self, threshold, value, original=original): mismatches = [] for scale in _all_scales(): for instant_str in _change_instants(scale): - if not _is_calendar_date(instant_str): + if instant_str == YEAR_ZERO_SENTINEL: + # Core's public lookup cannot parse year zero. continue passed.clear() scale(instant_str)