diff --git a/dcaf/project/_compiler.py b/dcaf/project/_compiler.py index 0e306dd..583251b 100644 --- a/dcaf/project/_compiler.py +++ b/dcaf/project/_compiler.py @@ -432,6 +432,8 @@ def build_generation(self) -> GenerationStream: amount_mwh=(generation.capacity_mw * generation.capacity_factor * hours), date=modeled_period.event_date, label=generation.label, + period_start=modeled_period.start, + period_end=modeled_period.end, ) ) base_generation = GenerationStream(entries) diff --git a/dcaf/streams/generation.py b/dcaf/streams/generation.py index 153a6ec..987caf1 100644 --- a/dcaf/streams/generation.py +++ b/dcaf/streams/generation.py @@ -49,17 +49,59 @@ class Generation: amount_mwh : float Energy produced in MWh. date : date - Date of the generation. + Recognition date used to place the entry in time for grouping, pricing, + escalation, discounting, and conversion to cashflows. When + ``period_start`` and ``period_end`` are provided, this is the booking + date for generation accumulated over that period rather than the date + on which all generation physically occurred. It is normally selected + from the period according to a timing convention, such as its beginning, + middle, or final day. label : str Descriptive label. + period_start : date or None + Inclusive start of the period represented by ``amount_mwh``. Must be + provided together with ``period_end``. + period_end : date or None + Exclusive end of the period represented by ``amount_mwh``. Must be + provided together with ``period_start``. + + Raises + ------ + ValueError + If only one period bound is provided or the period is empty or reversed. + + Notes + ----- + ``period_start`` and ``period_end`` describe the physical generation interval + using half-open ``[period_start, period_end)`` semantics. If they are omitted, + the entry is treated as a point event occurring on ``date``. The recognition + date remains the date used by stream operations and financial calculations in + both cases. """ amount_mwh: float date: date label: str = "" + period_start: dt.date | None = None + period_end: dt.date | None = None + + def __post_init__(self) -> None: + if (self.period_start is None) != (self.period_end is None): + raise ValueError("period_start and period_end must be provided together") + if ( + self.period_start is not None + and self.period_end is not None + and self.period_end <= self.period_start + ): + raise ValueError("period_end must be after period_start") def replace( - self, amount_mwh: float | None = None, date: dt.date | None = None, label: str | None = None + self, + amount_mwh: float | None = None, + date: dt.date | None = None, + label: str | None = None, + period_start: dt.date | None = None, + period_end: dt.date | None = None, ) -> "Generation": """ Return a new version of this Generation with the specified changes to parameters. @@ -68,7 +110,11 @@ def replace( ---------- amount_mwh: float | None = None date: date | None = None + Replacement recognition date. For an interval-backed entry, changing + this does not change its physical generation period. label: str | None = None + period_start: date | None = None + period_end: date | None = None Returns ------- @@ -98,6 +144,8 @@ def replace( amount_mwh=self.amount_mwh if amount_mwh is None else amount_mwh, date=self.date if date is None else date, label=self.label if label is None else label, + period_start=self.period_start if period_start is None else period_start, + period_end=self.period_end if period_end is None else period_end, ) @@ -316,6 +364,8 @@ def from_capacity( amount_mwh=mwh, date=period_window_event_date(window, timing), label=label, + period_start=window.start, + period_end=window.end, ) ) return cls(entries) @@ -404,6 +454,8 @@ def from_outage( amount_mwh=-lost_mwh, date=_outage_event_date(start=start, end=end, timing=timing), label=label, + period_start=start, + period_end=end, ) ] ) diff --git a/dcaf/tax/incentives.py b/dcaf/tax/incentives.py index 0f2c34f..7fd8455 100644 --- a/dcaf/tax/incentives.py +++ b/dcaf/tax/incentives.py @@ -13,7 +13,10 @@ from datetime import date +from dateutil.relativedelta import relativedelta + from dcaf.finance.escalation import EscalationPolicy +from dcaf.shared.time import elapsed_hours from dcaf.shared.types import ( DayCountConvention, Period, @@ -23,7 +26,37 @@ ) from dcaf.shared.validation import validate_non_negative from dcaf.streams.cashflows import CashFlow, CashFlowStream -from dcaf.streams.generation import GenerationStream, _generation_escalation +from dcaf.streams.generation import Generation, GenerationStream, _generation_escalation + + +def _eligible_ptc_generation( + entry: Generation, + *, + eligibility_start: date, + eligibility_end: date, + day_count_convention: DayCountConvention, +) -> float | None: + """Return eligible MWh, prorating an interval-backed generation entry by overlap.""" + if entry.period_start is None: + return entry.amount_mwh if eligibility_start <= entry.date < eligibility_end else None + + assert entry.period_end is not None + overlap_start = max(entry.period_start, eligibility_start) + overlap_end = min(entry.period_end, eligibility_end) + if overlap_end <= overlap_start: + return None + if overlap_start == entry.period_start and overlap_end == entry.period_end: + return entry.amount_mwh + + period_hours = elapsed_hours( + entry.period_start, + entry.period_end, + day_count_convention, + ) + eligible_hours = elapsed_hours(overlap_start, overlap_end, day_count_convention) + if period_hours == 0.0: + return 0.0 + return entry.amount_mwh * eligible_hours / period_hours def ptc( @@ -44,9 +77,16 @@ def ptc( Compute Production Tax Credit cashflows from a generation stream. This function converts eligible generation entries into positive credit - cashflows using a per-MWh PTC rate. Eligibility is limited to entries - dated within the first ``years`` calendar years beginning with the earliest - generation entry date in ``generation_stream``. + cashflows using a per-MWh PTC rate. Eligibility uses a half-open interval + covering the first ``years`` calendar years. The interval begins at the + earliest generation period start when period bounds are available, or the + earliest generation entry date otherwise. + + Entries with period bounds are prorated according to the overlap between + their half-open generation period and the eligibility interval. This assumes + generation is uniform under ``day_count_convention`` within an aggregated + period. Entries without period bounds are treated as point events and are + included only when their dates fall within the eligibility interval. The PTC rate may be escalated over time using the same escalation policy conventions used elsewhere in the generation-to-cashflow bridge. A simple @@ -61,9 +101,9 @@ def ptc( rate_per_mwh : float Base Production Tax Credit rate in dollars per MWh. years : int - Number of years of PTC eligibility, measured from the earliest entry - date in ``generation_stream``. Entries with ``entry.date.year`` greater - than or equal to ``first_entry_year + years`` are excluded. + Number of calendar years of PTC eligibility, measured from the earliest + generation period start or point-entry date. The anniversary ending the + eligibility interval is exclusive. escalation : float, optional Compound escalation rate for the PTC value, interpreted over ``escalation_period``. With the default ``escalation_period="year"``, @@ -82,7 +122,8 @@ def ptc( Date at which ``rate_per_mwh`` is known. If omitted, the earliest generation entry date is used as the escalation reference point. day_count_convention : DayCountConvention, optional - Day-count convention used for annual PTC escalation. + Day-count convention used for annual PTC escalation and for prorating + interval-backed generation entries that cross an eligibility boundary. escalation_policy : EscalationPolicy, optional Advanced override for custom escalation behavior. When provided, it must not be combined with ``escalation``, ``escalation_period``, or @@ -92,8 +133,8 @@ def ptc( ------- CashFlowStream Cashflow stream containing positive PTC credit cashflows for eligible - generation entries only. Returns an empty stream if - ``generation_stream`` is empty. + generation only. Interval-backed entries crossing a boundary contain a + prorated credit. Returns an empty stream if ``generation_stream`` is empty. Raises ------ @@ -143,8 +184,11 @@ def ptc( if not generation_stream.entries: return CashFlowStream() - first_entry_date = min(entry.date for entry in generation_stream.entries) - cutoff_year = first_entry_date.year + years + eligibility_start = min( + entry.period_start if entry.period_start is not None else entry.date + for entry in generation_stream.entries + ) + eligibility_end = eligibility_start + relativedelta(years=years) policy = _generation_escalation( entries=generation_stream.entries, escalation=escalation, @@ -159,12 +203,18 @@ def ptc( entries: list[CashFlow] = [] for entry in generation_stream.entries: - if entry.date.year >= cutoff_year: + eligible_generation = _eligible_ptc_generation( + entry, + eligibility_start=eligibility_start, + eligibility_end=eligibility_end, + day_count_convention=day_count_convention, + ) + if eligible_generation is None: continue ptc_rate = rate_per_mwh * policy.factor(entry.date) entries.append( CashFlow( - amount=entry.amount_mwh * ptc_rate, + amount=eligible_generation * ptc_rate, date=entry.date, label=label, is_cash=True, diff --git a/tests/unit/test_generation.py b/tests/unit/test_generation.py index 91bf837..c897162 100644 --- a/tests/unit/test_generation.py +++ b/tests/unit/test_generation.py @@ -24,6 +24,27 @@ def test_generation_defaults(): g = Generation(amount_mwh=100.0, date=date(2030, 1, 1)) assert g.amount_mwh == 100.0 assert g.label == "" + assert g.period_start is None + assert g.period_end is None + + +@pytest.mark.parametrize( + ("period_start", "period_end"), + [ + (date(2030, 1, 1), None), + (None, date(2030, 1, 2)), + (date(2030, 1, 1), date(2030, 1, 1)), + (date(2030, 1, 2), date(2030, 1, 1)), + ], +) +def test_generation_rejects_invalid_period_bounds(period_start, period_end): + with pytest.raises(ValueError, match="period"): + Generation( + amount_mwh=100.0, + date=date(2030, 1, 1), + period_start=period_start, + period_end=period_end, + ) def test_generation_immutable(): @@ -35,7 +56,13 @@ def test_generation_immutable(): def test_generation_replace(): """replace method replaces the specified parameters.""" - old_g = Generation(amount_mwh=100.0, date=date(2026, 1, 1), label="old_gen") + old_g = Generation( + amount_mwh=100.0, + date=date(2026, 1, 1), + label="old_gen", + period_start=date(2026, 1, 1), + period_end=date(2026, 1, 2), + ) new_g = old_g.replace(amount_mwh=150.0, label="new_gen") # Check that replacements were made @@ -44,6 +71,8 @@ def test_generation_replace(): # Check that other parameters are untouched assert new_g.date == date(2026, 1, 1) + assert new_g.period_start == old_g.period_start + assert new_g.period_end == old_g.period_end # Check that original stream is unmodified assert old_g.amount_mwh == 100.0 @@ -64,6 +93,8 @@ def test_from_capacity_annual(): expected_mwh = 100 * 0.92 * 8760 assert abs(gs.entries[0].amount_mwh - expected_mwh) < 1e-6 assert gs.entries[0].date == date(2030, 12, 31) + assert gs.entries[0].period_start == date(2030, 1, 1) + assert gs.entries[0].period_end == date(2031, 1, 1) assert gs.entries[1].date == date(2031, 12, 31) assert gs.entries[2].date == date(2032, 12, 31) @@ -208,6 +239,8 @@ def test_from_outage_creates_negative_generation(): assert outage.entries[0].amount_mwh == pytest.approx(-(1000.0 * 0.92 * 24.0 * 10.0)) assert outage.entries[0].date == date(2030, 5, 10) assert outage.entries[0].label == "Refueling extension" + assert outage.entries[0].period_start == date(2030, 5, 1) + assert outage.entries[0].period_end == date(2030, 5, 11) def test_from_outage_supports_partial_reduction_and_timing(): diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index fe040b8..aa6723a 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -1000,6 +1000,10 @@ def test_energy_project_prorates_partial_operating_periods_from_generation_dates assert analysis.timeline.operating_years == pytest.approx(expected_operating_years) assert analysis.generation.count() == 2 assert analysis.generation.sum() == pytest.approx(expected_generation) + assert analysis.generation.entries[0].period_start == date(2026, 6, 1) + assert analysis.generation.entries[0].period_end == date(2027, 6, 1) + assert analysis.generation.entries[1].period_start == date(2027, 6, 1) + assert analysis.generation.entries[1].period_end == exclusive_end assert analysis.cashflow_components["fixed_opex"].sum() == pytest.approx(expected_opex) diff --git a/tests/unit/test_tax_incentives.py b/tests/unit/test_tax_incentives.py index ceb43d3..a6dfde7 100644 --- a/tests/unit/test_tax_incentives.py +++ b/tests/unit/test_tax_incentives.py @@ -2,9 +2,10 @@ # ALL RIGHTS RESERVED """Tests for tax incentive functions.""" -from datetime import date +from datetime import date, timedelta import pytest +from dateutil.relativedelta import relativedelta from dcaf.streams.cashflows import CashFlow, CashFlowStream from dcaf.tax.depreciation import macrs_schedule @@ -136,6 +137,144 @@ def test_basic_ptc(self): assert result.entries[0].pro_forma_category is ProFormaCategory.TAX_CREDIT assert result.entries[0].tax_treatment is TaxTreatment.NONE + def test_ptc_point_entries_include_partial_final_calendar_year(self): + """An exact ten-year window includes January through June of the final year.""" + start = date(2030, 7, 1) + generation = GenerationStream( + [Generation(100.0, start + relativedelta(months=index)) for index in range(126)] + ) + + result = ptc(generation, rate_per_mwh=2.0, years=10) + + assert result.count() == 120 + assert result.entries[-1].date == date(2040, 6, 1) + assert sum(flow.amount for flow in result if flow.date.year == 2030) == pytest.approx( + 1_200.0 + ) + assert sum(flow.amount for flow in result if flow.date.year == 2040) == pytest.approx( + 1_200.0 + ) + assert result.sum() == pytest.approx(24_000.0) + + def test_ptc_prorates_annual_generation_and_reconciles_known_total(self): + """One MWh per day over ten years receives exactly ten years of PTC.""" + eligibility_start = date(2030, 7, 1) + eligibility_end = date(2040, 7, 1) + periods = [ + (eligibility_start, date(2031, 1, 1)), + *[(date(year, 1, 1), date(year + 1, 1, 1)) for year in range(2031, 2041)], + ] + generation = GenerationStream( + [ + Generation( + amount_mwh=float((period_end - period_start).days), + date=period_end - timedelta(days=1), + period_start=period_start, + period_end=period_end, + ) + for period_start, period_end in periods + ] + ) + + result = ptc(generation, rate_per_mwh=2.0, years=10) + + known_eligible_mwh = float((eligibility_end - eligibility_start).days) + assert result.count() == 11 + assert result.entries[0].amount == pytest.approx(184.0 * 2.0) + assert result.entries[-1].date == date(2040, 12, 31) + assert result.entries[-1].amount == pytest.approx(182.0 * 2.0) + assert result.sum() == pytest.approx(known_eligible_mwh * 2.0) + + @pytest.mark.parametrize( + ("convention", "final_period_mwh", "expected_final_mwh", "expected_total_mwh"), + [ + ("actual/actual", 366.0, 182.0, 366.0), + ("actual/365-no-leap", 365.0, 181.0, 365.0), + ], + ) + def test_ptc_prorates_annual_generation_using_day_count_convention( + self, + convention, + final_period_mwh, + expected_final_mwh, + expected_total_mwh, + ): + generation = GenerationStream( + [ + Generation( + 184.0, + date(2039, 12, 31), + period_start=date(2039, 7, 1), + period_end=date(2040, 1, 1), + ), + Generation( + final_period_mwh, + date(2040, 12, 31), + period_start=date(2040, 1, 1), + period_end=date(2041, 1, 1), + ), + ] + ) + + result = ptc( + generation, + rate_per_mwh=1.0, + years=1, + day_count_convention=convention, + ) + + assert result.entries[-1].amount == pytest.approx(expected_final_mwh) + assert result.sum() == pytest.approx(expected_total_mwh) + + def test_ptc_interval_eligibility_is_independent_of_booking_timing(self): + periods = [ + (date(2039, 7, 1), date(2040, 1, 1)), + (date(2040, 1, 1), date(2041, 1, 1)), + ] + begin = GenerationStream( + [ + Generation( + float((period_end - period_start).days), + period_start, + period_start=period_start, + period_end=period_end, + ) + for period_start, period_end in periods + ] + ) + end = GenerationStream( + [ + Generation( + float((period_end - period_start).days), + period_end - timedelta(days=1), + period_start=period_start, + period_end=period_end, + ) + for period_start, period_end in periods + ] + ) + + begin_credits = ptc(begin, rate_per_mwh=1.0, years=1) + end_credits = ptc(end, rate_per_mwh=1.0, years=1) + + assert [flow.amount for flow in begin_credits] == pytest.approx( + [flow.amount for flow in end_credits] + ) + assert begin_credits.sum() == pytest.approx(366.0) + + def test_ptc_leap_day_anniversary_is_exclusive_february_28(self): + generation = GenerationStream( + [ + Generation(100.0, date(2024, 2, 29)), + Generation(100.0, date(2025, 2, 27)), + Generation(100.0, date(2025, 2, 28)), + ] + ) + + result = ptc(generation, rate_per_mwh=1.0, years=1) + + assert [flow.date for flow in result] == [date(2024, 2, 29), date(2025, 2, 27)] + def test_ptc_escalation(self): """PTC rate escalates.""" generation = GenerationStream(