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
12 changes: 9 additions & 3 deletions dcaf/project/_builder_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,16 +395,22 @@ def __post_init__(self) -> None:

@dataclass(frozen=True)
class RevenueConfig:
"""Whole-project generation revenue configuration."""
"""Whole-project revenue configuration with scalar or explicit policy pricing."""

price: GenerationPrice
price: float | GenerationPrice
label: str = "Revenue"
pro_forma_category: ProFormaCategory | str | None = ProFormaCategory.REVENUE
tax_treatment: TaxTreatment | str = TaxTreatment.TAXABLE

def __post_init__(self) -> None:
if not isinstance(self.price, GenerationPrice):
raise TypeError("generation_revenue price must be a GenerationPrice")
if not isinstance(self.price, int | float) or isinstance(self.price, bool):
raise TypeError(
"generation_revenue price must be a finite scalar or GenerationPrice"
)
price = float(self.price)
validate_finite(price, "generation_revenue price")
object.__setattr__(self, "price", price)
category, treatment = normalize_cashflow_classification(
self.pro_forma_category,
self.tax_treatment,
Expand Down
67 changes: 55 additions & 12 deletions dcaf/project/_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ def compile(self) -> ProjectAnalysis:
component_streams.add_named(
"construction_outage",
outage_name,
self.build_construction_outage(outage_config),
self.build_construction_outage(outage_config, generation),
)

itc_stream = self.build_itc(construction_stream)
Expand Down Expand Up @@ -503,21 +503,34 @@ def build_generation_outages(self) -> GenerationStream:
def build_construction_outage(
self,
outage: ConstructionOutageConfig,
generation: GenerationStream,
) -> CashFlowStream:
"""Build operating-cost cashflows for a construction outage on baseline generation."""
if outage.sell_price_per_unit is None:
# TODO: Support schedule and callable prices after defining how an outage's
# aggregate booking event should be priced.
market = self.config.market
if market is None or market.price.mode != "fixed" or market.price.fixed_price is None:
if market is None or (
isinstance(market.price, GenerationPrice)
and (market.price.mode != "fixed" or market.price.fixed_price is None)
):
raise ValueError(
f"construction_outage {outage.name!r} requires sell_price_per_unit "
"unless generation_revenue is configured with a fixed price; "
"unless generation_revenue is configured with price or a fixed "
"price_policy; "
"scheduled and callable generation_revenue prices are not supported "
"for construction outages"
)
price_per_mwh = market.price.fixed_price
escalation = EscalationSettings(explicit=True)
if isinstance(market.price, GenerationPrice):
assert market.price.fixed_price is not None
price_per_mwh = market.price.fixed_price
escalation = EscalationSettings(explicit=True)
else:
price_per_mwh = market.price
escalation = EscalationSettings(
policy=self._generation_revenue_price_escalation(generation),
explicit=True,
)
else:
price_per_mwh = outage.sell_price_per_unit
escalation = self.context.effective_escalation(outage.escalation)
Expand Down Expand Up @@ -555,22 +568,47 @@ def build_revenue(
market = self.config.market
if market is None:
return CashFlowStream()
self._validate_price_schedule_alignment(
name="revenue",
price=market.price,
generation=generation,
)
if isinstance(market.price, GenerationPrice):
self._validate_price_schedule_alignment(
name="revenue",
price=market.price,
generation=generation,
)
if not generation.entries:
return CashFlowStream()
price_escalation: EscalationPolicy | None = None
if isinstance(market.price, float):
price_escalation = self._generation_revenue_price_escalation(generation)
return self._revenue_cashflows_from_generation(
name="revenue",
generation=generation,
price=market.price,
price_escalation=price_escalation,
label=market.label,
pro_forma_category=market.pro_forma_category,
tax_treatment=market.tax_treatment,
)

def _generation_revenue_price_escalation(
self,
generation: GenerationStream,
) -> EscalationPolicy | None:
"""Resolve the shared scalar-price escalation for revenue and outage fallback."""
escalation = self.config.default_escalation
if escalation.policy is not None:
return escalation.policy
reference_date = escalation.amount_reference_date
if reference_date is None:
if not generation.entries:
return None
reference_date = min(entry.date for entry in generation.entries)
return ConstantRateEscalation(
reference_date=reference_date,
rate=escalation.escalation,
period=escalation.escalation_period,
day_count_convention=self.config.day_count_convention,
)

def build_revenue_basis(self, generation: GenerationStream) -> CashFlowStream:
"""Build the unit-price basis for whole-project levelized cost."""
if not generation.entries or self.config.market is None:
Expand Down Expand Up @@ -850,7 +888,8 @@ def _revenue_cashflows_from_generation(
*,
name: str,
generation: GenerationStream,
price: GenerationPrice,
price: float | GenerationPrice,
price_escalation: EscalationPolicy | None,
label: str,
pro_forma_category: ProFormaCategory | str | None,
tax_treatment: TaxTreatment | str,
Expand All @@ -867,9 +906,13 @@ def _revenue_cashflows_from_generation(
requested_mwh=entry.amount_mwh,
delivered_mwh=entry.amount_mwh,
)
escalation_factor = (
1.0 if price_escalation is None else price_escalation.factor(entry.date)
)
resolved_price = price.resolve(event) if isinstance(price, GenerationPrice) else price
entries.append(
CashFlow(
amount=event.delivered_mwh * price.resolve(event),
amount=event.delivered_mwh * resolved_price * escalation_factor,
date=entry.date,
label=label,
is_cash=True,
Expand Down
61 changes: 41 additions & 20 deletions dcaf/project/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,6 @@
)


def _coerce_generation_price(price: GenerationPrice | float, *, field_name: str) -> GenerationPrice:
"""Normalize a scalar fixed price or explicit generation price policy."""
if isinstance(price, GenerationPrice):
return price
if isinstance(price, int | float) and not isinstance(price, bool):
return GenerationPrice.fixed(float(price))
raise TypeError(f"{field_name} must be a GenerationPrice or float")


class EnergyProject:
"""Immutable fluent builder for composing and analyzing energy project cash flows.

Expand Down Expand Up @@ -468,9 +459,11 @@ def construction_outage(
timing : TimingConvention, optional
Booking-date convention for generated cashflows.
sell_price_per_unit : float, optional
Explicit outage price per MWh. When omitted, a fixed price
configured with :meth:`generation_revenue` is used. Scheduled and
callable generation-revenue prices require an explicit outage price.
Explicit outage price per MWh. When omitted, a scalar ``price``
configured with :meth:`generation_revenue` is used with the same
project-default escalation, or a fixed ``price_policy`` is used
without escalation. Scheduled and callable generation-revenue
policies require an explicit outage price.
fixed_cost : float, optional
Additional one-time outage cost. Sign is ignored.
cost_per_day : float, optional
Expand Down Expand Up @@ -521,7 +514,8 @@ def construction_outage(
def generation_revenue(
self,
*,
price: GenerationPrice | float,
price: float | None = None,
price_policy: GenerationPrice | None = None,
label: str | None = None,
pro_forma_category: ProFormaCategory | str | None = ProFormaCategory.REVENUE,
tax_treatment: TaxTreatment | str = TaxTreatment.TAXABLE,
Expand All @@ -530,12 +524,17 @@ def generation_revenue(

Parameters
----------
price : float or GenerationPrice
Per-MWh settlement price for each generation event. A float is
treated as a fixed price via :meth:`GenerationPrice.fixed`; pass
``GenerationPrice`` directly for scheduled or callable prices. A
scheduled price must contain an entry whose date exactly matches
every generation event; schedule entries are not carried forward.
price : float, optional
Base per-MWh settlement price for each generation event. The price
inherits the project-wide rate configured by
:meth:`default_escalation`. When that default has no
``amount_reference_date``, the earliest generation event is the
reference date. Mutually exclusive with ``price_policy``.
price_policy : GenerationPrice, optional
Complete settlement-price policy that does not inherit the project
default escalation. A scheduled policy must contain an entry whose
date exactly matches every generation event; schedule entries are
not carried forward. Mutually exclusive with ``price``.
label : str, optional
Label applied to every generated revenue cashflow. Default is
``"Revenue"``.
Expand All @@ -548,16 +547,38 @@ def generation_revenue(
-------
EnergyProject
New project with updated revenue configuration.

Raises
------
TypeError
If ``price`` is not a scalar or ``price_policy`` is not a
``GenerationPrice``.
ValueError
If neither or both price arguments are provided, ``price`` is not
finite, generation revenue is already configured, or whole-project
revenue is combined with contract or remainder revenue.
"""
if (price is None) == (price_policy is None):
raise ValueError("provide exactly one of price or price_policy")
if price is not None and (not isinstance(price, int | float) or isinstance(price, bool)):
raise TypeError("generation_revenue price must be a finite scalar")
if price_policy is not None and not isinstance(price_policy, GenerationPrice):
raise TypeError("generation_revenue price_policy must be a GenerationPrice")
if self._config.market is not None:
raise ValueError("generation_revenue may only be called once")
if self._has_generation_revenue_policies():
raise ValueError(
"generation_revenue cannot be combined with "
"generation_revenue_contract or generation_revenue_remainder"
)
selected_price: float | GenerationPrice
if price is not None:
selected_price = float(price)
else:
assert price_policy is not None
selected_price = price_policy
updated = RevenueConfig(
price=_coerce_generation_price(price, field_name="generation_revenue price"),
price=selected_price,
label="Revenue" if label is None else label,
pro_forma_category=pro_forma_category,
tax_treatment=tax_treatment,
Expand Down
9 changes: 6 additions & 3 deletions docs/calculations/escalation.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,14 @@ anywhere escalation is accepted.

## Where escalation is applied

- `EnergyProject.default_escalation(rate=...)` sets a project-wide default.
- `EnergyProject.default_escalation(rate=...)` sets a project-wide default. A scalar
`.generation_revenue(price=...)` inherits it, using the earliest generation event as
the reference date when no `amount_reference_date` is configured.
- Per-component cost and credit methods accept an `escalation=` argument, e.g.
`.fixed_opex(amount=, escalation=...)` and `ptc(..., escalation=...)`.
- Simple generation revenue can use a float price; scheduled or callable revenue
prices use `GenerationPrice`.
- An explicit `GenerationPrice` supplied through
`.generation_revenue(price_policy=...)` fully specifies settlement prices and does
not inherit the project default. This includes fixed, scheduled, and callable prices.
- Tax credits escalate too: `ptc(..., escalation=...)`.

`GenerationPrice.schedule(...)` is an exact-date lookup, not a sequence of price
Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/energy_project.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ keyword-only.

| Method | Purpose |
|--------|---------|
| `.generation_revenue(price=...)` | Revenue = generation × price. |
| `.generation_revenue(price=..., price_policy=...)` | Whole-project revenue; provide one scalar price or explicit `GenerationPrice` policy. |
| `.generation_revenue_contract(name=, contract=)` | Contracted generation revenue such as PPAs. |
| `.generation_revenue_remainder(name=, price=)` | Revenue from generation not allocated to contracts. |
| `.fixed_opex(amount=, frequency=, ...)` | Recurring fixed operating cost. |
Expand Down
24 changes: 20 additions & 4 deletions docs/guides/escalation_and_financing.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,21 @@ first-class options on the builder.

## Escalation

A project-wide default escalation is applied to cost items and tax credits relative to a
reference date:
A project-wide default escalation is applied to scalar generation-revenue prices, cost
items, and tax credits relative to a reference date:

```python
project = project.default_escalation(rate=0.025, amount_reference_date=date(2025, 1, 1))
project = (
project
.default_escalation(rate=0.025, amount_reference_date=date(2025, 1, 1))
.generation_revenue(price=45.0)
)
```

Here, `45.0` is the price on January 1, 2025. If the default omits
`amount_reference_date`, the earliest generation event becomes the scalar price's
reference date.

Many per-component methods can override the default with their own `escalation=`
argument — a bare float for a constant rate, or an
{py:class}`~dcaf.finance.EscalationPolicy` for piecewise/index-based growth:
Expand All @@ -28,12 +36,20 @@ revenue_escalation = ConstantRateEscalation(date(2025, 1, 1), rate=0.025)
project = (
project
.generation_revenue(
price=GenerationPrice.callable(lambda event: 45.0 * revenue_escalation.factor(event.date))
price_policy=GenerationPrice.callable(
lambda event: 45.0 * revenue_escalation.factor(event.date)
)
)
.fixed_opex(amount=10_000_000.0, escalation=0.03)
)
```

An explicit `GenerationPrice` passed as `price_policy` is a complete settlement-price
policy and does not inherit `default_escalation`. This keeps
`GenerationPrice.fixed(...)` constant and prevents scheduled or callable prices from
being escalated twice. Use a callable, as above, when revenue needs escalation
different from the project default.

For explicit date-indexed prices, `GenerationPrice.schedule(...)` requires an exact
date match for every generation settlement being priced. Schedule entries are not
forward-filled or treated as effective-until-changed values.
Expand Down
6 changes: 6 additions & 0 deletions docs/guides/outages.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ project = project.construction_outage(
)
```

When `sell_price_per_unit` is omitted, the outage reuses whole-project generation
pricing. A scalar `.generation_revenue(price=...)` inherits the same project-default
escalation for outage lost revenue. A fixed
`.generation_revenue(price_policy=GenerationPrice.fixed(...))` remains un-escalated.
Scheduled and callable price policies require an explicit outage price.

Each outage becomes a component named `construction_outage:<name>`, so you can call
`.construction_outage(...)` multiple times and read each back individually:

Expand Down
Loading