Skip to content

Commit

Permalink
Stg daily add (#135)
Browse files Browse the repository at this point in the history
* Added balancing constraint for annual storage, i.e. sum of charge and discharge equal zero

* Annual balancing constraint for storage

* Removed intra day constraint temporarily

* Updated with daily balance

* Annual storage balance constraint and daily tag

* Fixed file path in test_solve

* Remove redundant variables

* Remove year from storage_balance_day

* Add bool parameter for storage_balance_year

* Remove linear expressions and update mask

* Update storage solve test

* Update docs

* Tidy

* Tidy

* Revert negligible changes

* Fixed dimension issue with daily storage

* Add default of False to storage_balance_day/year

* Revert test_model_construction_from_yaml to utopia/main.yaml

* Fix assertion error and reenable utopia/main test

* Daily balancing equations working as expected

* Daily storage balancing and tidy up test solve file path

* Fix utopia main test, incl. assertion error

* Update docs

* fix test

* tidy

---------

Co-authored-by: djwels <dan.w@transitionzero.org>
  • Loading branch information
edwardxtg and djwels committed Aug 13, 2024
1 parent 7ca5316 commit 5c36278
Show file tree
Hide file tree
Showing 8 changed files with 168 additions and 14 deletions.
10 changes: 10 additions & 0 deletions docs/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions examples/quickstart/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -106,6 +108,7 @@ technologies:

storage:
- id: BAT
capex: 0.01
capex: 0.
operating_life: 100
residual_capacity: 0
storage_balance_day: false
64 changes: 56 additions & 8 deletions tests/test_solve/test_solve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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},
)
],
),
Expand All @@ -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,
Expand All @@ -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():
Expand Down
14 changes: 13 additions & 1 deletion tz/osemosys/model/constraints/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}
Expand All @@ -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
38 changes: 36 additions & 2 deletions tz/osemosys/model/linear_expressions/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions tz/osemosys/model/variables/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
35 changes: 34 additions & 1 deletion tz/osemosys/schemas/compat/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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 = {}
Expand All @@ -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]})
Expand Down
13 changes: 13 additions & 0 deletions tz/osemosys/schemas/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down

0 comments on commit 5c36278

Please sign in to comment.