Skip to content

Commit 0e2aa1a

Browse files
gugrimmmthedekim-mskw
authored
Soc renaming (#709)
## Related Issue Closes #525 ## Description This pull request standardizes the handling of energy storage units across the codebase by consistently using a normalized state of charge (`soc` between 0 and 1) and a new `capacity` field (in MWh) instead of the previously used `max_soc`. It updates storage models, strategies, and scenario loaders to use these new conventions, ensuring calculations and constraints are based on normalized values and absolute capacity. This improves clarity, correctness, and interoperability between different modules. **Core data model updates:** * Replaced `max_soc` with `capacity` in the `SupportsMinMaxCharge` class and related storage classes, and updated all references accordingly. The `soc` is now always a float between 0 and 1, and all calculations are adjusted to use `capacity` for absolute values. * Updated docstrings and function arguments to clarify that `soc` is normalized and to document the new `capacity` field. **Calculation and logic changes:** * Adjusted all calculations involving state of charge, charging/discharging, and energy deltas to use normalized `soc` and scale by `capacity`. This affects dispatch planning, bid calculations, and reward functions. * Updated optimization models and constraints to use `capacity` and normalized `soc`, including Pyomo model bounds and initializations. **Scenario and loader updates:** * Modified all scenario loaders (`loader_amiris.py`, `loader_csv.py`, `loader_oeds.py`, `loader_pypsa.py`, `oeds/infrastructure.py`) to output `capacity` and normalized `initial_soc`, and to require the `capacity` field in input data. These changes make the codebase more robust and less error-prone by enforcing a clear separation between normalized and absolute values for storage units. ## Checklist - [x] Documentation updated (docstrings, READMEs, user guides, inline comments, `doc` folder updates etc.) - New unit/integration tests added (not applicable) - [x] Changes noted in release notes (if any) - [x] Consent to release this PR's code under the GNU Affero General Public License v3.0 --------- Co-authored-by: mthede <[email protected]> Co-authored-by: kim-mskw <[email protected]>
1 parent 971f652 commit 0e2aa1a

35 files changed

+658
-557
lines changed

assume/common/base.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,7 @@ class SupportsMinMaxCharge(BaseUnit):
473473
"""
474474

475475
initial_soc: float
476+
# float between 0 and 1 - initial state of charge
476477
min_power_charge: float
477478
# negative float - if this storage is charging, what is the minimum charging power (the negative non-zero power closest to zero) (resulting in negative current power)
478479
max_power_charge: float
@@ -489,7 +490,7 @@ class SupportsMinMaxCharge(BaseUnit):
489490
# negative
490491
ramp_down_charge: float | None
491492
# ramp_down_charge is negative
492-
max_soc: float
493+
capacity: float
493494
efficiency_charge: float
494495
efficiency_discharge: float
495496

@@ -509,7 +510,7 @@ def calculate_min_max_charge(
509510
Args:
510511
start (datetime.datetime): The start time of the dispatch.
511512
end (datetime.datetime): The end time of the dispatch.
512-
soc (float, optional): The current state-of-charge. Defaults to None.
513+
soc (float, optional): The current state-of-charge (between 0 and 1). Defaults to None.
513514
514515
Returns:
515516
tuple[np.ndarray, np.ndarray]: The min and max charging power for the given time period.
@@ -524,7 +525,7 @@ def calculate_min_max_discharge(
524525
Args:
525526
start (datetime.datetime): The start time of the dispatch.
526527
end (datetime.datetime): The end time of the dispatch.
527-
soc (float, optional): The current state-of-charge. Defaults to None.
528+
soc (float, optional): The current state-of-charge (between 0 and 1). Defaults to None.
528529
529530
Returns:
530531
tuple[np.ndarray, np.ndarray]: The min and max discharging power for the given time period.
@@ -660,7 +661,9 @@ def set_dispatch_plan(
660661
if current_power > max_soc_discharge:
661662
current_power = max_soc_discharge
662663

663-
delta_soc = -current_power * time_delta / self.efficiency_discharge
664+
delta_soc = (
665+
-current_power * time_delta / self.efficiency_discharge
666+
) / self.capacity
664667

665668
# charging
666669
elif current_power < 0:
@@ -669,7 +672,9 @@ def set_dispatch_plan(
669672
if current_power < max_soc_charge:
670673
current_power = max_soc_charge
671674

672-
delta_soc = -current_power * time_delta * self.efficiency_charge
675+
delta_soc = (
676+
-current_power * time_delta * self.efficiency_charge
677+
) / self.capacity
673678

674679
# update the values of the state of charge and the energy
675680
self.outputs["soc"].at[next_t] = soc + delta_soc

assume/scenario/loader_amiris.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,8 +285,8 @@ def add_agent_to_world(
285285
market_prices={"eom": forecast_price},
286286
)
287287

288-
max_soc = device["EnergyToPowerRatio"] * device["InstalledPowerInMW"]
289-
initial_soc = device["InitialEnergyLevelInMWH"]
288+
capacity = device["EnergyToPowerRatio"] * device["InstalledPowerInMW"]
289+
initial_soc = device["InitialEnergyLevelInMWH"] / capacity
290290
# TODO device["SelfDischargeRatePerHour"]
291291
world.add_unit(
292292
f"StorageTrader_{agent['Id']}",
@@ -298,7 +298,7 @@ def add_agent_to_world(
298298
"efficiency_charge": device["ChargingEfficiency"],
299299
"efficiency_discharge": device["DischargingEfficiency"],
300300
"initial_soc": initial_soc,
301-
"max_soc": max_soc,
301+
"capacity": capacity,
302302
"bidding_strategies": storage_strategies,
303303
"technology": "hydro", # PSPP? Pump-Storage Power Plant
304304
"emission_factor": 0,

assume/scenario/loader_csv.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,8 @@ def load_config_and_create_forecaster(
515515
storage_units["max_power_charge"] = -abs(storage_units["max_power_charge"])
516516
if "min_power_charge" in storage_units.columns:
517517
storage_units["min_power_charge"] = -abs(storage_units["min_power_charge"])
518+
if "capacity" not in storage_units.columns:
519+
raise ValueError("No capacity column provided for storage units!")
518520

519521
# Initialize an empty dictionary to combine the DSM units
520522
dsm_units = {}

assume/scenario/loader_oeds.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -302,8 +302,9 @@ def load_oeds(
302302
{
303303
"max_power_charge": storage["max_power_charge"] / 1e3,
304304
"max_power_discharge": storage["max_power_discharge"] / 1e3,
305-
"max_soc": storage["max_soc"] / 1e3,
306-
"min_soc": storage["min_soc"] / 1e3,
305+
"capacity": storage["capacity"] / 1e3,
306+
"max_soc": storage["max_soc"],
307+
"min_soc": storage["min_soc"],
307308
"efficiency_charge": storage["efficiency_charge"],
308309
"efficiency_discharge": storage["efficiency_discharge"],
309310
"bidding_strategies": bidding_strategies["storage"],

assume/scenario/loader_pypsa.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,10 @@ def load_pypsa(
159159
"efficiency_charge": storage.efficiency_store,
160160
"efficiency_discharge": storage.efficiency_dispatch,
161161
"initial_soc": storage.state_of_charge_initial,
162-
"max_soc": storage.p_nom,
162+
"capacity": storage.p_nom * storage.max_hours,
163163
"bidding_strategies": bidding_strategies[unit_type][storage.name],
164-
"technology": "hydro",
165-
"emission_factor": 0,
164+
"technology": storage.carrier,
165+
"emission_factor": storage.emission_factor or 0,
166166
"node": storage.bus,
167167
},
168168
UnitForecaster(index),

assume/scenario/oeds/infrastructure.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -676,16 +676,18 @@ def get_water_storage_systems(
676676
"startDate": pd.to_datetime(data["startDate"].to_numpy()[0]),
677677
"max_power_discharge": data["PMinus_max"].sum(),
678678
"max_power_charge": -data["PPlus_max"].sum(),
679-
"max_soc": data["VMax"].to_numpy()[0],
679+
"capacity": data["VMax"].to_numpy()[0],
680+
"max_soc": 1,
680681
"min_soc": 0,
681-
"V0": data["VMax"].to_numpy()[0] / 2,
682+
"initial_soc": 0.5,
683+
"V0": 0.5 * data["VMax"].to_numpy()[0],
682684
"lat": data["lat"].to_numpy()[0],
683685
"lon": data["lon"].to_numpy()[0],
684686
"efficiency_charge": 0.88,
685687
"efficiency_discharge": 0.92,
686688
}
687689
# https://energie.ch/pumpspeicherkraftwerk/
688-
if storage["max_soc"] > 0:
690+
if storage["capacity"] > 0:
689691
storages.append(storage)
690692
return storages
691693

assume/strategies/dmas_storage.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ def build_model(
139139
time_range, within=pyo.Reals, bounds=(0, unit.max_power_discharge)
140140
)
141141
self.model.volume = pyo.Var(
142-
time_range, within=pyo.NonNegativeReals, bounds=(0, unit.max_soc)
142+
time_range, within=pyo.NonNegativeReals, bounds=(0, unit.capacity)
143143
)
144144

145145
self.power = np.array(
@@ -151,7 +151,7 @@ def build_model(
151151
)
152152

153153
self.model.vol_con = pyo.ConstraintList()
154-
v0 = unit.outputs["soc"].at[start]
154+
v0 = unit.outputs["soc"].at[start] * unit.capacity
155155

156156
for t in time_range:
157157
if t == 0:
@@ -162,7 +162,7 @@ def build_model(
162162
)
163163

164164
# always end with half full SoC
165-
self.model.vol_con.add(self.model.volume[hour_count - 1] == unit.max_soc / 2)
165+
self.model.vol_con.add(self.model.volume[hour_count - 1] == unit.capacity / 2)
166166
return self.power
167167

168168
def optimize(

assume/strategies/flexable.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,12 @@ def calculate_EOM_price_if_off(
503503
# if we split starting_cost across av_operating_time
504504
# we are never adding the other parts of the cost to the following hours
505505

506-
markup = starting_cost / avg_operating_time / bid_quantity_inflex
506+
# if unit never operated before and min_operating_time is 0, set avg_operating_time is considered to be 1 and hence neglected to avoid division by zero
507+
# this lets the power plant only start if it can recover the starting costs in the first hour, which is quite restrictive
508+
if avg_operating_time == 0:
509+
markup = starting_cost / bid_quantity_inflex
510+
else:
511+
markup = starting_cost / avg_operating_time / bid_quantity_inflex
507512

508513
bid_price_inflex = min(marginal_cost_inflex + markup, 3000.0)
509514

@@ -546,7 +551,13 @@ def calculate_EOM_price_if_on(
546551
# check the starting cost if the unit were turned off for min_down_time
547552
starting_cost = unit.get_starting_costs(-unit.min_down_time)
548553

549-
price_reduction_restart = starting_cost / unit.min_down_time / bid_quantity_inflex
554+
# disregard unit.min_down_time of 0 to avoid division by zero
555+
if unit.min_down_time == 0:
556+
price_reduction_restart = starting_cost / bid_quantity_inflex
557+
else:
558+
price_reduction_restart = (
559+
starting_cost / unit.min_down_time / bid_quantity_inflex
560+
)
550561

551562
if unit.outputs["heat"].at[start] > 0:
552563
heat_gen_cost = (

assume/strategies/flexable_storage.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -152,14 +152,22 @@ def calculate_bids(
152152
# calculate theoretic SOC
153153
time_delta = (end - start) / timedelta(hours=1)
154154
if bid_quantity + current_power > 0:
155-
delta_soc = -(
156-
(bid_quantity + current_power)
157-
* time_delta
158-
/ unit.efficiency_discharge
155+
delta_soc = (
156+
-(
157+
(bid_quantity + current_power)
158+
* time_delta
159+
/ unit.efficiency_discharge
160+
)
161+
/ unit.capacity
159162
)
160163
elif bid_quantity + current_power < 0:
161-
delta_soc = -(
162-
(bid_quantity + current_power) * time_delta * unit.efficiency_charge
164+
delta_soc = (
165+
-(
166+
(bid_quantity + current_power)
167+
* time_delta
168+
* unit.efficiency_charge
169+
)
170+
/ unit.capacity
163171
)
164172
else:
165173
delta_soc = 0
@@ -329,10 +337,13 @@ def calculate_bids(
329337
)
330338
# calculate theoretic SOC
331339
time_delta = (end - start) / timedelta(hours=1)
332-
delta_soc = -(
333-
(bid_quantity + current_power)
334-
* time_delta
335-
/ unit.efficiency_discharge
340+
delta_soc = (
341+
-(
342+
(bid_quantity + current_power)
343+
* time_delta
344+
/ unit.efficiency_discharge
345+
)
346+
/ unit.capacity
336347
)
337348
theoretic_SOC += delta_soc
338349
previous_power = bid_quantity + current_power
@@ -440,7 +451,7 @@ def calculate_bids(
440451
time_delta = (end - start) / timedelta(hours=1)
441452
delta_soc = (
442453
(bid_quantity + current_power) * time_delta * unit.efficiency_charge
443-
)
454+
) / unit.capacity
444455
theoretic_SOC += delta_soc
445456
previous_power = bid_quantity + current_power
446457
else:

assume/strategies/learning_strategies.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -906,12 +906,12 @@ def get_individual_observations(
906906
the agent's action selection.
907907
"""
908908
# get the current soc and energy cost value
909-
soc_scaled = unit.outputs["soc"].at[start] / unit.max_soc
909+
soc = unit.outputs["soc"].at[start]
910910
cost_stored_energy_scaled = (
911911
unit.outputs["cost_stored_energy"].at[start] / self.max_bid_price
912912
)
913913

914-
individual_observations = np.array([soc_scaled, cost_stored_energy_scaled])
914+
individual_observations = np.array([soc, cost_stored_energy_scaled])
915915

916916
return individual_observations
917917

@@ -1069,15 +1069,17 @@ def calculate_reward(
10691069

10701070
# Calculate and clip the energy cost for the start time
10711071
# cost_stored_energy = average volume-weighted procurement costs of the currently stored energy
1072-
if next_soc < 1:
1072+
if next_soc * unit.capacity < 1:
10731073
unit.outputs["cost_stored_energy"].at[next_time] = 0
10741074
elif accepted_volume < 0:
10751075
# increase costs of current SoC by price for buying energy
10761076
# not fully representing the true cost per MWh (e.g. omitting discharge efficiency losses), but serving as a proxy for it
10771077
unit.outputs["cost_stored_energy"].at[next_time] = (
1078-
unit.outputs["cost_stored_energy"].at[start] * current_soc
1078+
unit.outputs["cost_stored_energy"].at[start]
1079+
* current_soc
1080+
* unit.capacity
10791081
- (accepted_price + marginal_cost) * accepted_volume * duration_hours
1080-
) / next_soc
1082+
) / (next_soc * unit.capacity)
10811083
else:
10821084
unit.outputs["cost_stored_energy"].at[next_time] = unit.outputs[
10831085
"cost_stored_energy"

0 commit comments

Comments
 (0)