diff --git a/docs/storage.md b/docs/storage.md index c3b16bde..b8a2f7c9 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -36,6 +36,16 @@ Optional, defaults to `None`. Maximum charging rate for the storage, in units of activity per year. Optional, defaults to `None`. +`storage_balance_day` `({region:bool})` - OSeMOSYS style name StorageBalanceDay. +Boolean parameter tagging storage technologies which must balance daily, i.e. charge must equal + discharge over each day, using daily time brackets. +Optional, defaults to `False`. + +`storage_balance_year` `({region:bool})` - OSeMOSYS style name StorageBalanceYear. +Boolean parameter tagging storage technologies which must balance anually, i.e. charge must +equal discharge over each year. +Optional, defaults to `False`. + ## Examples diff --git a/examples/quickstart/main.yaml b/examples/quickstart/main.yaml index 093fd828..333008a8 100644 --- a/examples/quickstart/main.yaml +++ b/examples/quickstart/main.yaml @@ -35,7 +35,7 @@ time_definition: commodities: - id: ELC long_name: Electricity - demand_annual: 219000 + demand_annual: "{y:(219000*1.1**(y-2020)) for y in ${time_definition.years}}" demand_profile: SD: 0.15 SN: 0.2 @@ -59,6 +59,7 @@ technologies: - id: STO_BAT long_name: Storage technology linked to BAT capacity_activity_unit_ratio: 31.536 + residual_capacity: "{yr: 10 * max((1 - (yr - 2020) / (2040 - 2020), 0)) for yr in range(2020, 2051)}" opex_fixed: 0 capex: 5 operating_life: 15 @@ -75,6 +76,7 @@ technologies: input_activity_ratio: ELC: 8760 to_storage: {"*": {"BAT": True}} + activity_annual_min: 5 - id: COAL-GEN long_name: Coal-fired power plant operating_life: 40 # years @@ -106,6 +108,7 @@ technologies: storage: - id: BAT - capex: 0.01 + capex: 0. operating_life: 100 residual_capacity: 0 + storage_balance_day: false diff --git a/tests/test_solve/test_solve.py b/tests/test_solve/test_solve.py index e203a504..02192423 100644 --- a/tests/test_solve/test_solve.py +++ b/tests/test_solve/test_solve.py @@ -52,7 +52,22 @@ def test_most_simple(): def test_simple_storage(): + """ + This model tests storage with 2 different behaviours, and has 2 different daily time brackets. + The only generation technology is solar, which produces power during the day but not at night, + so that storage must be used to meet demand. + + The first storage technology, 'bat-storage-daily', must charge and discharge by the same amount + each day. The second storage technology, 'bat-storage', has no restrictions on it's charging and + discharging behaviour. + The cost to produce electricity increases over each year, so that there is an incentive to carry + energy forward in years. + + bat-storage-daily is the cheaper technology so is used balance energy in the first few years, + whereas as the cost of electricity increases, bat-storage is used to store energy for later + years. + """ time_definition = TimeDefinition( id="years-only", years=range(2020, 2030), @@ -78,7 +93,22 @@ def test_simple_storage(): capacity_factor={"D": 1, "N": 0}, operating_modes=[ OperatingMode( - id="generation", opex_variable=0.0, output_activity_ratio={"electricity": 1.0} + id="generation", + opex_variable={ + "*": { + 2020: 0, + 2021: 10, + 2022: 20, + 2023: 30, + 2024: 40, + 2025: 50, + 2026: 60, + 2027: 70, + 2028: 80, + 2029: 90, + } + }, + output_activity_ratio={"electricity": 1.0}, ) ], ), @@ -91,27 +121,44 @@ def test_simple_storage(): id="charge", opex_variable=0, input_activity_ratio={"electricity": 1.0}, - to_storage={"*": {"bat-storage": True}}, + to_storage={"*": {"bat-storage-daily": True}}, ), OperatingMode( id="discharge", opex_variable=0, output_activity_ratio={"electricity": 1.0}, + from_storage={"*": {"bat-storage-daily": True}}, + ), + OperatingMode( + id="charge2", + opex_variable=0, + input_activity_ratio={"electricity": 1.0}, + to_storage={"*": {"bat-storage": True}}, + ), + OperatingMode( + id="discharge2", + opex_variable=0, + output_activity_ratio={"electricity": 1.0}, from_storage={"*": {"bat-storage": True}}, ), ], ), ] - storage = [ Storage( - id="bat-storage", + id="bat-storage-daily", capex=0.01, operating_life=100, residual_capacity=0, - ) + storage_balance_day=True, + ), + Storage( + id="bat-storage", + capex=0.1, + operating_life=100, + residual_capacity=0, + ), ] - model = Model( id="simple-carbon-price", time_definition=time_definition, @@ -121,11 +168,12 @@ def test_simple_storage(): storage=storage, technologies=technologies, ) - model.solve() assert model.solution.NewStorageCapacity.values[0][0][0] == 12.5 - assert np.round(model.objective) == 3494.0 + assert model.solution.NetCharge[0][1][0][0] == 75 # bat-storage 2020 Day charge + assert model.solution.NetCharge[0][1][0][1] == 0 # bat-storage 2020 Night charge + assert np.round(model.objective) == 8905.0 def test_simple_trade(): diff --git a/tz/osemosys/model/constraints/storage.py b/tz/osemosys/model/constraints/storage.py index 74cee51d..ec057a60 100644 --- a/tz/osemosys/model/constraints/storage.py +++ b/tz/osemosys/model/constraints/storage.py @@ -254,7 +254,6 @@ def add_storage_constraints(ds: xr.Dataset, m: Model, lex: Dict[str, LinearExpre ``` """ if ds["STORAGE"].size > 0: - # storage level may not exceed gross capacity con = lex["StorageLevel"] <= lex["GrossStorageCapacity"].expand_dims( {"TIMESLICE": ds["TIMESLICE"]} @@ -278,4 +277,17 @@ def add_storage_constraints(ds: xr.Dataset, m: Model, lex: Dict[str, LinearExpre con = lex["RateOfStorageDischarge"] <= ds["StorageMaxDischargeRate"] m.add_constraints(con, name="SC6_MaxDischargeConstraint") + if "StorageBalanceDay" in ds.data_vars: + # Require NetChargeWithinDay to be zero + con = (lex["StorageChargeDaily"] - lex["StorageDischargeDaily"]) == 0 + mask = ds["StorageBalanceDay"] == 1 + m.add_constraints(con, name="SC9_BalanceDailyConstraint", mask=mask) + + if "StorageBalanceYear" in ds.data_vars: + con = ( + (lex["RateOfStorageCharge"] - lex["RateOfStorageDischarge"]) * ds["YearSplit"] + ).sum(dims="TIMESLICE") == 0 + mask = ds["StorageBalanceYear"] == 1 + m.add_constraints(con, name="SC10_BalanceAnnualConstraint", mask=mask) + return m diff --git a/tz/osemosys/model/linear_expressions/storage.py b/tz/osemosys/model/linear_expressions/storage.py index 450cf7f4..469fa776 100644 --- a/tz/osemosys/model/linear_expressions/storage.py +++ b/tz/osemosys/model/linear_expressions/storage.py @@ -10,20 +10,52 @@ def add_lex_storage(ds: xr.Dataset, m: Model, lex: Dict[str, LinearExpression]): 1 + ds.coords["YEAR"][-1] - ds.coords["YEAR"][0] ) - ds["Conversionlh"] * ds["Conversionld"] * ds["Conversionls"] - RateOfStorageCharge = ( (ds["TechnologyToStorage"] * m["RateOfActivity"]).where( (ds["TechnologyToStorage"].notnull()) & (ds["TechnologyToStorage"] != 0) ) ).sum(["TECHNOLOGY", "MODE_OF_OPERATION"]) + StorageChargeDaily = ( + ( + ds["DaySplit"] + * ds["TechnologyToStorage"] + * ( + ds["Conversionlh"].fillna(0) + * ds["Conversionls"].fillna(0) + * ds["Conversionld"].fillna(0) + ).sum(dim="DAILYTIMEBRACKET") + * m["RateOfActivity"] + ).where( + (ds["TechnologyToStorage"].notnull()) + & (ds["StorageBalanceDay"] != 0) + & (ds["Conversionls"] != 0) + ) + ).sum(["TECHNOLOGY", "MODE_OF_OPERATION", "TIMESLICE"]) + RateOfStorageDischarge = ( (ds["TechnologyFromStorage"] * m["RateOfActivity"]).where( (ds["TechnologyFromStorage"].notnull()) & (ds["TechnologyFromStorage"] != 0) ) ).sum(["TECHNOLOGY", "MODE_OF_OPERATION"]) + StorageDischargeDaily = ( + ( + ds["DaySplit"] + * ds["TechnologyFromStorage"] + * ( + ds["Conversionlh"].fillna(0) + * ds["Conversionls"].fillna(0) + * ds["Conversionld"].fillna(0) + ).sum(dim="DAILYTIMEBRACKET") + * m["RateOfActivity"] + ).where( + (ds["TechnologyFromStorage"].notnull()) + & (ds["StorageBalanceDay"] != 0) + & (ds["Conversionls"] != 0) + ) + ).sum(["TECHNOLOGY", "MODE_OF_OPERATION", "TIMESLICE"]) + NetCharge = ds["YearSplit"] * (RateOfStorageCharge - RateOfStorageDischarge) NetChargeReshape = NetCharge.stack(YRTS=["YEAR", "TIMESLICE"]) @@ -65,6 +97,8 @@ def add_lex_storage(ds: xr.Dataset, m: Model, lex: Dict[str, LinearExpression]): { "RateOfStorageCharge": RateOfStorageCharge, "RateOfStorageDischarge": RateOfStorageDischarge, + "StorageChargeDaily": StorageChargeDaily, + "StorageDischargeDaily": StorageDischargeDaily, "NetCharge": NetCharge, "StorageLevel": StorageLevel, "NewStorageCapacity": NewStorageCapacity, diff --git a/tz/osemosys/model/variables/storage.py b/tz/osemosys/model/variables/storage.py index de37e0a2..41601590 100644 --- a/tz/osemosys/model/variables/storage.py +++ b/tz/osemosys/model/variables/storage.py @@ -20,4 +20,5 @@ def add_storage_variables(ds: xr.Dataset, m: Model) -> Model: RSY = [ds.coords["REGION"], ds.coords["STORAGE"], ds.coords["YEAR"]] m.add_variables(lower=0, upper=inf, coords=RSY, name="NewStorageCapacity", integer=False) + return m diff --git a/tz/osemosys/schemas/compat/storage.py b/tz/osemosys/schemas/compat/storage.py index c3431864..ac1a62e3 100644 --- a/tz/osemosys/schemas/compat/storage.py +++ b/tz/osemosys/schemas/compat/storage.py @@ -49,6 +49,14 @@ class OtooleStorage(BaseModel): "attribute": "max_charge_rate", "columns": ["REGION", "STORAGE", "VALUE"], }, + "StorageBalanceDay": { + "attribute": "storage_balance_day", + "columns": ["REGION", "STORAGE", "VALUE"], + }, + "StorageBalanceYear": { + "attribute": "storage_balance_year", + "columns": ["REGION", "STORAGE", "VALUE"], + }, } @classmethod @@ -149,9 +157,18 @@ def from_otoole_csv(cls, root_dir) -> List["Storage"]: if data_json_format["StorageMaxChargeRate"] is not None else None ), + storage_balance_day=( + OSeMOSYSData.R.Bool(data=data_json_format["StorageBalanceDay"]) + if data_json_format["StorageBalanceDay"] is not None + else None + ), + storage_balance_year=( + OSeMOSYSData.R.Bool(data=data_json_format["StorageBalanceYear"]) + if data_json_format["StorageBalanceYear"] is not None + else None + ), ) ) - return storage_instances @classmethod @@ -173,6 +190,8 @@ def to_dataframes(cls, storage: List["Storage"]) -> dict[str, pd.DataFrame]: residual_capacity_dfs = [] max_discharge_rate_dfs = [] max_charge_rate_dfs = [] + storage_balance_day_dfs = [] + storage_balance_year_dfs = [] for sto in storage: if sto.capex is not None: @@ -216,6 +235,16 @@ def to_dataframes(cls, storage: List["Storage"]) -> dict[str, pd.DataFrame]: df["STORAGE"] = sto.id df["REGION"] = df.index max_charge_rate_dfs.append(df) + if sto.storage_balance_day is not None: + df = pd.json_normalize(sto.storage_balance_day.data).T.rename(columns={0: "VALUE"}) + df["STORAGE"] = sto.id + df["REGION"] = pd.DataFrame(df.index.str.split(".").to_list(), index=df.index) + storage_balance_day_dfs.append(df) + if sto.storage_balance_year is not None: + df = pd.json_normalize(sto.storage_balance_year.data).T.rename(columns={0: "VALUE"}) + df["STORAGE"] = sto.id + df["REGION"] = pd.DataFrame(df.index.str.split(".").to_list(), index=df.index) + storage_balance_year_dfs.append(df) # collect concatenaed dfs dfs = {} @@ -233,6 +262,10 @@ def to_dataframes(cls, storage: List["Storage"]) -> dict[str, pd.DataFrame]: dfs["StorageMaxDischargeRate"] = pd.concat(max_discharge_rate_dfs) if max_charge_rate_dfs: dfs["StorageMaxChargeRate"] = pd.concat(max_charge_rate_dfs) + if storage_balance_day_dfs: + dfs["StorageBalanceDay"] = pd.concat(storage_balance_day_dfs) + if storage_balance_year_dfs: + dfs["StorageBalanceYear"] = pd.concat(storage_balance_year_dfs) # SETS dfs["STORAGE"] = pd.DataFrame({"VALUE": [sto.id for sto in storage]}) diff --git a/tz/osemosys/schemas/storage.py b/tz/osemosys/schemas/storage.py index 4767507f..b1cfbb09 100644 --- a/tz/osemosys/schemas/storage.py +++ b/tz/osemosys/schemas/storage.py @@ -48,6 +48,16 @@ class Storage(OSeMOSYSBase, OtooleStorage): Maximum charging rate for the storage, in units of activity per year. Optional, defaults to `None`. + `storage_balance_day` `({region:bool})` - OSeMOSYS style name StorageBalanceDay. + Boolean parameter tagging storage technologies which must balance daily, i.e. charge must equal + discharge over each day, using daily time brackets. + Optional, defaults to `False`. + + `storage_balance_year` `({region:bool})` - OSeMOSYS style name StorageBalanceYear. + Boolean parameter tagging storage technologies which must balance anually, i.e. charge must + equal discharge over each year. + Optional, defaults to `False`. + ## Examples @@ -89,6 +99,9 @@ class Storage(OSeMOSYSBase, OtooleStorage): max_discharge_rate: OSeMOSYSData.R | None = Field(None) max_charge_rate: OSeMOSYSData.R | None = Field(None) + storage_balance_day: OSeMOSYSData.R.Bool | None = Field(OSeMOSYSData.R.Bool(False)) + storage_balance_year: OSeMOSYSData.R.Bool | None = Field(OSeMOSYSData.R.Bool(False)) + @model_validator(mode="before") @classmethod def cast_values(cls, values: Any) -> Any: