Skip to content

Commit ec89f11

Browse files
authored
Merge pull request #58 from Open-ISP/basic-batteries
Basic batteries
2 parents 139aa85 + fb9841e commit ec89f11

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+3101
-147
lines changed

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66

77
An open-source capacity expansion modelling tool based on the methodology and assumptions used by the Australian Energy Market Operator (AEMO) to produce their Integrated System Plan (ISP). Built on [PyPSA](https://github.com/pypsa/pypsa).
88

9-
**This README is a quick reference.** For detailed instructions, tutorials, and API documentation, see the [full documentation](docs/) (hosted docs coming soon):
9+
**This README is a quick reference.** For detailed instructions, tutorials, and API documentation, see the [full documentation](https://open-isp.github.io/ISPyPSA/):
1010

11-
- [Getting Started](docs/getting_started.md) - Installation and first model run
12-
- [Configuration Reference](docs/config.md) - All configuration options
13-
- [CLI Guide](docs/cli.md) - Command line interface details
14-
- [API Reference](docs/api.md) - Python API for custom workflows
15-
- [Workflow Overview](docs/workflow.md) - How the modelling pipeline works
11+
- [Getting Started](https://open-isp.github.io/ISPyPSA/getting_started/) - Installation and first model run
12+
- [Configuration Reference](https://open-isp.github.io/ISPyPSA/config/) - All configuration options
13+
- [CLI Guide](https://open-isp.github.io/ISPyPSA/cli/) - Command line interface details
14+
- [API Reference](https://open-isp.github.io/ISPyPSA/api/) - Python API for custom workflows
15+
- [Workflow Overview](https://open-isp.github.io/ISPyPSA/workflow/) - How the modelling pipeline works
1616

1717
## Installation
1818

docs/method.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,36 @@ further information on custom constraint implementation.
9595

9696
## Generation
9797

98+
Generation is represented as a time-varying quantity for each generator:
99+
100+
- Variable renewable energy (VRE) generation data traces are the generation data published by AEMO
101+
for each project or REZ and resource type. These traces set the upper limit on VRE generator output in
102+
each modelled snapshot.
103+
- The historical weather years, or reference years, used as a basis for deriving the time varying
104+
generation data are defined using the `reference_year_cycle` options in the config. [More detail on
105+
reference years](#reference-years).
106+
- The time varying quantity of generation at each node is also dependent on the model year.
107+
AEMO publishes generation data for every year in modelling horizon for each reference year.
108+
- Other non-VRE generation is currently modelled under static output limits set by each generator's
109+
maximum capacity `maximum_capacity_mw` and minimum stable generation `minimum_load_mw` or `minimum_stable_level_%` (where defined).
110+
- Generator dispatch is optimised at each snapshot to meet demand at the lowest cost while meeting
111+
the output constraints described above.
112+
98113
## Storage
99114

115+
Storage charging and discharging behaviour is also represented as a time-varying quantity for
116+
each storage unit in the model:
117+
118+
- Charging and discharging efficiencies are defined for each battery and applied to charge/discharge
119+
in each snapshot accordingly.
120+
- The state of charge of each battery in each snapshot is determined by the previous state of charge
121+
plus energy charged minus energy discharged (with efficiencies applied).
122+
- Charge and discharge power and energy in each snapshot are limited by the `maximum_capacity_mw`
123+
and `maximum_capacity_mw` $\times$ `storage_duration_hours` properties of each battery, as well
124+
as the available state of charge.
125+
- Battery charging and discharging behaviour is optimised for each snapshot to meet demand at lowest cost,
126+
while subject to energy balance constraints.
127+
100128
## Reference years
101129

102130
Weather reference years are used ensure weather correlations are consistent between demand
@@ -237,6 +265,27 @@ modelled as relaxing the custom constraint limit.
237265

238266
### Generation
239267

268+
Generator capacities are decided by the model at the start of each [investment period](#investment-periodisation-and-discounting)
269+
and for each [node](#nodal-representation):
270+
271+
- The model currently considers all ECAA generators that are active (not retired) during the
272+
model investment periods. These projects have set capacities and are not extendable during the capacity expansion modelling,
273+
and have fixed retirement dates.
274+
- New entrant generator are extendable in the model, and the optimisation determines the capacity of new generation to be built at each node in each investment period.
275+
- Capital costs in $/MW for new entrant generators are annuitised according to the following formula:
276+
277+
$$
278+
c_{a} =\frac{c_{o} \times r }{1 - (1 + r)^{-t}}
279+
$$
280+
281+
Where $c_{a}$ is the annuitised cost and, $r$ is the [WACC](config.md#wacc), and $t$ is the generator lifetime.
282+
$c_{o}$ includes the overnight build cost (adjusted by locational cost factors), any applicable connection costs
283+
and/or additional system strength connection costs, and fixed operational costs.
284+
- Marginal costs in $/MWh are calculated for all generators for each model snapshot based on
285+
dynamic fuel prices, generator heat rates and variable operational costs defined in the input tables. Alternatively
286+
a static value can be set for a subset or all generators to simplify the model.
287+
- Where build or resource limit constraints are defined for VRE generation in specific REZs, these are set by custom constraints. Some resource limits can be relaxed up to the corresponding build limit for the specified resource type and REZ.
288+
240289
## Operational
241290

242291
Operational is the second modelling phase. In this modelling phase capacity expansion decisions are taken as fixed,

ispypsa_config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ temporal:
122122
# ~ (None): Full yearly temporal representation is used or another aggregation.
123123
# list[str]: peak-demand, residual-peak-demand, minimum-demand,
124124
# residual-minimum-demand, peak-consumption, residual-peak-consumption
125-
named_representative_weeks: [residual-peak-demand, peak-consumption, residual-minimum-demand]
125+
named_representative_weeks: [residual-peak-demand]
126126

127127
operational:
128128
resolution_min: 30

pyproject.toml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "ISPyPSA"
3-
version = "0.1.0"
3+
version = "0.1.0beta1"
44
description = "An open-source capacity expansion model based on the methodology and datasets used by the Australian Energy Market Operator (AEMO) in their Integrated System Plan (ISP)."
55
authors = [
66
{ name = "prakaa", email = "abiprakash007@gmail.com" },
@@ -14,10 +14,9 @@ dependencies = [
1414
"doit>=0.36.0",
1515
"xmltodict>=0.13.0",
1616
"thefuzz>=0.22.1",
17-
"isp-trace-parser>=1.0.3",
1817
"pyarrow>=18.0.0",
1918
"tables>=3.10.1",
20-
"isp-trace-parser>=2.0.2",
19+
"isp-trace-parser>=2.0.3",
2120
"isp-workbook-parser>=2.6.0",
2221
"requests>=2.32.3",
2322
"tqdm>=4.67.1",

src/ispypsa/iasr_table_caching/local_cache.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@
8080
"technology_specific_lcfs",
8181
] + _GENERATOR_PROPERTY_TABLES
8282

83+
_BATTERY_REQUIRED_PROPERTY_TABLES = ["battery_properties"]
84+
8385
_POLICY_REQUIRED_TABLES = [
8486
"vic_renewable_target_trajectory",
8587
"qld_renewable_target_trajectory",
@@ -97,6 +99,7 @@
9799
_NETWORK_REQUIRED_TABLES
98100
+ _GENERATORS_STORAGE_REQUIRED_SUMMARY_TABLES
99101
+ _GENERATORS_REQUIRED_PROPERTY_TABLES
102+
+ _BATTERY_REQUIRED_PROPERTY_TABLES
100103
+ _NEW_ENTRANTS_COST_TABLES
101104
+ _POLICY_REQUIRED_TABLES
102105
)

src/ispypsa/plotting/generation.py

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -349,16 +349,59 @@ def _create_export_trace(timesteps: pd.DatetimeIndex, values: list) -> go.Scatte
349349
)
350350

351351

352+
def _create_battery_charging_trace(
353+
timesteps: pd.DatetimeIndex, values: list
354+
) -> go.Scatter:
355+
"""Create a Plotly scatter trace for battery charging (shown as negative)."""
356+
return go.Scatter(
357+
x=timesteps,
358+
y=values, # Negative to show charging consumes power
359+
name="Battery Charging",
360+
mode="lines",
361+
stackgroup="two",
362+
fillcolor=get_fuel_type_color("Battery Charging"),
363+
line=dict(width=0),
364+
legendgroup="Load", # Appears in Load legend group
365+
legendgrouptitle_text="Load",
366+
visible="legendonly",
367+
hovertemplate="<b>Battery Charging</b><br>%{y:.2f} MW<extra></extra>",
368+
)
369+
370+
352371
def _create_plotly_figure(
353372
dispatch: pd.DataFrame,
354373
demand: pd.Series,
355374
title: str,
356375
transmission: pd.DataFrame | None = None,
357376
) -> go.Figure:
358-
"""Create a Plotly figure with generation, demand, and optionally transmission."""
377+
"""Create a Plotly figure with generation, demand, storage, and optionally transmission.
378+
379+
Battery storage is split into discharging (positive, stacks with generation)
380+
and charging (negative, stacks with load/exports).
381+
"""
359382
fig = go.Figure()
360383

361-
# Add transmission traces if provided
384+
# Separate battery dispatch from other generation
385+
battery_dispatch = dispatch[dispatch["fuel_type"] == "Battery"].copy()
386+
non_battery_dispatch = dispatch[dispatch["fuel_type"] != "Battery"].copy()
387+
388+
# Prepare battery charging/discharging data
389+
has_battery_data = not battery_dispatch.empty
390+
if has_battery_data:
391+
# Aggregate battery dispatch by timestep
392+
battery_by_timestep = (
393+
battery_dispatch.groupby("timestep")["dispatch_mw"].sum().reset_index()
394+
)
395+
# Discharging = positive values
396+
battery_discharging = battery_by_timestep.copy()
397+
battery_discharging["dispatch_mw"] = battery_discharging["dispatch_mw"].clip(
398+
lower=0
399+
)
400+
# Charging = negative values (keep as negative for display)
401+
battery_charging = battery_by_timestep.copy()
402+
battery_charging["dispatch_mw"] = battery_charging["dispatch_mw"].clip(upper=0)
403+
404+
# Add transmission traces if provided (hidden offset trace first)
362405
if transmission is not None and not transmission.empty:
363406
fig.add_trace(
364407
_create_generation_trace(
@@ -375,14 +418,26 @@ def _create_plotly_figure(
375418
)
376419
)
377420

378-
# Add generation traces (sorted alphabetically)
379-
fuel_types = sorted(dispatch["fuel_type"].unique())
421+
# Add battery discharging trace (stacks with generation)
422+
if has_battery_data and battery_discharging["dispatch_mw"].sum() > 0:
423+
fig.add_trace(
424+
_create_generation_trace(
425+
"Battery Discharging",
426+
battery_discharging["timestep"],
427+
battery_discharging["dispatch_mw"],
428+
)
429+
)
430+
431+
# Add generation traces (sorted alphabetically, excluding Battery)
432+
fuel_types = sorted(non_battery_dispatch["fuel_type"].unique())
380433
for fuel_type in fuel_types:
381434
fig.add_trace(
382435
_create_generation_trace(
383436
fuel_type,
384-
dispatch["timestep"],
385-
dispatch[dispatch["fuel_type"] == fuel_type]["dispatch_mw"],
437+
non_battery_dispatch["timestep"],
438+
non_battery_dispatch[non_battery_dispatch["fuel_type"] == fuel_type][
439+
"dispatch_mw"
440+
],
386441
)
387442
)
388443

@@ -394,10 +449,19 @@ def _create_plotly_figure(
394449
)
395450
)
396451

452+
# Add battery charging trace (stacks with load/exports, shown as negative)
453+
if has_battery_data and battery_charging["dispatch_mw"].sum() < 0:
454+
fig.add_trace(
455+
_create_battery_charging_trace(
456+
battery_charging["timestep"],
457+
battery_charging["dispatch_mw"], # Already negative
458+
)
459+
)
460+
397461
fig.add_trace(_create_demand_trace(demand["timestep"], demand["demand_mw"]))
398462

399-
# Apply professional styling
400-
layout = create_plotly_professional_layout(title=title)
463+
# Apply professional styling with timeseries formatting
464+
layout = create_plotly_professional_layout(title=title, timeseries=True)
401465
fig.update_layout(**layout)
402466
return fig
403467

src/ispypsa/plotting/plot.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,5 +180,10 @@ def save_plots(charts: dict[Path, dict], base_path: Path) -> None:
180180
csv_path = html_path.with_suffix(".csv")
181181
content["data"].to_csv(csv_path, index=False)
182182

183-
# Save the plot (HTML)
184-
plot.write_html(html_path)
183+
# Save the plot (HTML) with responsive sizing
184+
plot.write_html(
185+
html_path,
186+
full_html=True,
187+
include_plotlyjs=True,
188+
config={"responsive": True},
189+
)

src/ispypsa/plotting/style.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
# Hydrogen
2222
"Hydrogen": "#DDA0DD",
2323
"Hyblend": "#DDA0DD",
24+
# Battery Storage
25+
"Battery": "#3245c9",
26+
"Battery Charging": "#577CFF",
27+
"Battery Discharging": "#3245c9",
2428
# Transmission (for plotting)
2529
"Transmission Exports": "#927BAD",
2630
"Transmission Imports": "#521986",
@@ -50,19 +54,37 @@ def create_plotly_professional_layout(
5054
title: str,
5155
height: int = 600,
5256
width: int = 1200,
57+
timeseries: bool = False,
5358
) -> dict:
5459
"""Create professional/academic style layout for Plotly charts.
5560
5661
Args:
5762
title: Chart title
5863
y_max: Maximum y-axis value
5964
y_min: Minimum y-axis value
60-
height: Chart height in pixels
61-
width: Chart width in pixels
65+
height: Chart height in pixels (used as minimum height)
66+
width: Chart width in pixels (ignored when autosize is True)
67+
timeseries: If True, applies timeseries-specific formatting (rotated x-axis labels)
6268
6369
Returns:
6470
Plotly layout dictionary
6571
"""
72+
xaxis_config = {
73+
"gridcolor": "#E0E0E0",
74+
"gridwidth": 0.5,
75+
"showgrid": True,
76+
"showline": True,
77+
"linewidth": 1,
78+
"linecolor": "#CCCCCC",
79+
"mirror": True,
80+
"ticks": "outside",
81+
"tickfont": {"size": 11},
82+
}
83+
84+
if timeseries:
85+
xaxis_config["tickformat"] = "%Y-%m-%d %H:%M"
86+
xaxis_config["tickangle"] = 45
87+
6688
return {
6789
"title": {
6890
"text": title,
@@ -93,18 +115,7 @@ def create_plotly_professional_layout(
93115
"borderwidth": 1,
94116
"font": {"size": 11},
95117
},
96-
"xaxis": {
97-
"gridcolor": "#E0E0E0",
98-
"gridwidth": 0.5,
99-
"showgrid": True,
100-
"showline": True,
101-
"linewidth": 1,
102-
"linecolor": "#CCCCCC",
103-
"mirror": True,
104-
"ticks": "outside",
105-
"tickfont": {"size": 11},
106-
"tickformat": "%Y-%m-%d %H:%M",
107-
},
118+
"xaxis": xaxis_config,
108119
"yaxis": {
109120
"gridcolor": "#E0E0E0",
110121
"gridwidth": 0.5,
@@ -118,7 +129,6 @@ def create_plotly_professional_layout(
118129
"rangemode": "tozero",
119130
"tickformat": ",", # Comma separator
120131
},
121-
"height": height,
122-
"width": width,
132+
"autosize": True,
123133
"margin": {"l": 80, "r": 200, "t": 80, "b": 60},
124134
}

src/ispypsa/plotting/transmission.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,9 +317,10 @@ def plot_flows(
317317
)
318318
)
319319

320-
# Apply professional styling
320+
# Apply professional styling with timeseries formatting
321321
layout = create_plotly_professional_layout(
322-
title=f"{isp_name} - Week {week_starting} (Investment Period {investment_period})"
322+
title=f"{isp_name} - Week {week_starting} (Investment Period {investment_period})",
323+
timeseries=True,
323324
)
324325
layout["yaxis_title"] = {"text": "Flow (MW)", "font": {"size": 14}}
325326
layout["xaxis_title"] = {"text": "Timestep", "font": {"size": 14}}

src/ispypsa/plotting/website.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,11 @@ def _generate_html_template(
197197
.plot-container {{
198198
flex: 1;
199199
display: flex;
200-
align-items: center;
200+
align-items: stretch;
201201
justify-content: center;
202-
padding: 2rem;
203-
overflow: auto;
202+
padding: 1rem;
203+
overflow: hidden;
204+
min-height: 0;
204205
}}
205206
206207
.plot-frame {{

0 commit comments

Comments
 (0)