05 · Energy subnetwork (HS-27)

RQ4 — is energy trade a structurally different market? Same 35 economies, only HS chapter 27 (coal, crude, refined products, gas, electricity). Compare concentration, who leads, and where Austria sits against the all-merchandise network.

Why the node set is held fixed, and the caveats that matter for energy (2022 prices, suppliers outside the frame, pipeline-gas misattribution), are on the Data & method page.

Bridge. HS-2716 electrical energy is the customs view of the same physical flow that austria-energy-analysis measures hourly from ENTSO-E.

Show code
from __future__ import annotations

import pandas as pd
from IPython.display import Markdown, display

from eu_trade_network import config, data_loader, energy, graph, viz

FOCUS = "AUT"
TOP_K = 3
LABELS = ("All merchandise", "Energy (HS-27)")

Two networks, one node set

The energy subgraph keeps every economy but only the HS-27 flows between them.

Show code
all_edges = data_loader.build_edgelist()
energy_edges = data_loader.build_edgelist(products=config.HS_ENERGY)

G = graph.build_graph(all_edges)
E = graph.build_graph(energy_edges)

for label, network in zip(LABELS, (G, E), strict=True):
    stats = graph.graph_summary(network)
    print(
        f"{label:>16}: {int(stats['n_nodes'])} economies, {int(stats['n_edges']):>5} links, "
        f"density = {stats['density']:.2f}, "
        f"{stats['total_value_kusd'] / 1e6:>6,.0f} bn USD"
    )

Comparison table

Shares and ranks are computed the same way for both networks, so every row is like-for-like. The Herfindahl index is the sum of squared export shares; its reciprocal is the number of equal-sized exporters that would be as concentrated as what we observe.

Show code
comparison = energy.comparison_table(G, E, focus_iso3=FOCUS, top_k=TOP_K, labels=LABELS)
comparison.style.hide(axis="index")

The figure (RQ4 headline)

Left: how fast cumulative export share piles up as you walk down the exporter ranking — the steeper curve is the more concentrated market. Right: each economy’s share of energy exports against its share of merchandise exports. The dotted diagonal is “energy share = merchandise share”, so everything above it is an economy that punches above its trade weight in energy.

Show code
total_shares = energy.trade_shares(G)
energy_shares = energy.trade_shares(E)

fig = viz.plot_energy_comparison(
    total_shares,
    energy_shares,
    focus_iso3=FOCUS,
    top_k=TOP_K,
    labels=LABELS,
)
out = viz.save_fig(fig, "05_energy_subnetwork.png", headline=True)
print(f"Saved {out}")
fig.show()

What the chapter is made of

HS-27 is not one commodity. Splitting it by 4-digit heading shows what the network trades and what Austria — with no oil or gas of its own — buys and sells.

Show code
flows = energy.load_product_flows()

network_mix = energy.heading_composition(flows)
aut_imports_mix = energy.heading_composition(flows, iso3=FOCUS, direction="imports")
aut_exports_mix = energy.heading_composition(flows, iso3=FOCUS, direction="exports")

mix = (
    network_mix[["hs", "heading", "share"]]
    .rename(columns={"share": "network"})
    .merge(
        aut_imports_mix[["hs", "share"]].rename(columns={"share": f"{FOCUS} imports"}),
        on="hs",
        how="left",
    )
    .merge(
        aut_exports_mix[["hs", "share"]].rename(columns={"share": f"{FOCUS} exports"}),
        on="hs",
        how="left",
    )
    .head(6)
    .fillna(0.0)
)
mix.style.format(
    {"network": "{:.1%}", f"{FOCUS} imports": "{:.1%}", f"{FOCUS} exports": "{:.1%}"}
).hide(axis="index")

Austria’s energy partners

Who Austria buys HS-27 from and who it sells to, as a share of its own energy imports / exports inside this node set.

Show code
def _partner_shares(edges: pd.DataFrame, side: str, partner: str) -> pd.Series:
    """Partner shares of Austria's energy trade on one side of the edge."""
    own = edges.loc[edges[side] == FOCUS]
    totals = own.groupby(partner)["value_kusd"].sum().sort_values(ascending=False)
    return totals / totals.sum()


suppliers = _partner_shares(energy_edges, "importer_iso3", "exporter_iso3")
customers = _partner_shares(energy_edges, "exporter_iso3", "importer_iso3")

top_partners = pd.DataFrame(
    {
        "rank": range(1, 7),
        "supplier": suppliers.index[:6],
        "share of AUT energy imports": suppliers.to_numpy()[:6],
        "customer": customers.index[:6],
        "share of AUT energy exports": customers.to_numpy()[:6],
    }
).set_index("rank")
top_partners.style.format(
    {"share of AUT energy imports": "{:.1%}", "share of AUT energy exports": "{:.1%}"}
)

What the node set does not see

Intra-set import shares for merchandise vs energy (the blind spot quantified). The interpretation is on Data & method.

Show code
sourcing = energy.import_sourcing(focus_iso3=FOCUS, labels=LABELS)
sourcing_view = sourcing.assign(
    imports_world_bn=sourcing["imports_world_kusd"] / 1e6,
    imports_within_bn=sourcing["imports_within_kusd"] / 1e6,
)[["scope", "importer", "imports_world_bn", "imports_within_bn", "intra_share"]]
sourcing_view.style.format(
    {"imports_world_bn": "{:,.0f}", "imports_within_bn": "{:,.0f}", "intra_share": "{:.0%}"}
).hide(axis="index")

What this means (RQ4)

Generated from the run above, so the numbers always match the tables and the figure.

Show code
total_conc = energy.concentration(total_shares, top_k=TOP_K)
energy_conc = energy.concentration(energy_shares, top_k=TOP_K)
total_pos = energy.country_position(total_shares, FOCUS)
energy_pos = energy.country_position(energy_shares, FOCUS)

n_nodes = int(energy_pos["n_economies"])
total_value = float(all_edges["value_kusd"].sum())
energy_value = float(energy_edges["value_kusd"].sum())
top_energy = ", ".join(energy_shares["iso3"].head(TOP_K))
top_total = ", ".join(total_shares["iso3"].head(TOP_K))


def _share(mix_frame: pd.DataFrame, hs: str) -> float:
    """Share of one HS heading in a composition table (0.0 if absent)."""
    row = mix_frame.loc[mix_frame["hs"] == hs, "share"]
    return float(row.iloc[0]) if len(row) else 0.0


def _value(mix_frame: pd.DataFrame, hs: str) -> float:
    """Value (thousand USD) of one HS heading in a composition table."""
    row = mix_frame.loc[mix_frame["hs"] == hs, "value_kusd"]
    return float(row.iloc[0]) if len(row) else 0.0


elec_network = _share(network_mix, "2716")
elec_in, elec_out = _share(aut_imports_mix, "2716"), _share(aut_exports_mix, "2716")
gas_out, refined_out = _share(aut_exports_mix, "2711"), _share(aut_exports_mix, "2710")
electricity = flows.loc[flows["hs"] == "2716"]
elec_customers = ", ".join(
    electricity.loc[electricity["exporter_iso3"] == FOCUS]
    .groupby("importer_iso3")["value_kusd"]
    .sum()
    .sort_values(ascending=False)
    .index[:4]
)
gas_in_bn = _value(aut_imports_mix, "2711") / 1e6
gas_out_bn = _value(aut_exports_mix, "2711") / 1e6
elec_in_bn = _value(aut_imports_mix, "2716") / 1e6
elec_out_bn = _value(aut_exports_mix, "2716") / 1e6
intra = sourcing.set_index(["scope", "importer"])["intra_share"]
node_set = f"{n_nodes}-economy set"

paragraphs = [
    f"**A different market.** Energy is {energy_value / total_value:.0%} of the value in this "
    f"network but a structurally different market. The top {TOP_K} exporters carry "
    f"**{energy_conc['top_k_share']:.0%} of energy exports** ({top_energy}) against "
    f"{total_conc['top_k_share']:.0%} for merchandise as a whole ({top_total}); the Herfindahl "
    f"index is {energy_conc['hhi']:.3f} vs {total_conc['hhi']:.3f}, i.e. "
    f"{energy_conc['effective_exporters']:.0f} effective suppliers instead of "
    f"{total_conc['effective_exporters']:.0f}. The leaders change too: the manufacturing hubs "
    f"that dominate merchandise trade cannot sell resources they do not have, so resource "
    f"exporters take their place — Russia and Norway sit far above the diagonal in the "
    f"right-hand panel, Germany and China well below it. Concentration, not connectivity, is "
    f"again the risk — the energy graph is nearly complete (density "
    f"{float(len(energy_edges)) / (n_nodes * (n_nodes - 1)):.2f}), so everyone trades with "
    f"everyone; the volume just sits on very few sources.",
    f"**Caveat on the year.** {config.YEAR} is the energy-crisis year: BACI values are USD, so "
    f"the energy subnetwork's {energy_value / total_value:.0%} value share reflects prices as "
    f"much as volumes, and Russia still leads the {config.YEAR} table despite sanctions that "
    f"were only partly in force. Read the concentration ranking as a snapshot of a market under "
    f"stress, not a structural constant.",
    f"**Austria.** Austria is {int(energy_pos['export_rank'])}th of {n_nodes} by energy exports "
    f"({energy_pos['export_share']:.1%} of the network's energy) and "
    f"{int(energy_pos['import_rank'])}th by energy imports — versus "
    f"{int(total_pos['export_rank'])}th and {int(total_pos['import_rank'])}th for merchandise. "
    f"It is a net energy importer ({energy_pos['net_export_kusd'] / 1e6:+,.1f} bn USD). The "
    f"ranking flatters it: Austria produces almost no fossil fuel, so its energy exports are "
    f"re-exports and transit — {elec_out:.0%} electricity, {gas_out:.0%} gas and "
    f"{refined_out:.0%} refined products passed on to neighbours. Its imports are equally "
    f"intermediated: {suppliers.iloc[0]:.0%} arrive from {suppliers.index[0]}, which is a "
    f"routing fact, not a production one.",
    f"**The frame misses the real suppliers.** Only {intra[(LABELS[1], node_set)]:.0%} of this "
    f"node set's energy imports come from inside it, against "
    f"{intra[(LABELS[0], node_set)]:.0%} for merchandise; for Austria it is "
    f"{intra[(LABELS[1], FOCUS)]:.0%} vs {intra[(LABELS[0], FOCUS)]:.0%}. Europe's crude and LNG "
    f"largely originate outside the {n_nodes} economies modelled here (the Gulf, Kazakhstan, "
    f"North and West Africa). The energy subgraph therefore describes how energy is "
    f"*redistributed* within Europe, not where it ultimately comes from — a full dependence "
    f"analysis needs the world node set.",
    f"**Pipeline gas is badly captured — do not read dependence off it.** Austria's recorded "
    f"HS-2711 (gas) imports inside this node set are only {gas_in_bn:,.1f} bn USD while its gas "
    f"exports are {gas_out_bn:,.1f} bn, which would make Austria a net gas exporter in "
    f"{config.YEAR}. It was not: it burned mostly Russian pipeline gas. Customs statistics "
    f"attribute a flow to the declared consignment partner, so gas crossing hubs and traded "
    f"contracts lands on the wrong edge. Treat merchandise data as evidence about *value "
    f"routed*, and use physical flow data for dependence.",
    f"**Bridge to [austria-energy-analysis](https://github.com/fatemeh-studio/"
    f"austria-energy-analysis).** Electricity (HS-2716) is {elec_network:.0%} of the energy "
    f"network's value but {elec_out:.0%} of Austria's energy exports and {elec_in:.0%} of its "
    f"energy imports — {elec_out_bn:,.1f} bn USD out against {elec_in_bn:,.1f} bn in, an almost "
    f"perfectly balanced position with its neighbours ({elec_customers}). That is the same "
    f"physical flow the sibling project measures hourly from ENTSO-E: there, Austria's renewable "
    f"share of electricity reaches 86% (2024), gas generation falls by a third, and each GW of "
    f"wind+solar cuts the day-ahead price by 14.3 EUR/MWh. Read together: this project locates "
    f"Austria in Europe's energy exchange (mid-sized, intermediating, import-dependent), the "
    f"other one explains what the exchange is for.",
]

display(Markdown("\n\n".join(paragraphs)))