Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ A task is complete when:
- Add or update tests when behavior changes.
- Tests belong only in `/tests`.
- Prefer minimal unit tests over integration tests.
- all testing routines `test_statistics_functions.py` for functions in `statistics_functions.py` must test the output-format. The outputformat MUST be a pandas.Series with Multiindex of ``country`` and ``unit``. It CAN include more levels in the Multiindex.

## Background Information
> [!WARNING]
Expand Down
1 change: 1 addition & 0 deletions pypsa_validation_processing/configs/mapping.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@

Final Energy [by Carrier]|Electricity: Final_Energy_by_Carrier__Electricity
Final Energy [by Sector]|Transportation: Final_Energy_by_Sector__Transportation
Final Energy [by Sector]|Industry: Final_Energy_by_Sector__Industry
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Title case breaks with Pythons convetion to use snake_case for function names. Is there a reason? just wondering

93 changes: 64 additions & 29 deletions pypsa_validation_processing/statistics_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,35 +21,26 @@ def <function_name>(network_collection: pypsa.Network) -> pd.Series:

def Final_Energy_by_Carrier__Electricity(
n: pypsa.Network,
) -> pd.DataFrame:
"""Extract electricity final energy from a PyPSA NetworkCollection.
) -> pd.Series:
"""Extract electricity final energy from a PyPSA Network.

Returns the total electricity consumption (excluding transmission /
distribution losses) across all networks in *network_collection*.
distribution losses)

Parameters
----------
network_collection : pypsa.NetworkCollection
Collection of PyPSA networks to process.
n : pypsa.Network
PyPSA network to process.

Returns
-------
pd.DataFrame
Long-format DataFrame with columns ``variable``, ``unit``, ``year``,
and ``value``. The ``variable`` column contains
``"Final Energy [by Carrier]|Electricity"`` for every row.
pd.Series
Pandas Series with Multiindex of ``country`` and ``unit``

Notes
-----
The actual extraction of electricity final energy from the network
collection will be implemented by the user. A typical call would be::

network_collection.statistics.energy_balance(
comps=["Load"], bus_carrier="AC"
)

The current implementation returns a dummy value of ``0.0 MWh`` for the
year 2020 so that the end-to-end workflow can be tested.
Extracts all withdrawals from elec network. low_voltage is included in AC withdrawal.
Remove discharger afterwards, as battery-connecting links have different carrier names.
"""
# withdrawal from electricity including low_voltage
res = n.statistics.energy_balance(
Expand All @@ -66,24 +57,21 @@ def Final_Energy_by_Carrier__Electricity(

def Final_Energy_by_Sector__Transportation(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about EV charger losses here (notspeaking of V2G, but EV batteries)?

until now I always considered FED as "whats metered at customer". Charger losses would become metered I guess.

n: pypsa.Network,
) -> pd.DataFrame:
"""Extract transportation-sector final energy from a PyPSA NetworkCollection.
) -> pd.Series:
"""Extract transportation-sector final energy from a PyPSA Network.

Returns the total energy consumed by the transportation sector (excluding
transmission / distribution losses) across all networks in
*network_collection*.
transmission / distribution losses)

Parameters
----------
network_collection : pypsa.NetworkCollection
Collection of PyPSA networks to process.
n : pypsa.Network
PyPSA network to process.

Returns
-------
pd.DataFrame
Long-format DataFrame with columns ``variable``, ``unit``, ``year``,
and ``value``. The ``variable`` column contains
``"Final Energy [by Sector]|Transportation"`` for every row.
pd.Series
Pandas Series with Multiindex of ``country`` and ``unit``

Notes
-----
Expand All @@ -103,7 +91,54 @@ def Final_Energy_by_Sector__Transportation(
],
components="Load",
groupby=["carrier", "unit", "country"],
direction="withdrawal",
direction="withdrawal", # for positive values
)
.groupby(["country", "unit"])
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we want to go NUTS - pun intended - we'd need "location" in the groupby. Lets discuss this. I think Daniel has a module prepared that aggregates NUTS levels

.sum()
)
return res


def Final_Energy_by_Sector__Industry(
n: pypsa.Network,
) -> pd.DataFrame:
"""Extract Industry-sector final energy from a PyPSA Network.

Returns the total energy consumed by the Industry sector (excluding
transmission / distribution losses)

Parameters
----------
n : pypsa.Network
PyPSA network to process.

Returns
-------
pd.Series
Pandas Series with Multiindex of ``country`` and ``unit``

Notes
-----
Includes all carriers directly connected to loads in the industry sector. Same Carrier
names are also attached to some links, so components-grouping is needed!
Values are exogenously set, so output values are round numbers!
"""
carriers = [
"coal for industry",
"industry electricity",
"gas for industry",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remember "gas for industry" being a special case:

# n.statistics.energy_balance(groupby=["carrier", "bus_carrier"], bus_carrier="gas for industry", aggregate_across_components=False)
component  carrier              bus_carrier     
Link       gas for industry     gas for industry    1.320132e+08
           gas for industry CC  gas for industry    4.345682e+07
Load       gas for industry     gas for industry   -1.754700e+08

the gas for industry CC Link has efficiencies < 1.0:

# n.links.filter(like="gas for industry CC", axis=0).filter(regex="bus|eff").iloc[0, :].T
bus0                        AL gas
bus1           AL gas for industry
efficiency                     0.9
bus4                              
efficiency4                    1.0
bus3                 AL co2 stored
efficiency3                 0.1881
bus2                co2 atmosphere
efficiency2                 0.0099
Name: AL gas for industry CC-2050, dtype: object

--> the captured CO2 is sequestered and not part of the Load withdrawal.

Following the mentioned logic that defines FED as "whats metered" and assuming that consumers need to pay additional gas for CCS, we'd need sum the gas for industry and gas for industry CC Link withdrawal from gas bus instead:

# n.statistics.withdrawal(groupby=["carrier", "bus_carrier"], bus_carrier="gas", carrier=["gas for industry", "gas for industry CC"], aggregate_across_components=False)
component  carrier              bus_carrier
Link       gas for industry     gas            1.320132e+08
           gas for industry CC  gas            4.828536e+07

sum = 180298535.78097
# n.statistics.energy_balance(groupby=["carrier", "bus_carrier"], bus_carrier="gas for industry", aggregate_across_components=False, comps="Load")
carrier           bus_carrier     
gas for industry  gas for industry   -175470000.0
180298535.78097 / 1e6 - 175470000.0 / 1e6 = 4.82   # TWh

Bottom line is this:
Loads cannot always be used as FED directly if Links feeding their buses have efficiencies < 1.0

"H2 for industry",
"solid biomass for industry",
"industry methanol",
"naphtha for industry",
"low-temperature heat for industry",
]
res = (
n.statistics.energy_balance(
carrier=carriers,
groupby=["carrier", "unit", "country"],
components="Load",
direction="withdrawal", # for positive values
)
.groupby(["country", "unit"])
.sum()
Expand Down
47 changes: 47 additions & 0 deletions tests/test_statistics_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from pypsa_validation_processing.statistics_functions import (
Final_Energy_by_Carrier__Electricity,
Final_Energy_by_Sector__Industry,
Final_Energy_by_Sector__Transportation,
)

Expand Down Expand Up @@ -103,3 +104,49 @@ def test_multiple_networks(self, mock_network_collection: MockNetworkCollection)
assert isinstance(result.index, pd.MultiIndex)
assert result.index.names == ["country", "unit"]
assert len(result) > 0


# ---------------------------------------------------------------------------
# Tests for Final_Energy_by_Sector__Industry
# ---------------------------------------------------------------------------


class TestFinalEnergyBySectorIndustry:
"""Test suite for Final_Energy_by_Sector__Industry function."""

def test_returns_series(self, mock_network: MockPyPSANetwork):
"""Test that the function returns a pandas Series."""
result = Final_Energy_by_Sector__Industry(mock_network)
assert isinstance(result, pd.Series)

def test_has_country_and_unit_multiindex(self, mock_network: MockPyPSANetwork):
"""Test that result has MultiIndex with country and unit levels."""
result = Final_Energy_by_Sector__Industry(mock_network)
assert isinstance(result.index, pd.MultiIndex)
assert result.index.names == ["country", "unit"]

def test_not_empty(self, mock_network: MockPyPSANetwork):
"""Test that result is not empty."""
result = Final_Energy_by_Sector__Industry(mock_network)
assert len(result) > 0

def test_numeric_values(self, mock_network: MockPyPSANetwork):
"""Test that result values are numeric."""
result = Final_Energy_by_Sector__Industry(mock_network)
assert result.dtype in [float, int] or pd.api.types.is_numeric_dtype(
result.dtype
)

def test_contains_austria(self, mock_network: MockPyPSANetwork):
"""Test that result contains Austria (AT) data."""
result = Final_Energy_by_Sector__Industry(mock_network)
assert "AT" in result.index.get_level_values("country")

def test_multiple_networks(self, mock_network_collection: MockNetworkCollection):
"""Test processing multiple networks from collection."""
for network in mock_network_collection:
result = Final_Energy_by_Sector__Industry(network)
assert isinstance(result, pd.Series)
assert isinstance(result.index, pd.MultiIndex)
assert result.index.names == ["country", "unit"]
assert len(result) > 0
Loading