Skip to content

Commit 3d844c3

Browse files
committed
Allow a storage unit to be energy neutral over its EnergyType period
eESSInventory links a unit's inventory to its own past and bounds it, but nothing requires it to end a period where it started. For a store that is the right default. For demand-side management it is not: shifted consumption is meant to be recovered within a window, not displaced across the horizon, and a unit ending each day away from where it started quietly relaxes the shifting it represents. eMinimumEnergy and eMaximumEnergy already bound the energy a unit produces over an EnergyType period, using the same window. They cannot express neutrality, because they compare output against an exogenous profile whereas neutrality ties output to charge, and the level the two must agree on is decided by the optimisation rather than given. This reuses that period rather than introducing a second way to say daily or weekly: EnergyNeutrality opts a unit in, EnergyType sets the block. Both columns are optional and default to off, so cases written before them load unchanged.
1 parent 363b61c commit 3d844c3

3 files changed

Lines changed: 30 additions & 0 deletions

File tree

openTEPES/openTEPES_DataConfiguration.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,9 @@ def Create_ESS_RES_Sets(mTEPES) -> None:
518518
par['pStorageTimeStep'] = par['pStorageType' ].map(idxCycle ).fillna(1) .astype('int')
519519
par['pOutflowsTimeStep'] = par['pOutflowsType'].map(idxOutflows).fillna(1).where(par['pEnergyOutflows' ].sum() > 0.0, other = 1).astype('int')
520520
par['pEnergyTimeStep'] = par['pEnergyType' ].map(idxEnergy ).fillna(1).where(par['pVariableMinEnergy'].sum() + par['pVariableMaxEnergy'].sum() > 0.0, other = 1).astype('int')
521+
# Same period vocabulary, but not gated on the min/max energy profiles: neutrality needs a block
522+
# length whether or not the unit also carries an energy bound.
523+
par['pNeutralityTimeStep'] = par['pEnergyType'].map(idxEnergy).fillna(1).astype('int')
521524

522525
par['pStorageTimeStep'] = pd.concat([par['pStorageTimeStep'], par['pOutflowsTimeStep'], par['pEnergyTimeStep']], axis=1).min(axis=1)
523526
# cycle time step can't exceed the stage duration
@@ -618,6 +621,8 @@ def Create_ESS_RES_Sets(mTEPES) -> None:
618621
par['pStorageTimeStep'] = par['pStorageTimeStep'].loc [mTEPES.es ]
619622
par['pOutflowsTimeStep'] = par['pOutflowsTimeStep'].loc [mTEPES.es ]
620623
par['pStorageType'] = par['pStorageType'].loc [mTEPES.es ]
624+
par['pIndEnergyNeutrality'] = par['pIndEnergyNeutrality'].loc [mTEPES.es ]
625+
par['pNeutralityTimeStep'] = par['pNeutralityTimeStep'].loc [mTEPES.es ]
621626

622627
# separate positive and negative demands to avoid converting negative values to 0
623628
par['pDemandElecPos'] = par['pDemandElec'].where(par['pDemandElec'] >= 0.0, 0.0)
@@ -1113,6 +1118,8 @@ def filter_rows(df, set):
11131118
mTEPES.pEnergyTimeStep = Param(mTEPES.gg, initialize=par['pEnergyTimeStep'].to_dict() , within=PositiveIntegers, doc='Unit energy cycle' )
11141119
mTEPES.pIniInventory = Param(mTEPES.psnes, initialize=par['pIniInventory'].to_dict() , within=NonNegativeReals, doc='ESS Initial storage', mutable=True)
11151120
mTEPES.pStorageType = Param(mTEPES.es, initialize=par['pStorageType'].to_dict() , within=Any , doc='ESS Storage type' )
1121+
mTEPES.pIndEnergyNeutrality = Param(mTEPES.es, initialize=par['pIndEnergyNeutrality'].to_dict(), within=Binary, doc='Energy neutral over its EnergyType period')
1122+
mTEPES.pNeutralityTimeStep = Param(mTEPES.es, initialize=par['pNeutralityTimeStep'].to_dict() , within=PositiveIntegers, doc='Energy neutrality period [load levels]')
11161123
mTEPES.pGenLoInvest = Param(mTEPES.eb, initialize=par['pGenLoInvest'].to_dict() , within=NonNegativeReals, doc='Lower bound of the investment decision', mutable=True)
11171124
mTEPES.pGenUpInvest = Param(mTEPES.eb, initialize=par['pGenUpInvest'].to_dict() , within=NonNegativeReals, doc='Upper bound of the investment decision', mutable=True)
11181125
mTEPES.pGenLoRetire = Param(mTEPES.gd, initialize=par['pGenLoRetire'].to_dict() , within=NonNegativeReals, doc='Lower bound of the retirement decision', mutable=True)

openTEPES/openTEPES_InputData.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,10 @@ def ProcessParameter(pDataFrame: pd.DataFrame, pTimeStep: int) -> pd.DataFrame:
381381
par['pEfficiency'] = dfs['dfGeneration'] ['Efficiency' ] # ESS round-trip efficiency [p.u.]
382382
par['pStorageType'] = dfs['dfGeneration'] ['StorageType' ] # ESS storage type
383383
par['pOutflowsType'] = dfs['dfGeneration'] ['OutflowsType' ] # ESS outflows type
384+
# Optional opt-in to energy neutrality. The period is EnergyType, read just below.
385+
par['pIndEnergyNeutrality'] = (dfs['dfGeneration']['EnergyNeutrality']
386+
if 'EnergyNeutrality' in dfs['dfGeneration'].columns
387+
else pd.Series(0, index=dfs['dfGeneration'].index)).fillna(0).astype('int')
384388
par['pEnergyType'] = dfs['dfGeneration'] ['EnergyType' ] # unit energy type
385389
par['pRMaxReactivePower'] = dfs['dfGeneration'] ['MaximumReactivePower' ] * 1e-3 # rated maximum reactive power [Gvar]
386390
par['pGenLoInvest'] = dfs['dfGeneration'] ['InvestmentLo' ] # Lower bound of the investment decision [p.u.]

openTEPES/openTEPES_ModelFormulationElectricity.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,25 @@ def eESSInventory(OptModel,n,es):
311311
return Constraint.Skip
312312
setattr(OptModel, f'eESSInventory_{p}_{sc}_{st}', Constraint(mTEPES.nesc, rule=eESSInventory, doc='ESS inventory balance [GWh]'))
313313

314+
# eMinimumEnergy and eMaximumEnergy bound the energy a unit produces over an EnergyType period
315+
# against an exogenous profile. This is the same period and the same window, but it ties output
316+
# to charge rather than to a profile: over the block the two must net to zero. That cannot be
317+
# written as a min/max pair, since the level they must agree on is decided by the optimisation.
318+
ne = [es for es in mTEPES.es if mTEPES.pIndEnergyNeutrality[es]]
319+
if ne:
320+
def eEnergyNeutrality(OptModel,n,es):
321+
win = mTEPES.pNeutralityTimeStep[es]
322+
i = mTEPES.n.ord(n)
323+
if i % win != 0 or (p,es) not in mTEPES.pes or (p,sc,st,n) not in mTEPES.s2n:
324+
return Constraint.Skip
325+
return sum(mTEPES.pDuration[p,sc,n2]() * (OptModel.vTotalOutput[p,sc,n2,es]
326+
- OptModel.vESSTotalCharge[p,sc,n2,es]) for n2 in n2list[i-win:i]) == 0.0
327+
setattr(OptModel, f'eEnergyNeutrality_{p}_{sc}_{st}',
328+
Constraint(mTEPES.n, ne, rule=eEnergyNeutrality, doc='energy neutrality over the EnergyType period [GWh]'))
329+
330+
if pIndLogConsole:
331+
print('eEnergyNeutrality ... ', len(getattr(OptModel, f'eEnergyNeutrality_{p}_{sc}_{st}')), ' rows')
332+
314333
if pIndLogConsole:
315334
print('eESSInventory ... ', len(getattr(OptModel, f'eESSInventory_{p}_{sc}_{st}')), ' rows')
316335

0 commit comments

Comments
 (0)