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
2 changes: 2 additions & 0 deletions dcaf/project/_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
56 changes: 54 additions & 2 deletions dcaf/streams/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
-------
Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
)
]
)
Expand Down
78 changes: 64 additions & 14 deletions dcaf/tax/incentives.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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"``,
Expand All @@ -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
Expand All @@ -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
------
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
35 changes: 34 additions & 1 deletion tests/unit/test_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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():
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
Loading