oemof / oemof-solph

A model generator for energy system modelling and optimisation (LP/MILP).
https://oemof.org
MIT License
283 stars 124 forks source link

Use representative year for multi-year-periods in multi-period-investment #992

Open nailend opened 9 months ago

nailend commented 9 months ago

The yet implemented multi-period-investment was designed by the assumption to provide time series for every year (equivalent period) within the optimization horizon. Due to a long optimization horizon (~60 years), we need to use representative years/time series for the years within a multi-year-period.

Fortunately, @jokochems well-designed approach already facilitates most of this. Currently, only the flow costs (variable and fixed) for the flows are not handled accordingly. This could be done by adjusting values for the objective weighting but might collide with its use in the TSAM integration (as discussed in #987) which we also want to use.

Therefore, I propose the boolean-flag use_representative_year_in_multi_year_periods for solph.EnergySystem . This would lead to a further condition at the calculation for the variable_costs, here.

In case it's True, variable costs would be calculated like this:

            for i, o in m.FLOWS:
                if m.flows[i, o].variable_costs[0] is not None:
                    if self.use_representative_year_in_multi_year_periods:
                        for p, timesteps in m.TIMESTEPS_IN_PERIOD.items():
                            # sum variable costs of representative year
                            variable_costs_increment = sum(
                                    m.flow[i, o, p, t]
                                    * m.objective_weighting[t]
                                    * m.flows[i, o].variable_costs[t]
                                    for t in timesteps
                            )
                            # discount to present value
                            variable_costs_increment =  
                                    variable_costs_increment 
                                    * ((1 + m.discount_rate) ** - m.es.periods_years[p])
                                )
                            # add and discount for every implicit year
                            variable_costs += sum(
                                variable_costs_increment
                                * ((1 + m.discount_rate) ** (-pp))
                                for pp in range(0, get_period_duration(p))
                            )
                    else:
                        ...

The variable costs for the representative/explicit year (with timeseries provided) would then be discounted and used for the implicit years of the period (without timeseries provided).

Further get_period_duration() from #982 would need some adaption for multi-year-periods as well:

def get_period_duration(self, period):

   period = self.period[period]
   if period < len(self.periods):
     duration = (
        self.periods[period+1].min().year
         - self.periods[period].min().year
         )
   elif period == len(self.periods):
     duration =  (
           self.periods[period].max().year
            - self.periods[period].min().year
            + 1
           )
   else:
     raise KeyError()

    return duration
jokochems commented 9 months ago

Hello @nailend,

I see what you are aiming for. Maybe I have not fully understood it, but wouldn't what I just commented for #987 maybe also address and potentially even solve this problem?

For the suggested adjustment in the variable_costs calculation, I see no problems except for some additional code duplication. For the adjustment of the method get_period_duration(), I fear that we might run into trouble for the last period. If its length was only one year, let's assume its year 60, we would have range(60, 60), so an empty object. This now is problematic for investments in the last period as this range object is also needed for iteration for the investment annuities and the fixed costs and used in an annuity routine which cannot deal with a duration of 0, see #982.

Long story short: One should think of a safe(r) way for get_period_duration(), the remainder sounds reasonable.

nailend commented 9 months ago
  • If you use one representative year to represent 10 years, timeincrement would be 1, right?

yes!

  • If you then explicitly specified an objective_weighting of 10, I think the current implementation would already cover it, wouldn't it?

I guess, I haven't fully understood, what the effect of changing 'objective_weighting means for the discount rate.

  • Of course, you have to take care of proper discounting. ... ... the latter is somewhat hacky, so I see why you want to address this inside the code base.

Exactly!

For the suggested adjustment in the variable_costs calculation, I see no problems except for some additional code duplication. For the adjustment of the method get_period_duration(), I fear that we might run into trouble for the last period. If its length was only one year, let's assume its year 60, we would have range(60, 60), so an empty object. This now is problematic for investments in the last period as this range object is also needed for iteration for the investment annuities and the fixed costs and used in an annuity routine which cannot deal with a duration of 0, see #982.

I am not sure which range() you are referring to, but in calculation of the variable_costs the range should be range(0,1) and therefore just add variable costs for the last year without discounting it. (I have adapted the function slightly to be able to use -1 for selecting the last period). With this, I also don't see any complications with the investment_flow_block. Maybe you can link the range() you are referring to?

nailend commented 9 months ago

@jokochems I think I mixed up the if conditions. In the last period, your former code will be used, so there should be no problem if there was none before. Just in all other periods, I changed the method.

Sorry for the confusion!

p-snft commented 9 months ago

I don't really get the issue. Can't you e.g. just use six representative years, each standing for ten years of your optimisation horizon? (If the issue is resolved, please close it.)

nailend commented 9 months ago

I don't really get the issue. Can't you e.g. just use six representative years, each standing for ten years of your optimisation horizon? (If the issue is resolved, please close it.)

That's exactly what I want to do, but currently the variable costs for the implicit years would not be accounted for. You can probably also do this with the objective_weighting, but I am not sure how to derive the correct value for six years and the discounting (6 * (1 + m.discount_rate) ** (-1) * (1 + m.discount_rate) ** (-2) .... * (1 + m.discount_rate) ** (-6) ?)

I would also see this as a frequently used feature for long term multi-period-investments and therefore useful. It's not that much of code changes, is it?

p-snft commented 9 months ago

Currently, no costs would be considered automatically for the implicit years. Probably, this is also the most transparent way to work. Disregarding the current implementation, I would suggest the following solution:

CAPEX:

  1. Increase the discount rate, e.g. from 0.02 to 0.22 (1.22 = 1.02^10).
  2. Give lifetimes in multiples of 10 years, so that the deprecation matches.

OPEX:

  1. Calculate the discounted prices for every year.
  2. Use sums over the 10 year periods for optimisation.

In my opinion, the way to implement this is to disable automatic discounting for the OPEX. This would be useful anyway, as you often get prognosis for future energy costs discounted to today's values.

jokochems commented 9 months ago

Currently, no costs would be considered automatically for the implicit years. Probably, this is also the most transparent way to work. Disregarding the current implementation, I would suggest the following solution:

CAPEX:

  1. Increase the discount rate, e.g. from 0.02 to 0.22 (1.22 = 1.02^10).
  2. Give lifetimes in multiples of 10 years, so that the deprecation matches.

OPEX:

  1. Calculate the discounted prices for every year.
  2. Use sums over the 10 year periods for optimisation.

In my opinion, the way to implement this is to disable automatic discounting for the OPEX. This would be useful anyway, as you often get prognosis for future energy costs discounted to today's values.

Hi, @p-snft. Thank you. I do see your point and I think, I commented similar stuff above.

I have some remarks:

Also, I like your ideas on structural improvements and am looking forward towards the discussions at the next dev meeting. Thank you!

jokochems commented 9 months ago

I am not sure which range() you are referring to, but in calculation of the variable_costs the range should be range(0,1) and therefore just add variable costs for the last year without discounting it. (I have adapted the function slightly to be able to use -1 for selecting the last period). With this, I also don't see any complications with the investment_flow_block. Maybe you can link the range() you are referring to?

I meant the ones to control for fixed costs being limited to the optimization horizon, for instance here: https://github.com/oemof/oemof-solph/blob/119e9cbab35f8289fcde3e0539486d53dd7e99bb/src/oemof/solph/flows/_investment_flow_block.py#L1025C29-L1028C30

The other remark was on the newly introduced end_year_of_optimization attribute for an energy system and the problem if this is the same as the start year of the last period.

But I think, this has been resolved as you altered the implementation of get_period_duration. I think, it even makes more sense as you defined it now - thank you. :-)