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/ny-drive-clean-effective-date.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
80 changes: 80 additions & 0 deletions policyengine_us/tests/test_ny_drive_clean_rebate_schedule.py
Original file line number Diff line number Diff line change
@@ -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
127 changes: 122 additions & 5 deletions policyengine_us/tests/test_parameter_scale_thresholds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -212,17 +216,129 @@ 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:
return False
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
Expand All @@ -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)
Expand Down
Loading