Skip to main content

Calculate a monthly grid fee

This guide calculates what a customer pays in grid fees for one month, from a tariff and the customer's hourly consumption. You can use it to show customers their expected cost, to compare tariffs, or to see how much moving consumption would save.

What you need​

  • A tariff, fetched with GET /tariffs/{id}. The example uses GN6KW ("Tidsindelad 6 kW, Max 63A"), which has a higher power fee on weekdays 07:00–20:00 in winter.
  • Hourly consumption for the month: the average power in kW for each hour. This is the same number as the kWh used in that hour. You get it from the customer's meter, for example through Home Assistant or the meter's own API. The example generates made-up data instead.

How it works​

The code goes through the tariff's three fee groups (see Fees and price components):

  1. Fixed price: the annual fee divided by 12.
  2. Energy price: for each component, the kWh used while the component applies × the price. This includes energy tax.
  3. Power price:
    • For peak components: for each day, take the highest hour while the component applies. Then take the average of the highest days (usually 3) and multiply by the price.
    • For dynamic components: fetch the hourly prices for the month and add up power × price for each hour.

A component only counts in the hours where it applies: its validPeriod must cover the date, and the time and day must match its recurringPeriods and calendar patterns. See When a component applies.

Code​

monthly_cost.py
"""Calculate the grid fee for one month, from a tariff and hourly consumption.

Requires Python 3.9 or later. Run with: python monthly_cost.py
"""
import json
import urllib.parse
import urllib.request
from datetime import date, datetime, timedelta

BASE_URL = "https://api.uat.goteborgenergi.cloud/gridtariff/v0"
TARIFF_ID = "3e90a7bf-fd2a-4134-8c79-49611e7fffef" # GN6KW, 2026
MONTH = "2026-02"
# Name your application, so it can be told apart from other clients.
USER_AGENT = "grid-tariff-example/1.0"


def example_consumption():
"""Hourly consumption for the month.

Each entry is the average power in kW during the hour, which equals the
energy in kWh used that hour. Timestamps are in Swedish local time with
UTC offset, like in the API. Replace this with data from your meter.
"""
hours = []
for day in range(1, 29):
for hour in range(24):
evening = 17 <= hour < 21
hours.append({
"start": f"{MONTH}-{day:02d}T{hour:02d}:00:00+01:00",
"kW": 3 + (day % 5) * 0.4 if evening else 0.8,
})
return hours


def get_json(path):
request = urllib.request.Request(BASE_URL + path, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(request) as response:
return json.load(response)


def in_calendar_pattern(day, reference, calendar_patterns):
pattern = next(p for p in calendar_patterns if p["reference"] == reference)
weekday = date.fromisoformat(day).isoweekday() # Monday = 1 ... Sunday = 7
return weekday in pattern["days"] or day in pattern["dates"]


def is_active(component, start, calendar_patterns):
"""Is the component active during the hour starting at `start`?"""
day, time = start[:10], start[11:19]
valid = component["validPeriod"]
if not valid["fromIncluding"] <= day < valid["toExcluding"]:
return False
if not component.get("recurringPeriods"):
return True

for recurring in component["recurringPeriods"]:
if recurring["frequency"] != "P1D":
raise ValueError(f"Unsupported frequency {recurring['frequency']}")
for period in recurring["activePeriods"]:
to = "24:00:00" if period["toExcluding"] == "00:00:00" else period["toExcluding"]
refs = period["calendarPatternReferences"]
if (
period["fromIncluding"] <= time < to
and any(in_calendar_pattern(day, r, calendar_patterns) for r in refs["include"])
and not any(in_calendar_pattern(day, r, calendar_patterns) for r in refs["exclude"])
):
return True
return False


def peak_power(component, consumption, calendar_patterns):
"""Average of the N highest daily peaks among the hours where the component is active."""
settings = component["peakIdentificationSettings"]
if settings["peakIdentificationPeriod"] != "P1D" or settings["peakDuration"] != "PT1H":
raise ValueError(f"Unsupported peak settings in {component['name']}")
daily_peaks = {}
for hour in consumption:
if is_active(component, hour["start"], calendar_patterns):
day = hour["start"][:10]
daily_peaks[day] = max(daily_peaks.get(day, 0), hour["kW"])
highest = sorted(daily_peaks.values(), reverse=True)
highest = highest[: settings["numberOfPeaksForAverageCalculation"]]
return sum(highest) / len(highest) if highest else 0


def hourly_prices(component, consumption):
start = consumption[0]["start"]
end = (datetime.fromisoformat(consumption[-1]["start"]) + timedelta(hours=1)).isoformat()
query = urllib.parse.urlencode({
"componentId": component["id"],
"fromIncluding": start,
"toExcluding": end,
})
prices = get_json(f"/prices?{query}")
return {datetime.fromisoformat(p["start"]): p for p in prices["actual"] + prices["preview"]}


def monthly_cost(tariff, calendar_patterns, consumption):
lines = []

def add(name, quantity, price, factor):
lines.append({
"name": name,
"quantity": quantity,
"exVat": factor * price["priceExVat"],
"incVat": factor * price["priceIncVat"],
})

month_start = consumption[0]["start"][:7] + "-01"

for c in (tariff.get("fixedPrice") or {}).get("components", []):
valid = c["validPeriod"]
if not valid["fromIncluding"] <= month_start < valid["toExcluding"]:
continue
per_month = {"P1Y": 1 / 12, "P1M": 1}.get(c["pricedPeriod"])
if per_month is None:
raise ValueError(f"Unsupported pricedPeriod {c['pricedPeriod']}")
add(c["name"], "1 month" if per_month == 1 else "1/12 year", c["price"], per_month)

for c in (tariff.get("energyPrice") or {}).get("components", []):
if c["type"] != "fixed":
raise ValueError(f"Unsupported energy component type {c['type']}")
kwh = sum(h["kW"] for h in consumption if is_active(c, h["start"], calendar_patterns))
if kwh > 0:
add(c["name"], f"{kwh:.1f} kWh", c["price"], kwh)

for c in (tariff.get("powerPrice") or {}).get("components", []):
if c["type"] == "peak":
if c["unit"] != "kW":
raise ValueError(f"Unsupported unit {c['unit']} in {c['name']}")
kw = peak_power(c, consumption, calendar_patterns)
if kw > 0:
add(c["name"], f"{kw:.2f} kW", c["price"], kw)
elif c["type"] == "dynamic":
active = [h for h in consumption if is_active(c, h["start"], calendar_patterns)]
if not active:
continue
prices = hourly_prices(c, active)
ex_vat = inc_vat = 0
for hour in active:
price = prices.get(datetime.fromisoformat(hour["start"]))
if price is None:
raise ValueError(f"No price for {hour['start']}")
ex_vat += hour["kW"] * price["priceExVat"]
inc_vat += hour["kW"] * price["priceIncVat"]
lines.append({"name": c["name"], "quantity": f"{len(active)} hours",
"exVat": ex_vat, "incVat": inc_vat})
else:
raise ValueError(f"Unsupported power component type {c['type']}")
return lines


response = get_json(f"/tariffs/{TARIFF_ID}")
tariff = response["tariff"]
lines = monthly_cost(tariff, response["calendarPatterns"], example_consumption())

print(f"{tariff['name']} ({tariff['product']}), {MONTH}\n")
for line in lines:
print(f"{line['name']:<32} {line['quantity']:>12} {line['incVat']:>10.2f} SEK")
print(f"{'Total incl. VAT':<45} {sum(l['incVat'] for l in lines):>10.2f} SEK")
print(f"{'Total excl. VAT':<45} {sum(l['exVat'] for l in lines):>10.2f} SEK")

Example output:

Tidsindelad 6 kW, Max 63A (GN6KW), 2026-02

Fast avgift 1/12 year 200.00 SEK
Elöverföringsavgift 873.6 kWh 56.78 SEK
Energiskatt 873.6 kWh 393.12 SEK
Tidsindelad, effektavgift hög 4.60 kW 621.00 SEK
Tidsindelad, effektavgift låg 4.60 kW 0.00 SEK
Total incl. VAT 1270.90 SEK
Total excl. VAT 1016.72 SEK

Limitations​

The example covers the components that Göteborg Energi's tariffs for households and smaller businesses use. It stops with an error if it meets something it doesn't handle, rather than giving a wrong answer:

  • Components that need other data than hourly power, such as reactive power (kVAr) or a subscribed power level.
  • Peaks measured over other periods than one hour per day.

It also assumes that all consumption timestamps are in Swedish local time and that the whole month uses the same tariff version.

tip

When you send a time range to /prices, don't include fractional seconds: 2026-02-01T00:00:00+01:00 and 2026-01-31T23:00:00Z work, but 2026-01-31T23:00:00.000Z is rejected. In JavaScript, toISOString() always adds .000, so remove it.