diff --git a/dcaf/project/_builder_config.py b/dcaf/project/_builder_config.py index dc367db..2accd08 100644 --- a/dcaf/project/_builder_config.py +++ b/dcaf/project/_builder_config.py @@ -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, diff --git a/dcaf/project/_compiler.py b/dcaf/project/_compiler.py index 583251b..6f010d9 100644 --- a/dcaf/project/_compiler.py +++ b/dcaf/project/_compiler.py @@ -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) @@ -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) @@ -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: @@ -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, @@ -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, diff --git a/dcaf/project/builder.py b/dcaf/project/builder.py index a80ff5b..2ede775 100644 --- a/dcaf/project/builder.py +++ b/dcaf/project/builder.py @@ -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. @@ -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 @@ -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, @@ -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"``. @@ -548,7 +547,23 @@ 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(): @@ -556,8 +571,14 @@ def generation_revenue( "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, diff --git a/docs/calculations/escalation.md b/docs/calculations/escalation.md index d2eca8f..f80e69c 100644 --- a/docs/calculations/escalation.md +++ b/docs/calculations/escalation.md @@ -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 diff --git a/docs/concepts/energy_project.md b/docs/concepts/energy_project.md index 9be57f1..9f33490 100644 --- a/docs/concepts/energy_project.md +++ b/docs/concepts/energy_project.md @@ -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. | diff --git a/docs/guides/escalation_and_financing.md b/docs/guides/escalation_and_financing.md index cd44bc8..5f40d4e 100644 --- a/docs/guides/escalation_and_financing.md +++ b/docs/guides/escalation_and_financing.md @@ -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: @@ -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. diff --git a/docs/guides/outages.md b/docs/guides/outages.md index 9b49d3e..b92b786 100644 --- a/docs/guides/outages.md +++ b/docs/guides/outages.md @@ -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:`, so you can call `.construction_outage(...)` multiple times and read each back individually: diff --git a/tests/unit/test_generation_linked_policies.py b/tests/unit/test_generation_linked_policies.py index fea4fc9..29dfd3f 100644 --- a/tests/unit/test_generation_linked_policies.py +++ b/tests/unit/test_generation_linked_policies.py @@ -28,6 +28,15 @@ def _annual_generation() -> GenerationStream: ) +def _two_year_generation() -> GenerationStream: + return GenerationStream( + [ + Generation(100.0, date(2026, 1, 1)), + Generation(100.0, date(2027, 1, 1)), + ] + ) + + def _fraction_of_generation_contract( generation_share: float = 0.50, price: float = 45.0, @@ -174,7 +183,10 @@ def test_generation_revenue_replaces_revenue_from_generation_for_whole_project_s ] ) ) - .generation_revenue(price=GenerationPrice.fixed(50.0), label="Generation Revenue") + .generation_revenue( + price_policy=GenerationPrice.fixed(50.0), + label="Generation Revenue", + ) .analyze() ) @@ -188,7 +200,7 @@ def test_generation_revenue_replaces_revenue_from_generation_for_whole_project_s assert not hasattr(EnergyProject(), "revenue_from_generation") -def test_generation_revenue_accepts_float_price_as_fixed_generation_price(): +def test_generation_revenue_float_price_is_unescalated_without_project_default(): analysis = ( EnergyProject() .generation_stream( @@ -208,6 +220,156 @@ def test_generation_revenue_accepts_float_price_as_fixed_generation_price(): assert [flow.amount for flow in revenue] == pytest.approx([5_000.0, 10_000.0]) +@pytest.mark.parametrize(("price", "expected_revenue"), [(0, 0.0), (-50, -5_000.0)]) +def test_generation_revenue_accepts_zero_and_negative_scalar_prices( + price: int, + expected_revenue: float, +): + analysis = ( + EnergyProject() + .generation_stream(stream=GenerationStream([Generation(100.0, date(2026, 1, 1))])) + .generation_revenue(price=price) + .analyze() + ) + + assert analysis.cashflow_components["revenue"].sum() == pytest.approx(expected_revenue) + + +@pytest.mark.parametrize("price", [float("nan"), float("inf"), float("-inf")]) +def test_generation_revenue_rejects_non_finite_scalar_prices(price: float): + with pytest.raises(ValueError, match="generation_revenue price must be finite"): + EnergyProject().generation_revenue(price=price) + + +def test_generation_revenue_rejects_bool_scalar_price(): + with pytest.raises(TypeError, match="generation_revenue price must be a finite scalar"): + EnergyProject().generation_revenue(price=True) # type: ignore[arg-type] + + +def test_generation_revenue_float_price_uses_project_default_escalation(): + analysis = ( + EnergyProject() + .default_escalation(rate=0.10) + .generation_stream(stream=_two_year_generation()) + .generation_revenue(price=50.0) + .analyze() + ) + + revenue = analysis.cashflow_components["revenue"] + + assert [flow.amount for flow in revenue] == pytest.approx([5_000.0, 5_500.0]) + + +def test_generation_revenue_float_price_uses_earliest_date_in_unsorted_generation(): + analysis = ( + EnergyProject() + .default_escalation(rate=0.10) + .generation_stream( + stream=GenerationStream( + [ + Generation(100.0, date(2027, 1, 1)), + Generation(100.0, date(2026, 1, 1)), + ] + ) + ) + .generation_revenue(price=50.0) + .analyze() + ) + + revenue = analysis.cashflow_components["revenue"] + + assert [flow.amount for flow in revenue] == pytest.approx([5_500.0, 5_000.0]) + + +def test_generation_revenue_float_price_uses_default_escalation_reference_date(): + analysis = ( + EnergyProject() + .default_escalation(rate=0.10, amount_reference_date=date(2025, 1, 1)) + .generation_stream(stream=_two_year_generation()) + .generation_revenue(price=50.0) + .analyze() + ) + + revenue = analysis.cashflow_components["revenue"] + + assert [flow.amount for flow in revenue] == pytest.approx([5_500.0, 6_050.0]) + + +@pytest.mark.parametrize( + "price", + [ + GenerationPrice.fixed(50.0), + GenerationPrice.schedule( + { + date(2026, 1, 1): 50.0, + date(2027, 1, 1): 50.0, + } + ), + GenerationPrice.callable(lambda _event: 50.0), + ], + ids=["fixed", "schedule", "callable"], +) +def test_explicit_generation_price_ignores_project_default_escalation( + price: GenerationPrice, +): + analysis = ( + EnergyProject() + .default_escalation(rate=0.10) + .generation_stream(stream=_two_year_generation()) + .generation_revenue(price_policy=price) + .analyze() + ) + + revenue = analysis.cashflow_components["revenue"] + + assert [flow.amount for flow in revenue] == pytest.approx([5_000.0, 5_000.0]) + + +def test_generation_revenue_rejects_generation_price_in_scalar_price_argument(): + invalid_price = cast(float, GenerationPrice.fixed(50.0)) + + with pytest.raises(TypeError, match="generation_revenue price must be a finite scalar"): + EnergyProject().generation_revenue(price=invalid_price) + + +def test_generation_revenue_requires_exactly_one_price_source(): + with pytest.raises(ValueError, match="provide exactly one of price or price_policy"): + EnergyProject().generation_revenue() + + with pytest.raises(ValueError, match="provide exactly one of price or price_policy"): + EnergyProject().generation_revenue( + price=50.0, + price_policy=GenerationPrice.fixed(50.0), + ) + + +def test_generation_contract_and_remainder_prices_ignore_project_default_escalation(): + analysis = ( + EnergyProject() + .default_escalation(rate=0.10) + .generation_stream(stream=_two_year_generation()) + .generation_revenue_contract( + name="revenue:ppa", + contract=EnergyContract.fraction_of_generation( + generation_share=0.50, + price=GenerationPrice.fixed(50.0), + ), + ) + .generation_revenue_remainder( + name="revenue:merchant", + price=GenerationPrice.fixed(40.0), + ) + .analyze() + ) + + assert [flow.amount for flow in analysis.cashflow_components["revenue:ppa"]] == ( + pytest.approx([2_500.0, 2_500.0]) + ) + assert [flow.amount for flow in analysis.cashflow_components["revenue:merchant"]] == ( + pytest.approx([2_000.0, 2_000.0]) + ) + + def test_generation_revenue_schedule_settles_each_event_at_its_exact_date_price(): """Exact-date prices settle 100 MWh at $45 and 200 MWh at $47.""" analysis = ( @@ -221,7 +383,7 @@ def test_generation_revenue_schedule_settles_each_event_at_its_exact_date_price( ) ) .generation_revenue( - price=GenerationPrice.schedule( + price_policy=GenerationPrice.schedule( { date(2026, 1, 1): 45.0, date(2027, 1, 1): 47.0, @@ -270,7 +432,7 @@ def test_generation_revenue_schedule_requires_an_exact_one_to_one_date_domain( ( EnergyProject() .generation_stream(stream=generation) - .generation_revenue(price=GenerationPrice.schedule(price_schedule)) + .generation_revenue(price_policy=GenerationPrice.schedule(price_schedule)) .analyze() ) @@ -296,7 +458,7 @@ def price(event: GenerationSettlementEvent) -> float: ] ) ) - .generation_revenue(price=GenerationPrice.callable(price)) + .generation_revenue(price_policy=GenerationPrice.callable(price)) .analyze() ) @@ -324,13 +486,13 @@ def test_generation_revenue_may_only_be_called_once(): with pytest.raises(ValueError, match="generation_revenue may only be called once"): ( EnergyProject() - .generation_revenue(price=GenerationPrice.fixed(50.0)) - .generation_revenue(price=GenerationPrice.fixed(60.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(60.0)) ) def test_generation_revenue_is_mutually_exclusive_with_contract_revenue_methods(): - whole_project = EnergyProject().generation_revenue(price=GenerationPrice.fixed(50.0)) + whole_project = EnergyProject().generation_revenue(price_policy=GenerationPrice.fixed(50.0)) with pytest.raises( ValueError, @@ -363,7 +525,7 @@ def test_generation_revenue_is_mutually_exclusive_with_contract_revenue_methods( name="revenue:ppa", contract=_fraction_of_generation_contract(), ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) ) @@ -439,14 +601,14 @@ def test_only_one_generation_revenue_remainder_is_allowed(): ) -def test_generation_revenue_prices_must_be_generation_prices_or_floats(): +def test_generation_revenue_policy_and_remainder_prices_must_be_generation_prices(): bad_price = cast(GenerationPrice, object()) with pytest.raises( TypeError, - match="generation_revenue price must be a GenerationPrice or float", + match="generation_revenue price_policy must be a GenerationPrice", ): - EnergyProject().generation_revenue(price=bad_price) + EnergyProject().generation_revenue(price_policy=bad_price) with pytest.raises( TypeError, @@ -463,7 +625,7 @@ def test_generation_revenue_methods_require_generation_at_analysis_time(): ValueError, match="generation revenue requires generation to be configured", ): - EnergyProject().generation_revenue(price=GenerationPrice.fixed(50.0)).analyze() + EnergyProject().generation_revenue(price_policy=GenerationPrice.fixed(50.0)).analyze() with pytest.raises( ValueError, diff --git a/tests/unit/test_outage.py b/tests/unit/test_outage.py index a74da75..73ec69a 100644 --- a/tests/unit/test_outage.py +++ b/tests/unit/test_outage.py @@ -11,7 +11,7 @@ from dcaf.finance.outage import construction_outage, generator_outage from dcaf.shared.time import timedelta_fractional_years from dcaf.shared.types import ProFormaCategory, TaxTreatment -from dcaf.streams import GenerationStream +from dcaf.streams import Generation, GenerationStream def test_generator_outage_matches_classmethod(): @@ -221,7 +221,7 @@ def test_construction_outage_equivalence_with_builder_method(): operations_start=date(2026, 1, 1), operations_end=date(2027, 1, 1), ) - .generation_revenue(price=GenerationPrice.fixed(sell_price)) + .generation_revenue(price_policy=GenerationPrice.fixed(sell_price)) .construction_outage( capacity_mw=1000.0, capacity_factor=0.92, @@ -253,16 +253,17 @@ def test_construction_outage_equivalence_with_builder_method(): def test_construction_outage_market_price_lookup_via_builder(): - """The builder still falls back to the configured market price when none is given.""" + """An explicit fixed market policy remains fixed when used for outage fallback.""" project = ( EnergyProject() + .default_escalation(rate=0.10, amount_reference_date=date(2024, 5, 11)) .generation( capacity_mw=10.0, capacity_factor=1.0, operations_start=date(2026, 1, 1), operations_end=date(2027, 1, 1), ) - .generation_revenue(price=GenerationPrice.fixed(42.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(42.0)) .construction_outage( start=date(2025, 5, 1), end=date(2025, 5, 11), @@ -276,6 +277,78 @@ def test_construction_outage_market_price_lookup_via_builder(): assert impact.sum() == pytest.approx(expected) +def test_construction_outage_scalar_market_price_inherits_default_escalation(): + project = ( + EnergyProject() + .default_escalation(rate=0.10, amount_reference_date=date(2025, 1, 1)) + .generation( + capacity_mw=10.0, + capacity_factor=1.0, + operations_start=date(2026, 1, 1), + operations_end=date(2027, 1, 1), + ) + .generation_revenue(price=50.0) + .construction_outage( + start=date(2026, 1, 1), + end=date(2026, 1, 2), + capacity_mw=1.0, + capacity_factor=1.0, + timing="begin", + ) + ) + + impact = project.analyze().cashflow_components["construction_outage"] + + assert impact.sum() == pytest.approx(-(24.0 * 50.0 * 1.10)) + + +def test_construction_outage_scalar_market_price_uses_earliest_generation_reference(): + project = ( + EnergyProject() + .default_escalation(rate=0.10) + .generation_stream( + stream=GenerationStream( + [ + Generation(100.0, date(2026, 1, 1)), + Generation(100.0, date(2027, 1, 1)), + ] + ) + ) + .generation_revenue(price=50.0) + .construction_outage( + start=date(2027, 1, 1), + end=date(2027, 1, 2), + capacity_mw=1.0, + capacity_factor=1.0, + timing="begin", + ) + ) + + impact = project.analyze().cashflow_components["construction_outage"] + + assert impact.sum() == pytest.approx(-(24.0 * 50.0 * 1.10)) + + +def test_construction_outage_scalar_market_price_deflates_before_generation_reference(): + project = ( + EnergyProject() + .default_escalation(rate=0.10) + .generation_stream(stream=GenerationStream([Generation(100.0, date(2026, 1, 1))])) + .generation_revenue(price=50.0) + .construction_outage( + start=date(2025, 1, 1), + end=date(2025, 1, 2), + capacity_mw=1.0, + capacity_factor=1.0, + timing="begin", + ) + ) + + impact = project.analyze().cashflow_components["construction_outage"] + + assert impact.sum() == pytest.approx(-(24.0 * 50.0 / 1.10)) + + @pytest.mark.parametrize( "market_price", [ @@ -294,7 +367,7 @@ def test_construction_outage_requires_explicit_price_for_dynamic_generation_reve operations_start=date(2026, 1, 1), operations_end=date(2027, 1, 1), ) - .generation_revenue(price=market_price) + .generation_revenue(price_policy=market_price) .construction_outage( start=date(2025, 5, 1), end=date(2025, 5, 11), @@ -306,8 +379,8 @@ def test_construction_outage_requires_explicit_price_for_dynamic_generation_reve with pytest.raises( ValueError, match=( - "requires sell_price_per_unit unless generation_revenue is configured with a fixed " - "price" + "requires sell_price_per_unit unless generation_revenue is configured with price " + "or a fixed price_policy" ), ): project.analyze() diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index aa6723a..d91a74e 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -85,7 +85,7 @@ def test_energy_project_single_asset_workflow_builds_analysis_and_metrics(): ) .fixed_opex(amount=100.0, frequency="year") .variable_cost(rate_per_unit=10.0) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) .depreciation_macrs(property_class=5) .investment_tax_credit(rate=0.10) .tax(rate=0.21) @@ -148,7 +148,7 @@ def test_component_key_collision_rejects_policy_and_generation_revenue(): project = ( EnergyProject() .generation_stream(stream=GenerationStream([Generation(100.0, date(2026, 1, 1))])) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) .generation_linked_policy(name="revenue", policy=_EmptyGenerationLinkedPolicy()) ) @@ -326,7 +326,7 @@ def test_energy_project_is_order_independent_across_sections(): operations_start=date(2026, 1, 1), operations_end=date(2027, 1, 1), ) - .generation_revenue(price=GenerationPrice.fixed(60.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(60.0)) .fixed_opex(amount=50.0, frequency="year") .construction( overnight_cost=200.0, @@ -347,7 +347,7 @@ def test_energy_project_is_order_independent_across_sections(): period="year", ) .fixed_opex(amount=50.0, frequency="year") - .generation_revenue(price=GenerationPrice.fixed(60.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(60.0)) .generation( capacity_mw=25.0, capacity_factor=0.8, @@ -384,7 +384,7 @@ def test_energy_project_metrics_treats_irr_overflow_as_non_convergence(): ) .fixed_opex(amount=100.0) .variable_cost(rate_per_unit=10.0) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) ) analysis = project.analyze() @@ -752,7 +752,7 @@ def test_energy_project_generation_outage_reduces_modeled_generation_economics() end=date(2026, 5, 11), label="Refueling extension", ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) .variable_cost(rate_per_unit=5.0) .production_tax_credit(rate_per_unit=1.0, years=1) ) @@ -821,7 +821,7 @@ def test_energy_project_ptc_is_tax_credit_not_taxable_income(): operations_start=date(2026, 1, 1), operations_end=date(2027, 1, 1), ) - .generation_revenue(price=GenerationPrice.fixed(10.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(10.0)) .production_tax_credit(rate_per_unit=2.0, years=1) .tax(rate=0.21) ) @@ -845,7 +845,7 @@ def test_energy_project_construction_outage_preserves_generation(): operations_start=date(2026, 1, 1), operations_end=date(2027, 1, 1), ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) .construction_outage( start=date(2025, 5, 1), end=date(2025, 5, 11), @@ -901,7 +901,7 @@ def test_energy_project_construction_outage_explicit_price_and_lcoe(): operations_start=date(2026, 1, 1), operations_end=date(2027, 1, 1), ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) ) outage_project = base_project.construction_outage( start=date(2025, 5, 1), @@ -949,7 +949,7 @@ def test_energy_project_construction_outage_models_two_construction_outages(): operations_start=date(2032, 1, 1), operations_end=date(2033, 1, 1), ) - .generation_revenue(price=GenerationPrice.fixed(45.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(45.0)) .construction_outage( name="refueling_1", start=date(2028, 4, 1), @@ -1045,7 +1045,7 @@ def test_project_pro_forma_can_write_csv(tmp_path): operations_start=date(2026, 1, 1), operations_end=date(2027, 1, 1), ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) ) pro_forma = project.analyze().pro_forma(period="year") @@ -1599,7 +1599,7 @@ def test_energy_project_discount_rate_sets_default_metrics_rate(): operations_start=date(2026, 1, 1), operations_end=date(2027, 1, 1), ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) ) analysis = project.analyze() @@ -1637,7 +1637,7 @@ def test_energy_project_metrics_override_builder_valuation_rate(): construction_start=date(2025, 1, 1), period="year", ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) ) analysis = project.analyze() @@ -1658,7 +1658,7 @@ def test_energy_project_metrics_override_rejects_invalid_negative_discount_rate( operations_start=date(2026, 1, 1), operations_end=date(2027, 1, 1), ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) .analyze() ) @@ -1676,7 +1676,7 @@ def test_energy_project_metrics_override_allows_negative_discount_rate_above_min operations_start=date(2026, 1, 1), operations_end=date(2027, 1, 1), ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) .analyze() ) @@ -1704,7 +1704,7 @@ def test_energy_project_tax_allows_zero_rate(): operations_start=date(2026, 1, 1), operations_end=date(2027, 1, 1), ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) ) analysis = project.analyze() diff --git a/tests/unit/test_timing_convention.py b/tests/unit/test_timing_convention.py index 638e6f1..36f0112 100644 --- a/tests/unit/test_timing_convention.py +++ b/tests/unit/test_timing_convention.py @@ -138,7 +138,7 @@ def test_annual_capex_end_timing(self): construction_start=date(2025, 2, 1), period="year", ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) ) analysis = project.analyze() capex_stream = analysis.cashflow_components["construction"] @@ -174,7 +174,7 @@ def project(self): construction_start=date(2029, 1, 1), period="year", ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) .fixed_opex(amount=1_000_000, frequency="year") ) @@ -230,7 +230,7 @@ def test_generation_dates_begin_timing(self): construction_start=date(2024, 1, 1), period="year", ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) ) analysis = project.analyze() gen_dates = [g.date for g in analysis.generation.entries] @@ -259,7 +259,7 @@ def test_fixed_opex_dates_begin_timing(self): construction_start=date(2024, 1, 1), period="year", ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) .fixed_opex(amount=500_000, frequency="year") ) analysis = project.analyze() @@ -299,7 +299,7 @@ def test_generation_dates_middle_timing(self): construction_start=date(2024, 1, 1), period="year", ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) ) analysis = project.analyze() gen_dates = [g.date for g in analysis.generation.entries] @@ -323,7 +323,7 @@ def test_middle_timing_partial_first_and_last_period(self): construction_start=date(2024, 1, 1), period="year", ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) ) analysis = project.analyze() gen_dates = [g.date for g in analysis.generation.entries] @@ -359,7 +359,7 @@ def test_generation_begin_with_project_end(self): construction_start=date(2024, 1, 1), period="year", ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) .fixed_opex(amount=100_000, frequency="year") ) analysis = project.analyze() @@ -405,7 +405,7 @@ def test_macrs_dates_remapped_to_year_end(self): construction_start=date(2024, 1, 1), period="year", ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) .depreciation_macrs(property_class=5) ) analysis = project.analyze() @@ -434,7 +434,7 @@ def test_macrs_dates_after_operations_end_are_truncated_with_warning(self): construction_start=date(2025, 1, 1), period="year", ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) .depreciation_macrs(property_class=5) ) @@ -475,7 +475,7 @@ def test_construction_debt_amortization_dates_remapped(self): construction_start=date(2024, 1, 1), period="year", ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) .construction_financing( debt_fraction=0.5, amortization_rate=0.05, @@ -507,7 +507,7 @@ def test_construction_debt_after_operations_end_is_truncated_with_warning(self): construction_start=date(2025, 1, 1), period="year", ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) .construction_financing( debt_fraction=1.0, amortization_rate=0.05, @@ -629,7 +629,7 @@ def test_explicit_periods_use_calendar_end_uncapped(self): construction_start=date(2024, 1, 1), period="year", ) - .generation_revenue(price=GenerationPrice.fixed(50.0)) + .generation_revenue(price_policy=GenerationPrice.fixed(50.0)) ) analysis = project.analyze() gen_dates = [g.date for g in analysis.generation.entries]