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
39 changes: 35 additions & 4 deletions tariff_fetch/urdb/rateacuity_history_gas/__init__.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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)
]

Expand All @@ -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
50 changes: 47 additions & 3 deletions tariff_fetch/urdb/rateacuity_history_gas/history_data.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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]
Expand All @@ -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)
114 changes: 114 additions & 0 deletions tests/test_rateacuity_history_gas_build.py
Original file line number Diff line number Diff line change
@@ -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))
65 changes: 65 additions & 0 deletions tests/test_rateacuity_history_gas_history_data.py
Original file line number Diff line number Diff line change
@@ -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),
)
Loading