Skip to content

feat: split capital_cost into investment and FOM costs#1507

Merged
lkstrp merged 40 commits intomasterfrom
feature/capital-cost-split
Jan 28, 2026
Merged

feat: split capital_cost into investment and FOM costs#1507
lkstrp merged 40 commits intomasterfrom
feature/capital-cost-split

Conversation

@FabianHofmann
Copy link
Copy Markdown
Contributor

@FabianHofmann FabianHofmann commented Jan 3, 2026

Closes #371.

Changes proposed in this Pull Request

Add support for separating capital_cost into overnight investment costs and fixed O&M:

EDIT (updated summary)

Add support for separating overnight investment costs from fixed O&M costs:

  • New component attributes:
    • overnight_cost - Overnight (upfront) investment cost per MW (default NaN)
    • discount_rate - Discount rate for annuity calculation (default NaN)
    • fom_cost - Fixed operation and maintenance costs per year (default 0)
  • New pypsa.costs module with:
    • annuity(discount_rate, lifetime) - Calculate annuity factor
    • annuity_factor(discount_rate, lifetime) - Alias for annuity
    • periodized_cost(overnight_cost, discount_rate, lifetime, fom_cost, nyears) - Full periodized cost calculation
  • New statistics methods:
    • n.statistics.overnight_cost() - Report overnight investment costs
    • n.statistics.fom() - Report fixed O&M costs
  • Backward compatible: When overnight_cost is not set (NaN, default), capital_cost is used directly as annualized cost, preserving existing behavior.

Checklist

  • Code changes are sufficiently documented; i.e. new functions contain docstrings and further explanations may be given in docs.
  • Unit tests for new features were added (if applicable).
  • A note for the release notes docs/release-notes.md of the upcoming release is included.
  • I consent to the release of this PR's code under the MIT license.

@lindnemi
Copy link
Copy Markdown
Contributor

lindnemi commented Jan 5, 2026

Very elegant solution given the current capital_cost attribute, and covers what i had in mind. However...

However, I would prefer instead to have investment costs and capital cost side by side and only allow one of them to be defined by the user.

I totally agree.

Copy link
Copy Markdown
Member

@fneum fneum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @FabianHofmann for tackling this. It's good that the issue of separating FOM from capital_cost and a way to compute both periodized and overnight investment cost is moving forward!

First, I'll give the reasons why I think this solution unfortunately does not work (sorry!), then I'll think of a solution in a separate comment.

  1. A discount rate of zero is a valid input for annualising investment costs and should therefore not be used as a switch. (Example: overnight cost=100, discount rate=0, lifetime=20 give periodized cost of 100/20=5). That's the strongest reason.

  2. I don't think overloading capital_cost is good style. The meaning of values in capital_cost thereby depend on another attribute and are not directly clear. If you want to do anything with the capital_cost attribute, you will always have to make a case distinction, which is inconvenient. That's a good reason, but less strong than 1.

I would prefer a solution where capital_cost still denotes the annuitized investment costs, and build a solution around that. I like how the FOM are separated from capital_cost and the additional discount_rate attribute.

@fneum
Copy link
Copy Markdown
Member

fneum commented Jan 5, 2026

The way I see it, this PR tries to achieve three things:

  1. A way to disentangle FOM from capital_cost attribute.
  2. A way to access both annualized and overnight investment costs.
  3. More convenience for calculating annualized investment costs (common task) from known
    • overnight cost,
    • lifetime,
    • discount rate, and
    • number of years in snapshots.

(1.) and (2.) are relatively straightforward to implement with backwards compatibility and minimal invasiveness to the current use of the capital_cost attribute; (3.) is a challenge but also an add-on and could be left for later.

A solution for (1.) and (2.) could look like this (recycled and adapted from #371 (comment)):

  1. Split capital_cost into fom_cost and capital_cost. The new optional input attribute fom_cost would default to 0. In the objective, both attributes are simply summed up and associated with the investment variable. This is compatible with old networks where FOM is included in capital_cost.
  2. Add a new optional input attribute discount_rate (defaults to 0). Together with the existing optional input attribute lifetime, the annuity factor can be calculated with pypsa.common.annuity.
  3. Add a new output attribute overnight_cost that is a dependent attribute (similar to x_pu_eff) and a function of capital_cost, lifetime, and discount_rate (excluding fom_cost. Use annuity_factor() function to calculate it in n.calculate_dependent_values(). The status quo remains that the annualized investment costs are entered by the user, the overnight cost are derived.
  4. Add a new statistics function for calculating overnight costs (upfront investments) based on overnight_cost, called n.statistics.overnight_cost().
  5. The statistics function n.statistics.total_cost() must be amended by fom_cost since they are no longer included in n.statistics.capex().

Overall, I think this offers a good compromise between backward compatibility, invasiveness to the current use of capital_cost, and the ability to distinguish investment costs (as annualized and overnight value) and FOM.

The suggestion does not offer more convenience for calculating annualized capital_costs (3.), but that's a fairly simple calculation with the utility function pypsa.common.annuity at hand. The dilemma here is the clear distinction of inputs and outputs in PyPSA by design. Ideally, we'd want the user to specify either annualized or overnight costs, and the respective other attribute is filled based on derived values. But that option requires handling if both attributes are filled with values. Maybe a simple consistency check and routine in calculating dependent values will already do the trick?

@lindnemi
Copy link
Copy Markdown
Contributor

lindnemi commented Jan 5, 2026

The dilemma here is the clear distinction of inputs and outputs in PyPSA by design. Ideally, we'd want the user to specify either annualized or overnight costs, and the respective other attribute is filled based on derived values.

Isn't that very simple to achieve with a wrapper, that throws an error when both are there?

Basically n.add would start by checking its arguments for consistency, and derive either capital_cost or overnight_cost, and explain what's wrong otherwise. Or maybe I am missing something?

@fneum
Copy link
Copy Markdown
Member

fneum commented Jan 5, 2026

Basically n.add would start by checking its arguments for consistency, and derive either capital_cost or overnight_cost, and explain what's wrong otherwise. Or maybe I am missing something?

Doesn't sound too bad, actually. Just needs to be extended to all other cases of IO and network building / manipulation with a strong consistency check.

@lkstrp
Copy link
Copy Markdown
Member

lkstrp commented Jan 5, 2026

Basically n.add would start by checking its arguments for consistency, and derive either capital_cost or overnight_cost, and explain what's wrong otherwise. Or maybe I am missing something?

That just starts mixing input and output variables, which we don't currently do by design. But I am not opposed to the idea in general. There is just more to it. In n.add, this can also be easily checked with good user feedback. But n.add is not our only interface, we also need to check any dataframe manipulation and IO. And then the UX get's more tricky.

Doesn't sound too bad, actually. Just needs to be extended to all other cases of IO and network building / manipulation with a strong consistency check.

We can't extend the same check, since in n.add I only pass one of both. Via IO or in the dataframe, I have both values calculated already, without knowing which one was passed. The consistency check would basically need to recalculate all values to check if they still match, and not just check "are both given?". And if they don't match you say "add one of them again, to overwrite the other"? If you changed discount_rate/ life_time, do you take capital or overnight cost to calculate the other? Also n.add would only solve this with overwrite=True, which is not default. It is also currently not deriving any attributes. Compute wise that would probably work, but the additional compute as well as UX isn't very clean.

I am mainly wondering how many other use cases we would have, where an attribute could be both output and input. If there are not many, I would rather keep the existing input vs output structure, but assign them dynamically which we could do here with a hidden helper attribute, which stores the info which one was the input. And keep to (or start to properly) enforce only input can be changed, which would be best for any interface (n.add, DataFrame, Excel, App etc.). But as @FabianHofmann said, it's not much fun to implement without proper validation. I should probably just start resurrecting #1128

@fneum
Copy link
Copy Markdown
Member

fneum commented Jan 5, 2026

That just starts mixing input and output variables, which we don't currently do by design.

Yes, ideally that does not change.

The consistency check would basically need to recalculate all values to check if they still match, and not just check "are both given?"

Yes, that's what I thought. Not sure if that's elegant. Probably not.

And if they don't match you say "add one of them again, to overwrite the other"?

Hmm, difficult. The user has to fix it by aligning either capital_cost or overnight_cost with the rest? Not great.

@lindnemi
Copy link
Copy Markdown
Contributor

lindnemi commented Jan 6, 2026

Thanks Lukas for elaborating a bit on the complexity of the I/O. Regarding the consistency_check:

And if they don't match you say "add one of them again, to overwrite the other"?

Hmm, difficult. The user has to fix it by aligning either capital_cost or overnight_cost with the rest? Not great.

I would still suggest to do exactly that and I don't think it's too bad. If the user specifies both values for some odd reason, that's most likely due to a mistake in their model building pipeline. So in the best case throwing a hard error in the consistency check would only improve their model by forcing the elimination of ambiguities.

@FabianHofmann
Copy link
Copy Markdown
Contributor Author

FabianHofmann commented Jan 6, 2026

thanks all for the comments. I am wondering if there is good way to make the stretch between @lindnemi requirements for convenience which are valid and touch quite some use cases and @fneum's request to keep a clear data model and avoid ambiguity which I also strongly second, why I came up with the overloading approach.

Proposal 1:

A boolean attribute capital_as_overnight_cost (default false) decides the kind of cost input and how to process. (It's essentially what is now hidden in the discount rate which is bad API I have to admit).

Like this we:

  • allow user to set all costs with
    • overnight cost, lifetime, discount rate → new use case
    • capital cost (old use case)
  • don't change inputs on the fly and set ambiguous attributes potentially seen as inputs
  • can derive all important cost parameters internally and expose them via component attributes:
    • n.c.Generator.overnight_cost
    • n.c.Generator.capital_cost
    • n.c.Generator.total_cost
    • n.c.Generator.annuity
  • new component attributes feed into statistics functions

Note: FOM is independent and simply adds to period capital costs in optimization.

# Old behavior (default)
n.add("Generator", "gas", capital_cost=105.8)

# New behavior with overnight costs
n.add("Generator", "wind",
    capital_cost=1000,  # interpreted as overnight
    capital_as_overnight_cost=True,
    discount_rate=0.07,
    lifetime=25,
    fom_cost=20,
)
# → n.c.generators.capital_cost["wind"] ≈ 85.8 (derived)
# → n.c.generators.total_cost["wind"] ≈ 105.8

Proposal 2:

Introduce an overnight_cost attribute (default NaN) which takes precedence over capital_cost when set. Don't calculate one from the other—full information is exposed through component attributes (n.c.Generator....). Raise consistency_check warning if both are given, skip checks in n.add and other IO until we have proper data validation.

# Old behavior
n.add("Generator", "gas", capital_cost=105.8)

# New behavior
n.add("Generator", "wind",
    overnight_cost=1000,
    discount_rate=0.07,  # 0% is valid here!
    lifetime=25,
    fom_cost=20,
)
# → optimization uses: 1000 * annuity(0.07, 25) + 20 ≈ 105.8

Proposal 2 might be cleaner—no boolean flag, separate attributes with clear semantics, and discount_rate=0 works correctly. Proposal 1 reuses capital_cost for two meanings, same flexibility, mitigates confusion on the user side.

@fneum
Copy link
Copy Markdown
Member

fneum commented Jan 7, 2026

Out of proposals 1 and 2, proposal 2 is cleaner.

@lindnemi needs to chime in, since he already uses overnight_cost in PyPSA-DE networks where both are stored.

The attribute total_cost should be called periodic_cost, since (e.g. in statistics), total cost includes also OPEX.

Not sure I understood the n.c.Generator.capital_cost. Wouldn't it be an issue if n.generators.capital_cost would not return simply the values of the column in n.generators but a (potentially) computed value? That I would find confusing.

@lindnemi
Copy link
Copy Markdown
Contributor

lindnemi commented Jan 7, 2026

@lindnemi needs to chime in, since he already uses overnight_cost in PyPSA-DE networks where both are stored.

For me both is fine. I like Proposal 2 better, even though it will mean a bit more refactoring work on the PyPSA-DE side because there every asset already has capital_cost as well as overnight_cost defined, which will then lead to warnings.

What's most important for me, is that capital_cost, overnight_cost, FOM and lifetime are separately accesible in the solved networks (and ideally via n.statistics)

Add support for overnight investment costs with automatic annuitization:
- New attributes: discount_rate, lifetime, fom_cost for extendable components
- New pypsa.costs module with annuity(), annuity_factor(), effective_annual_cost()
- New statistics methods: investment() and fom()
- Backward compatible: discount_rate=0 (default) treats capital_cost as pre-annuitized
- Deprecate pypsa.common.annuity in favor of pypsa.costs.annuity

Closes #371
- Add overnight_cost attribute (NaN default) that takes precedence when set
- Change discount_rate default from 0 to NaN to allow 0% rate (simple depreciation)
- Keep capital_cost semantics unchanged for backward compatibility
- Update statistics.investment() and costs module for new logic
Disallow overnight_cost with varying investment period durations (nyears).
When nyears is uniform, collapse to scalar before calculation.
This removes complex np.outer broadcasting logic.
@FabianHofmann FabianHofmann force-pushed the feature/capital-cost-split branch from a4c758a to 3723c47 Compare January 12, 2026 09:56
- Add API reference for pypsa.costs module
- Add overnight_cost() and fom() to statistics metrics list
- Document overnight cost vs direct capital_cost approaches in objective.md
Copy link
Copy Markdown
Member

@lkstrp lkstrp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First batch of comments and the main question, why you wanna stuff properties on c.da. I think that isn't a wise design.

We are also very close to riding claude into lands of verbosity without editing

return res

@property
def periodized_cost(self) -> xr.DataArray:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why should we put any of this under c.da? Currently c.da is only transforming df data, not giving you any custom properties

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not under .da ; it was in one of my previous attempts. but that did not feel good. it is a stand-alone components feature now.

but I guess what you mean is that this is not the best module where to define it? can move it to pypsa/components/components.py

pypsa/common.py Outdated


@deprecated(
deprecated_in="0.35.0",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doing

Copy link
Copy Markdown
Contributor Author

@FabianHofmann FabianHofmann Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lkstrp deprecation now aligning with the convention?

Copy link
Copy Markdown
Member

@fneum fneum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're getting closer!

@FabianHofmann
Copy link
Copy Markdown
Contributor Author

@lkstrp @fneum thanks for the reviews. I addressed all of them. And yes, I still have to learn to better handle iterative changes with CC

@FabianHofmann
Copy link
Copy Markdown
Contributor Author

@fneum I guess two things were not clear:

  1. fom_cost is expected to be periodized by the user, just as capital_costs. they are added wo periodization. I have updated the docs, they were a bit vague
  2. back-calculation in n.c.<component>.overnight_cost is automatically done internally. this will raise an error now if lifetime and discount_rate are not given.

Copy link
Copy Markdown
Member

@fneum fneum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent! This version looks very good! Happy for it to be merged (once docs build again).

@lkstrp lkstrp enabled auto-merge (squash) January 28, 2026 13:09
@lkstrp lkstrp merged commit bec89ae into master Jan 28, 2026
27 checks passed
@lkstrp lkstrp deleted the feature/capital-cost-split branch January 28, 2026 21:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Split capital cost into FOM and investment costs

5 participants