From c691bbd2a84aab88b23eae67d43823c792a69f3a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 16:52:33 -0400 Subject: [PATCH 1/4] Date the NY Drive Clean schedule change July 1, 2021; guard calendar dates The NYSERDA Drive Clean Implementation Manual (last updated June 30, 2021, section 4) gives one rebate table for vehicles purchased on or before June 30, 2021 and another for vehicles purchased after it. The parameters dated two of the new amounts 2021-06-31, which is not a day, and the other new amount and the $42,000 MSRP cap 2021-06-30, one day early. All four now start on 2021-07-01. Core compares instants as strings and its INSTANT_PATTERN accepts day 31 in any month, so the impossible date loaded without error, and the duplicate-threshold test skipped it by name. That exception is gone: a new test requires every parameter instant to be a calendar date, with the documented 0000-01-01 sentinel as the only exception. Co-Authored-By: Claude Opus 5.5 --- .../ny-drive-clean-effective-date.fixed.md | 1 + .../states/ny/nyserda/drive_clean/amount.yaml | 7 ++- .../flat_rebate/msrp_threshold.yaml | 3 +- .../test_ny_drive_clean_rebate_schedule.py | 42 +++++++++++++ .../tests/test_parameter_scale_thresholds.py | 62 +++++++++++++++++-- 5 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 changelog.d/ny-drive-clean-effective-date.fixed.md create mode 100644 policyengine_us/tests/test_ny_drive_clean_rebate_schedule.py 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/test_ny_drive_clean_rebate_schedule.py b/policyengine_us/tests/test_ny_drive_clean_rebate_schedule.py new file mode 100644 index 00000000000..21a0e790803 --- /dev/null +++ b/policyengine_us/tests/test_ny_drive_clean_rebate_schedule.py @@ -0,0 +1,42 @@ +"""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. Annual simulations read parameters on January 1, so this +schedule is checked at the two instants directly. +""" + +import numpy as np +import pytest + +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]) + + +@pytest.mark.parametrize( + "instant, amounts, msrp_threshold", + [ + ( + "2021-06-30", + [500, 1_100, 1_100, 1_700, 1_700, 2_000, 2_000, 2_000], + 60_000, + ), + ( + "2021-07-01", + [500, 500, 500, 1_000, 1_000, 1_000, 1_000, 2_000], + 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 diff --git a/policyengine_us/tests/test_parameter_scale_thresholds.py b/policyengine_us/tests/test_parameter_scale_thresholds.py index 0899577cbed..90873cefbc5 100644 --- a/policyengine_us/tests/test_parameter_scale_thresholds.py +++ b/policyengine_us/tests/test_parameter_scale_thresholds.py @@ -16,11 +16,12 @@ """ import math +import re from collections import defaultdict from datetime import date import pytest -from policyengine_core.parameters import ParameterNode, ParameterScale +from policyengine_core.parameters import Parameter, ParameterNode, ParameterScale from policyengine_core.parameters.operations.uprate_parameters import ( uprate_parameters, ) @@ -212,10 +213,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 +232,48 @@ 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", + ] + + +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 +291,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) From 69002d342646e0e270a07c32144849175a565a43 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 17:21:03 -0400 Subject: [PATCH 2/4] Assert the calendar-date check rejects partial and compact instants Review note: the old check in this file skipped every instant that date.fromisoformat could not parse (a comment named the Drive Clean dates), rather than skipping those dates by name as the previous commit message said. The new check's YYYY-MM-DD pattern now has direct cases: "2021" and "2021-06" (which core's INSTANT_PATTERN accepts) and "20210630" and "2021-W26-3" (which date.fromisoformat accepts). Co-Authored-By: Claude Opus 5.5 --- policyengine_us/tests/test_parameter_scale_thresholds.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/policyengine_us/tests/test_parameter_scale_thresholds.py b/policyengine_us/tests/test_parameter_scale_thresholds.py index 90873cefbc5..e3ca5ae02cd 100644 --- a/policyengine_us/tests/test_parameter_scale_thresholds.py +++ b/policyengine_us/tests/test_parameter_scale_thresholds.py @@ -261,6 +261,10 @@ def test_calendar_date_guard_flags_impossible_dates_only(): "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) def test_parameter_instants_are_calendar_dates(): From 6f29a5f53579bf6f7b28a343d514bc7e7ba9278a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 14:00:10 -0400 Subject: [PATCH 3/4] test: property-test parameter calendar date validation --- .../tests/test_parameter_scale_thresholds.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/policyengine_us/tests/test_parameter_scale_thresholds.py b/policyengine_us/tests/test_parameter_scale_thresholds.py index e3ca5ae02cd..5a4899c5de8 100644 --- a/policyengine_us/tests/test_parameter_scale_thresholds.py +++ b/policyengine_us/tests/test_parameter_scale_thresholds.py @@ -15,12 +15,15 @@ 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 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, @@ -267,6 +270,64 @@ def test_calendar_date_guard_flags_impossible_dates_only(): 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}" From 1480bbf54c81e676dfe22dea7e8e1fd375589a8e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 14:31:29 -0400 Subject: [PATCH 4/4] test: cover NY Drive Clean rolling-year schedule starts --- .../states/ny/nyserda/drive_clean_rebate.yaml | 72 +++++++++++++++++++ .../test_ny_drive_clean_rebate_schedule.py | 46 ++++++++++-- 2 files changed, 114 insertions(+), 4 deletions(-) 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 index 21a0e790803..862276999d7 100644 --- a/policyengine_us/tests/test_ny_drive_clean_rebate_schedule.py +++ b/policyengine_us/tests/test_ny_drive_clean_rebate_schedule.py @@ -5,17 +5,28 @@ 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. Annual simulations read parameters on January 1, so this -schedule is checked at the two instants directly. +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( @@ -23,12 +34,12 @@ [ ( "2021-06-30", - [500, 1_100, 1_100, 1_700, 1_700, 2_000, 2_000, 2_000], + OLD_AMOUNTS, 60_000, ), ( "2021-07-01", - [500, 500, 500, 1_000, 1_000, 1_000, 1_000, 2_000], + NEW_AMOUNTS, 42_000, ), ], @@ -40,3 +51,30 @@ def test_drive_clean_schedule_changes_after_june_30_2021( 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