diff --git a/tariff_fetch/urdb/rateacuity_history_gas/__init__.py b/tariff_fetch/urdb/rateacuity_history_gas/__init__.py index 0e64a40..dd57f2a 100644 --- a/tariff_fetch/urdb/rateacuity_history_gas/__init__.py +++ b/tariff_fetch/urdb/rateacuity_history_gas/__init__.py @@ -1,11 +1,12 @@ import itertools +from calendar import monthrange from collections.abc import Collection from math import inf from tariff_fetch.urdb.schema import EnergyTier, URDBRate from .exceptions import EmptyBandsError -from .history_data import ConsumptionRow, FixedChargeRow, PercentageRow, Row +from .history_data import ConsumptionRow, FixedChargeRow, PercentageRow, Row, Season def build_urdb(rows: Collection[Row], include_taxes: bool) -> URDBRate: @@ -20,9 +21,10 @@ def _build_energy_schedule_raw(rows: Collection[Row], include_taxes: bool) -> UR for month in range(0, 12): tax = monthly_taxes[month] if include_taxes else 1 bands = [ - (row.start_kwh, row.end_kwh, row.month_value_kwh(month) * tax) + (row.start_kwh, row.end_kwh, row.month_value_kwh(month) * factor * tax) for row in rows if isinstance(row, ConsumptionRow) + if (factor := _seasonal_month_fraction(row.season, row.year, month)) > 0 ] band_limits = sorted({*(l1 for l1, _, _ in bands), *(l2 for _, l2, _ in bands)}) summed_bands = tuple( @@ -63,14 +65,25 @@ def _build_energy_schedule_raw(rows: Collection[Row], include_taxes: bool) -> UR def _build_static_charges(rows: Collection[Row]) -> URDBRate: fixed_charge_sum = ( - sum(row.month_value(month) for row in rows if isinstance(row, FixedChargeRow) for month in range(0, 12)) / 12 + sum( + row.month_value(month) * _seasonal_month_fraction(row.season, row.year, month) + for row in rows + if isinstance(row, FixedChargeRow) + for month in range(0, 12) + ) + / 12 ) return {"fixedchargefirstmeter": fixed_charge_sum, "fixedchargeunits": "$/month"} def _get_monthly_taxes(rows: Collection[Row]) -> list[float]: return [ - 1 + sum(row.month_value_float(month) for row in rows if isinstance(row, PercentageRow)) + 1 + + sum( + row.month_value_float(month) * _seasonal_month_fraction(row.season, row.year, month) + for row in rows + if isinstance(row, PercentageRow) + ) for month in range(0, 12) ] @@ -80,3 +93,21 @@ def _band_tuple_to_tier(band_tuple: tuple[float, float]) -> EnergyTier: if limit == inf: return {"rate": rate, "unit": "kWh"} return {"rate": rate, "unit": "kWh", "max": limit} + + +def _seasonal_month_fraction(season: Season | None, year: int, month: int) -> float: + if season is None: + return 1 + month_number = month + 1 + days_in_month = monthrange(year, month_number)[1] + days_in_season = sum(1 for day in range(1, days_in_month + 1) if _season_contains_day(season, month_number, day)) + return days_in_season / days_in_month + + +def _season_contains_day(season: Season, month: int, day: int) -> bool: + start = (season.start.month, season.start.day) + end = (season.end.month, season.end.day) + current = (month, day) + if start <= end: + return start <= current <= end + return current >= start or current <= end diff --git a/tariff_fetch/urdb/rateacuity_history_gas/history_data.py b/tariff_fetch/urdb/rateacuity_history_gas/history_data.py index 2693e8c..21d8ba9 100644 --- a/tariff_fetch/urdb/rateacuity_history_gas/history_data.py +++ b/tariff_fetch/urdb/rateacuity_history_gas/history_data.py @@ -1,11 +1,13 @@ import contextlib +import re +from calendar import monthrange from collections.abc import Iterator from datetime import datetime from math import inf -from typing import Any, cast, final +from typing import Any, NamedTuple, cast, final import polars as pl -from pydantic import BaseModel, TypeAdapter, ValidationError +from pydantic import BaseModel, TypeAdapter, ValidationError, field_validator from typing_extensions import override from .exceptions import IncorrectDataframeSchemaMonths, IncorrectDataframeSchemaMultipleYears @@ -18,6 +20,19 @@ ) +class DayOfMonth(NamedTuple): + day: int + month: int + + +class Season(NamedTuple): + start: DayOfMonth + end: DayOfMonth + + +_SEASON_PATTERN = re.compile(r"^\s*(\d{2})/(\d{2})\s*-\s*(\d{2})/(\d{2})\s*$") + + @final class RowValidationError(Exception): def __init__(self, row: dict[str, Any]) -> None: # pyright: ignore[reportExplicitAny] @@ -79,7 +94,8 @@ def location_avg_factor(self) -> float: class _Row(BaseModel): rate: str - season: str | None + season: Season | None + year: int effective_date: str | None start: float | None = None end: float | None = None @@ -89,6 +105,24 @@ class _Row(BaseModel): month_values: list[float | None] location_avg_factor: float + @field_validator("season", mode="before") + @classmethod + def _parse_season(cls, value: object) -> object: + if value is None or value == "": + return None + if isinstance(value, Season): + return value + if not isinstance(value, str): + return value + match = _SEASON_PATTERN.fullmatch(value) + if match is None: + return value + start_month, start_day, end_month, end_day = (int(part) for part in match.groups()) + return Season( + start=_validated_day_of_month(start_month, start_day), + end=_validated_day_of_month(end_month, end_day), + ) + @property def start_kwh(self) -> float: if self.determinant is None: @@ -151,6 +185,7 @@ def month_value(self, month: int) -> float: def _row_to_model(row: dict[str, Any], location_avg_factor: float, month_column_names: list[str]) -> Row: # pyright: ignore[reportExplicitAny] result = row.copy() result["month_values"] = [] + result["year"] = datetime.strptime(month_column_names[0], "%m/%d/%Y").year for col in month_column_names: value = cast(float | None, row[col]) del result[col] @@ -172,3 +207,12 @@ def _get_month_column_names(df: pl.DataFrame): if len({c.year for c in date_columns_datetimes}) != 1: raise IncorrectDataframeSchemaMultipleYears() return date_columns + + +def _validated_day_of_month(month: int, day: int) -> DayOfMonth: + if not 1 <= month <= 12: + raise ValueError(f"Invalid season month: {month}") + # Use a leap year so recurring seasonal boundaries can represent Feb 29. + if not 1 <= day <= monthrange(2024, month)[1]: + raise ValueError(f"Invalid season day {day} for month {month}") + return DayOfMonth(day=day, month=month) diff --git a/tests/test_rateacuity_history_gas_build.py b/tests/test_rateacuity_history_gas_build.py new file mode 100644 index 0000000..9b7217d --- /dev/null +++ b/tests/test_rateacuity_history_gas_build.py @@ -0,0 +1,114 @@ +import pytest + +from tariff_fetch.urdb.rateacuity_history_gas import ( + _build_energy_schedule_raw, + _build_static_charges, + _get_monthly_taxes, + _seasonal_month_fraction, +) +from tariff_fetch.urdb.rateacuity_history_gas.history_data import ( + ConsumptionRow, + DayOfMonth, + FixedChargeRow, + PercentageRow, + Season, +) +from tariff_fetch.urdb.rateacuity_history_gas.shared import kwh_multiplier + + +def test_seasonal_month_fraction_handles_partial_wraparound_seasons() -> None: + season = Season( + start=DayOfMonth(day=15, month=11), + end=DayOfMonth(day=10, month=4), + ) + + assert _seasonal_month_fraction(season, 2025, 9) == 0 + assert _seasonal_month_fraction(season, 2025, 10) == pytest.approx(16 / 30) + assert _seasonal_month_fraction(season, 2025, 11) == 1 + assert _seasonal_month_fraction(season, 2025, 3) == pytest.approx(10 / 30) + assert _seasonal_month_fraction(season, 2025, 4) == 0 + + +def test_seasonal_month_fraction_uses_actual_year_for_leap_february() -> None: + season = Season( + start=DayOfMonth(day=1, month=2), + end=DayOfMonth(day=29, month=2), + ) + + assert _seasonal_month_fraction(season, 2024, 1) == 1 + assert _seasonal_month_fraction(season, 2025, 1) == pytest.approx(28 / 28) + + +def test_build_helpers_prorate_percentage_and_fixed_rows_by_season() -> None: + season = Season( + start=DayOfMonth(day=15, month=11), + end=DayOfMonth(day=10, month=12), + ) + percentage_row = PercentageRow( + rate="Tax", + season=season, + year=2025, + effective_date=None, + month_values=[10.0] * 12, + location_avg_factor=1, + rate_determinant="percent", + ) + fixed_row = FixedChargeRow( + rate="Customer Charge", + season=season, + year=2025, + effective_date=None, + month_values=[30.0] * 12, + location_avg_factor=1, + rate_determinant="per month", + ) + + monthly_taxes = _get_monthly_taxes([percentage_row]) + static_charges = _build_static_charges([fixed_row]) + fixed_charge = static_charges.get("fixedchargefirstmeter") + + assert monthly_taxes[9] == 1 + assert monthly_taxes[10] == pytest.approx(1 + 0.1 * (16 / 30)) + assert monthly_taxes[11] == pytest.approx(1 + 0.1 * (10 / 31)) + assert fixed_charge is not None + assert fixed_charge == pytest.approx((30 * (16 / 30) + 30 * (10 / 31)) / 12) + + +def test_build_energy_schedule_prorates_consumption_rows_by_season() -> None: + season = Season( + start=DayOfMonth(day=15, month=11), + end=DayOfMonth(day=10, month=12), + ) + baseline_row = ConsumptionRow( + rate="Base", + season=None, + year=2025, + effective_date=None, + month_values=[1.0] * 12, + location_avg_factor=1, + rate_determinant="per therm", + ) + seasonal_row = ConsumptionRow( + rate="Winter Adder", + season=season, + year=2025, + effective_date=None, + month_values=[1.0] * 12, + location_avg_factor=1, + rate_determinant="per therm", + ) + + urdb = _build_energy_schedule_raw([baseline_row, seasonal_row], include_taxes=False) + rate_structure = urdb.get("energyratestructure") + schedules = urdb.get("energyweekdayschedule") + assert rate_structure is not None + assert schedules is not None + + october_rate = rate_structure[schedules[9][0]][0]["rate"] + november_rate = rate_structure[schedules[10][0]][0]["rate"] + december_rate = rate_structure[schedules[11][0]][0]["rate"] + + base_rate = round(1 / kwh_multiplier("per therm"), 6) + assert october_rate == pytest.approx(base_rate) + assert november_rate == pytest.approx(round((1 / kwh_multiplier("per therm")) * (1 + 16 / 30), 6)) + assert december_rate == pytest.approx(round((1 / kwh_multiplier("per therm")) * (1 + 10 / 31), 6)) diff --git a/tests/test_rateacuity_history_gas_history_data.py b/tests/test_rateacuity_history_gas_history_data.py new file mode 100644 index 0000000..607aa22 --- /dev/null +++ b/tests/test_rateacuity_history_gas_history_data.py @@ -0,0 +1,65 @@ +import pytest + +from tariff_fetch.urdb.rateacuity_history_gas.history_data import ( + DayOfMonth, + RowValidationError, + Season, + _row_to_model, +) + + +def _base_row() -> dict[str, object]: + row: dict[str, object] = { + "rate": "Supply", + "season": None, + "effective_date": None, + "start": None, + "end": None, + "determinant": None, + "location": None, + "rate_determinant": "per month", + } + for month in range(1, 13): + row[f"{month:02d}/01/2025"] = 1.0 + return row + + +def test_row_to_model_parses_season_string() -> None: + row = _base_row() + row["season"] = "11/01 - 04/30" + + result = _row_to_model(row, location_avg_factor=1, month_column_names=list(row.keys())[-12:]) + + assert result.season == Season( + start=DayOfMonth(day=1, month=11), + end=DayOfMonth(day=30, month=4), + ) + + +def test_row_to_model_rejects_invalid_season_string() -> None: + row = _base_row() + row["season"] = "winter" + + with pytest.raises(RowValidationError): + _row_to_model(row, location_avg_factor=1, month_column_names=list(row.keys())[-12:]) + + +@pytest.mark.parametrize("season", ["13/01 - 04/30", "00/15 - 01/01", "02/30 - 03/01", "04/31 - 05/01"]) +def test_row_to_model_rejects_impossible_season_dates(season: str) -> None: + row = _base_row() + row["season"] = season + + with pytest.raises(RowValidationError): + _row_to_model(row, location_avg_factor=1, month_column_names=list(row.keys())[-12:]) + + +def test_row_to_model_allows_february_29_season_boundary() -> None: + row = _base_row() + row["season"] = "02/29 - 03/01" + + result = _row_to_model(row, location_avg_factor=1, month_column_names=list(row.keys())[-12:]) + + assert result.season == Season( + start=DayOfMonth(day=29, month=2), + end=DayOfMonth(day=1, month=3), + )