Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@

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).

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

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

## Installation

Expand Down
49 changes: 49 additions & 0 deletions docs/method.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,36 @@ further information on custom constraint implementation.

## Generation

Generation is represented as a time-varying quantity for each generator:

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

## Storage

Storage charging and discharging behaviour is also represented as a time-varying quantity for
each storage unit in the model:

- Charging and discharging efficiencies are defined for each battery and applied to charge/discharge
in each snapshot accordingly.
- The state of charge of each battery in each snapshot is determined by the previous state of charge
plus energy charged minus energy discharged (with efficiencies applied).
- Charge and discharge power and energy in each snapshot are limited by the `maximum_capacity_mw`
and `maximum_capacity_mw` $\times$ `storage_duration_hours` properties of each battery, as well
as the available state of charge.
- Battery charging and discharging behaviour is optimised for each snapshot to meet demand at lowest cost,
while subject to energy balance constraints.

## Reference years

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

### Generation

Generator capacities are decided by the model at the start of each [investment period](#investment-periodisation-and-discounting)
and for each [node](#nodal-representation):

- The model currently considers all ECAA generators that are active (not retired) during the
model investment periods. These projects have set capacities and are not extendable during the capacity expansion modelling,
and have fixed retirement dates.
- 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.
- Capital costs in $/MW for new entrant generators are annuitised according to the following formula:

$$
c_{a} =\frac{c_{o} \times r }{1 - (1 + r)^{-t}}
$$

Where $c_{a}$ is the annuitised cost and, $r$ is the [WACC](config.md#wacc), and $t$ is the generator lifetime.
$c_{o}$ includes the overnight build cost (adjusted by locational cost factors), any applicable connection costs
and/or additional system strength connection costs, and fixed operational costs.
- Marginal costs in $/MWh are calculated for all generators for each model snapshot based on
dynamic fuel prices, generator heat rates and variable operational costs defined in the input tables. Alternatively
a static value can be set for a subset or all generators to simplify the model.
- 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.

## Operational

Operational is the second modelling phase. In this modelling phase capacity expansion decisions are taken as fixed,
Expand Down
2 changes: 1 addition & 1 deletion ispypsa_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ temporal:
# ~ (None): Full yearly temporal representation is used or another aggregation.
# list[str]: peak-demand, residual-peak-demand, minimum-demand,
# residual-minimum-demand, peak-consumption, residual-peak-consumption
named_representative_weeks: [residual-peak-demand, peak-consumption, residual-minimum-demand]
named_representative_weeks: [residual-peak-demand]

operational:
resolution_min: 30
Expand Down
5 changes: 2 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "ISPyPSA"
version = "0.1.0"
version = "0.1.0beta1"
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)."
authors = [
{ name = "prakaa", email = "abiprakash007@gmail.com" },
Expand All @@ -14,10 +14,9 @@ dependencies = [
"doit>=0.36.0",
"xmltodict>=0.13.0",
"thefuzz>=0.22.1",
"isp-trace-parser>=1.0.3",
"pyarrow>=18.0.0",
"tables>=3.10.1",
"isp-trace-parser>=2.0.2",
"isp-trace-parser>=2.0.3",
"isp-workbook-parser>=2.6.0",
"requests>=2.32.3",
"tqdm>=4.67.1",
Expand Down
3 changes: 3 additions & 0 deletions src/ispypsa/iasr_table_caching/local_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@
"technology_specific_lcfs",
] + _GENERATOR_PROPERTY_TABLES

_BATTERY_REQUIRED_PROPERTY_TABLES = ["battery_properties"]

_POLICY_REQUIRED_TABLES = [
"vic_renewable_target_trajectory",
"qld_renewable_target_trajectory",
Expand All @@ -97,6 +99,7 @@
_NETWORK_REQUIRED_TABLES
+ _GENERATORS_STORAGE_REQUIRED_SUMMARY_TABLES
+ _GENERATORS_REQUIRED_PROPERTY_TABLES
+ _BATTERY_REQUIRED_PROPERTY_TABLES
+ _NEW_ENTRANTS_COST_TABLES
+ _POLICY_REQUIRED_TABLES
)
Expand Down
80 changes: 72 additions & 8 deletions src/ispypsa/plotting/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,16 +349,59 @@ def _create_export_trace(timesteps: pd.DatetimeIndex, values: list) -> go.Scatte
)


def _create_battery_charging_trace(
timesteps: pd.DatetimeIndex, values: list
) -> go.Scatter:
"""Create a Plotly scatter trace for battery charging (shown as negative)."""
return go.Scatter(
x=timesteps,
y=values, # Negative to show charging consumes power
name="Battery Charging",
mode="lines",
stackgroup="two",
fillcolor=get_fuel_type_color("Battery Charging"),
line=dict(width=0),
legendgroup="Load", # Appears in Load legend group
legendgrouptitle_text="Load",
visible="legendonly",
hovertemplate="<b>Battery Charging</b><br>%{y:.2f} MW<extra></extra>",
)


def _create_plotly_figure(
dispatch: pd.DataFrame,
demand: pd.Series,
title: str,
transmission: pd.DataFrame | None = None,
) -> go.Figure:
"""Create a Plotly figure with generation, demand, and optionally transmission."""
"""Create a Plotly figure with generation, demand, storage, and optionally transmission.

Battery storage is split into discharging (positive, stacks with generation)
and charging (negative, stacks with load/exports).
"""
fig = go.Figure()

# Add transmission traces if provided
# Separate battery dispatch from other generation
battery_dispatch = dispatch[dispatch["fuel_type"] == "Battery"].copy()
non_battery_dispatch = dispatch[dispatch["fuel_type"] != "Battery"].copy()

# Prepare battery charging/discharging data
has_battery_data = not battery_dispatch.empty
if has_battery_data:
# Aggregate battery dispatch by timestep
battery_by_timestep = (
battery_dispatch.groupby("timestep")["dispatch_mw"].sum().reset_index()
)
# Discharging = positive values
battery_discharging = battery_by_timestep.copy()
battery_discharging["dispatch_mw"] = battery_discharging["dispatch_mw"].clip(
lower=0
)
# Charging = negative values (keep as negative for display)
battery_charging = battery_by_timestep.copy()
battery_charging["dispatch_mw"] = battery_charging["dispatch_mw"].clip(upper=0)

# Add transmission traces if provided (hidden offset trace first)
if transmission is not None and not transmission.empty:
fig.add_trace(
_create_generation_trace(
Expand All @@ -375,14 +418,26 @@ def _create_plotly_figure(
)
)

# Add generation traces (sorted alphabetically)
fuel_types = sorted(dispatch["fuel_type"].unique())
# Add battery discharging trace (stacks with generation)
if has_battery_data and battery_discharging["dispatch_mw"].sum() > 0:
fig.add_trace(
_create_generation_trace(
"Battery Discharging",
battery_discharging["timestep"],
battery_discharging["dispatch_mw"],
)
)

# Add generation traces (sorted alphabetically, excluding Battery)
fuel_types = sorted(non_battery_dispatch["fuel_type"].unique())
for fuel_type in fuel_types:
fig.add_trace(
_create_generation_trace(
fuel_type,
dispatch["timestep"],
dispatch[dispatch["fuel_type"] == fuel_type]["dispatch_mw"],
non_battery_dispatch["timestep"],
non_battery_dispatch[non_battery_dispatch["fuel_type"] == fuel_type][
"dispatch_mw"
],
)
)

Expand All @@ -394,10 +449,19 @@ def _create_plotly_figure(
)
)

# Add battery charging trace (stacks with load/exports, shown as negative)
if has_battery_data and battery_charging["dispatch_mw"].sum() < 0:
fig.add_trace(
_create_battery_charging_trace(
battery_charging["timestep"],
battery_charging["dispatch_mw"], # Already negative
)
)

fig.add_trace(_create_demand_trace(demand["timestep"], demand["demand_mw"]))

# Apply professional styling
layout = create_plotly_professional_layout(title=title)
# Apply professional styling with timeseries formatting
layout = create_plotly_professional_layout(title=title, timeseries=True)
fig.update_layout(**layout)
return fig

Expand Down
9 changes: 7 additions & 2 deletions src/ispypsa/plotting/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,5 +180,10 @@ def save_plots(charts: dict[Path, dict], base_path: Path) -> None:
csv_path = html_path.with_suffix(".csv")
content["data"].to_csv(csv_path, index=False)

# Save the plot (HTML)
plot.write_html(html_path)
# Save the plot (HTML) with responsive sizing
plot.write_html(
html_path,
full_html=True,
include_plotlyjs=True,
config={"responsive": True},
)
42 changes: 26 additions & 16 deletions src/ispypsa/plotting/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
# Hydrogen
"Hydrogen": "#DDA0DD",
"Hyblend": "#DDA0DD",
# Battery Storage
"Battery": "#3245c9",
"Battery Charging": "#577CFF",
"Battery Discharging": "#3245c9",
# Transmission (for plotting)
"Transmission Exports": "#927BAD",
"Transmission Imports": "#521986",
Expand Down Expand Up @@ -50,19 +54,37 @@ def create_plotly_professional_layout(
title: str,
height: int = 600,
width: int = 1200,
timeseries: bool = False,
) -> dict:
"""Create professional/academic style layout for Plotly charts.

Args:
title: Chart title
y_max: Maximum y-axis value
y_min: Minimum y-axis value
height: Chart height in pixels
width: Chart width in pixels
height: Chart height in pixels (used as minimum height)
width: Chart width in pixels (ignored when autosize is True)
timeseries: If True, applies timeseries-specific formatting (rotated x-axis labels)

Returns:
Plotly layout dictionary
"""
xaxis_config = {
"gridcolor": "#E0E0E0",
"gridwidth": 0.5,
"showgrid": True,
"showline": True,
"linewidth": 1,
"linecolor": "#CCCCCC",
"mirror": True,
"ticks": "outside",
"tickfont": {"size": 11},
}

if timeseries:
xaxis_config["tickformat"] = "%Y-%m-%d %H:%M"
xaxis_config["tickangle"] = 45

return {
"title": {
"text": title,
Expand Down Expand Up @@ -93,18 +115,7 @@ def create_plotly_professional_layout(
"borderwidth": 1,
"font": {"size": 11},
},
"xaxis": {
"gridcolor": "#E0E0E0",
"gridwidth": 0.5,
"showgrid": True,
"showline": True,
"linewidth": 1,
"linecolor": "#CCCCCC",
"mirror": True,
"ticks": "outside",
"tickfont": {"size": 11},
"tickformat": "%Y-%m-%d %H:%M",
},
"xaxis": xaxis_config,
"yaxis": {
"gridcolor": "#E0E0E0",
"gridwidth": 0.5,
Expand All @@ -118,7 +129,6 @@ def create_plotly_professional_layout(
"rangemode": "tozero",
"tickformat": ",", # Comma separator
},
"height": height,
"width": width,
"autosize": True,
"margin": {"l": 80, "r": 200, "t": 80, "b": 60},
}
5 changes: 3 additions & 2 deletions src/ispypsa/plotting/transmission.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,9 +317,10 @@ def plot_flows(
)
)

# Apply professional styling
# Apply professional styling with timeseries formatting
layout = create_plotly_professional_layout(
title=f"{isp_name} - Week {week_starting} (Investment Period {investment_period})"
title=f"{isp_name} - Week {week_starting} (Investment Period {investment_period})",
timeseries=True,
)
layout["yaxis_title"] = {"text": "Flow (MW)", "font": {"size": 14}}
layout["xaxis_title"] = {"text": "Timestep", "font": {"size": 14}}
Expand Down
7 changes: 4 additions & 3 deletions src/ispypsa/plotting/website.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,10 +197,11 @@ def _generate_html_template(
.plot-container {{
flex: 1;
display: flex;
align-items: center;
align-items: stretch;
justify-content: center;
padding: 2rem;
overflow: auto;
padding: 1rem;
overflow: hidden;
min-height: 0;
}}

.plot-frame {{
Expand Down
10 changes: 9 additions & 1 deletion src/ispypsa/pypsa_build/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from ispypsa.pypsa_build.initialise import _initialise_network
from ispypsa.pypsa_build.investment_period_weights import _add_investment_period_weights
from ispypsa.pypsa_build.links import _add_links_to_network
from ispypsa.pypsa_build.storage import _add_batteries_to_network


def build_pypsa_network(
Expand Down Expand Up @@ -58,7 +59,11 @@ def build_pypsa_network(
network, pypsa_friendly_tables["investment_period_weights"]
)

_add_carriers_to_network(network, pypsa_friendly_tables["generators"])
_add_carriers_to_network(
network,
pypsa_friendly_tables.get("generators"),
pypsa_friendly_tables.get("batteries"),
)

_add_buses_to_network(
network, pypsa_friendly_tables["buses"], path_to_pypsa_friendly_timeseries_data
Expand All @@ -73,6 +78,9 @@ def build_pypsa_network(
path_to_pypsa_friendly_timeseries_data,
)

if "batteries" in pypsa_friendly_tables.keys():
_add_batteries_to_network(network, pypsa_friendly_tables["batteries"])

if "custom_constraints_generators" in pypsa_friendly_tables.keys():
_add_bus_for_custom_constraints(network)

Expand Down
Loading
Loading